diff --git a/eslint.config.js b/eslint.config.js index feab8a77..f980e211 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -69,9 +69,11 @@ export default defineConfig( flagWords: ["cancellation", "cancelled"], // prefer en-US spelling for consistency ignoreWords: [ "arktype", + "healthz", "heartbeating", "idempotently", "openworkflow", + "readyz", "sonarjs", "timestamptz", ], diff --git a/knip.json b/knip.json index 0e5b8064..66882a50 100644 --- a/knip.json +++ b/knip.json @@ -19,7 +19,8 @@ "**/openworkflow.config.*", "openworkflow/**/*.ts", "packages/dashboard/src/components/ui/*.tsx", - "packages/docs/style.css" + "packages/docs/style.css", + "packages/openworkflow/http.ts" ], "ignoreIssues": { "packages/dashboard/src/components/ui/*.tsx": ["exports"], diff --git a/package-lock.json b/package-lock.json index 2b479887..f540e9e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2738,9 +2738,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -3276,6 +3276,10 @@ "resolved": "packages/docs", "link": true }, + "node_modules/@openworkflow/server": { + "resolved": "packages/server", + "link": true + }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.121.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.121.0.tgz", @@ -10698,9 +10702,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -16965,6 +16969,7 @@ "openworkflow": "dist/cli.js" }, "devDependencies": { + "@openworkflow/server": "*", "openworkflow": "*", "vitest": "^4.0.18" }, @@ -17129,6 +17134,24 @@ "optional": true } } + }, + "packages/server": { + "name": "@openworkflow/server", + "dependencies": { + "@hono/node-server": "^1.19.14", + "hono": "^4.12.14", + "zod": "^4.3.6" + }, + "devDependencies": { + "openworkflow": "*", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "openworkflow": "^0.9.0" + } } } } diff --git a/packages/cli/cli.ts b/packages/cli/cli.ts index 228fd759..09b4eb11 100644 --- a/packages/cli/cli.ts +++ b/packages/cli/cli.ts @@ -5,6 +5,7 @@ import { doctor, getVersion, init, + serverStart, workerStart, } from "./commands.js"; import { withErrorHandling } from "./errors.js"; @@ -59,4 +60,17 @@ program .option("--config ", "path to OpenWorkflow config file") .action(withErrorHandling(dashboard)); +// server +const serverCmd = program + .command("server") + .description("manage the API server"); + +// server start +serverCmd + .command("start") + .description("start the HTTP API server") + .option("-p, --port ", "port to listen on", Number.parseInt) + .option("--config ", "path to OpenWorkflow config file") + .action(withErrorHandling(serverStart)); + await program.parseAsync(process.argv); diff --git a/packages/cli/commands.test.ts b/packages/cli/commands.test.ts index 4176cf70..18e93768 100644 --- a/packages/cli/commands.test.ts +++ b/packages/cli/commands.test.ts @@ -5,7 +5,7 @@ import { getConfigFileName, getExampleWorkflowFileName, getRunFileName, - validateDashboardPort, + validatePort, } from "./commands.js"; import fs from "node:fs"; import os from "node:os"; @@ -164,27 +164,29 @@ describe("getDashboardSpawnOptions", () => { }); }); -describe("validateDashboardPort", () => { +describe("validatePort", () => { test("returns undefined when no custom port is provided", () => { - expect(validateDashboardPort()).toBeUndefined(); + expect(validatePort(undefined, "dashboard")).toBeUndefined(); }); test("returns the port when it is within range", () => { - expect(validateDashboardPort(3001)).toBe(3001); + expect(validatePort(3001, "dashboard")).toBe(3001); }); test("throws for non-integer ports", () => { - expect(() => validateDashboardPort(Number.NaN)).toThrow( + expect(() => validatePort(Number.NaN, "dashboard")).toThrow( "Invalid dashboard port.", ); - expect(() => validateDashboardPort(3000.5)).toThrow( + expect(() => validatePort(3000.5, "dashboard")).toThrow( "Invalid dashboard port.", ); }); test("throws for out-of-range ports", () => { - expect(() => validateDashboardPort(0)).toThrow("Invalid dashboard port."); - expect(() => validateDashboardPort(65_536)).toThrow( + expect(() => validatePort(0, "dashboard")).toThrow( + "Invalid dashboard port.", + ); + expect(() => validatePort(65_536, "dashboard")).toThrow( "Invalid dashboard port.", ); }); diff --git a/packages/cli/commands.ts b/packages/cli/commands.ts index db1439f1..b091069e 100644 --- a/packages/cli/commands.ts +++ b/packages/cli/commands.ts @@ -35,7 +35,7 @@ interface CommandOptions { config?: string; } -interface DashboardOptions extends CommandOptions { +interface PortedOptions extends CommandOptions { port?: number; } @@ -283,21 +283,13 @@ export async function workerStart( const ow = new OpenWorkflow({ backend }); let worker: ReturnType | null = null; - let shuttingDown = false; - - /** Stop the worker on process shutdown. */ - async function gracefulShutdown(): Promise { - if (shuttingDown) return; - shuttingDown = true; - - consola.warn("Shutting down worker..."); - try { + const gracefulShutdown = registerGracefulShutdown({ + noun: "worker", + stopApp: async () => { await worker?.stop(); - } finally { - await backend.stop(); - } - consola.success("Worker stopped"); - } + }, + backend, + }); try { // discover and import workflows @@ -330,9 +322,6 @@ export async function workerStart( worker = ow.newWorker(workerOptions); - process.on("SIGINT", () => void gracefulShutdown()); - process.on("SIGTERM", () => void gracefulShutdown()); - await worker.start(); consola.success("Worker started."); } catch (error) { @@ -369,19 +358,23 @@ export function getDashboardSpawnOptions(port?: number): { } /** - * Validate dashboard port option. - * @param port - Optional dashboard port. - * @returns Validated dashboard port. - * @throws {CLIError} If the provided port is not an integer in the 1-65535 range. + * Validate a port option. + * @param port - Optional port number. + * @param label - Label used in the error message (e.g. "dashboard", "server"). + * @returns Validated port, or undefined if not provided. + * @throws {CLIError} If the port is not an integer in the 1-65535 range. */ -export function validateDashboardPort(port?: number): number | undefined { +export function validatePort( + port: number | undefined, + label: string, +): number | undefined { if (port === undefined) { return undefined; } if (!Number.isInteger(port) || port < 1 || port > 65_535) { throw new CLIError( - "Invalid dashboard port.", + `Invalid ${label} port.`, "Use an integer between 1 and 65535, for example `--port 3001`.", ); } @@ -394,9 +387,9 @@ export function validateDashboardPort(port?: number): number | undefined { * @param options - Dashboard command options. * @returns Resolves when the dashboard process exits. */ -export async function dashboard(options: DashboardOptions = {}): Promise { +export async function dashboard(options: PortedOptions = {}): Promise { const configPath = options.config; - const port = validateDashboardPort(options.port); + const port = validatePort(options.port, "dashboard"); consola.start("Starting dashboard..."); const { configFile } = await loadConfigWithEnv(configPath); @@ -458,8 +451,106 @@ export async function dashboard(options: DashboardOptions = {}): Promise { }); } +/** + * openworkflow server start + * @param options - Server start options. + */ +export async function serverStart(options: PortedOptions = {}): Promise { + const port = validatePort(options.port, "server") ?? 3000; + consola.start("Starting server..."); + + const { configFile, config } = await loadConfigWithEnv(options.config); + if (!configFile) { + throw new CLIError( + "No config file found.", + "Run `npx @openworkflow/cli init` to create a config file.", + ); + } + consola.info(`Using config: ${configFile}`); + + const backend = config.backend; + let handle: { close(): Promise } | null = null; + const gracefulShutdown = registerGracefulShutdown({ + noun: "server", + stopApp: async () => { + await handle?.close(); + }, + backend, + }); + + try { + // dynamic to defer hono's ~150KB until `server start` is invoked + // this is not ideal and will be addressed before release + let serverModule: typeof import("@openworkflow/server"); + try { + serverModule = await import("@openworkflow/server"); + } catch { + throw new CLIError( + "@openworkflow/server is not installed.", + 'Install it to enable the "server start" command: `npm install @openworkflow/server`.', + ); + } + const { createServer, serve } = serverModule; + + const server = createServer(backend, { + logRequests: true, + onError: (error, ctx) => { + consola.error(`[${ctx.method} ${ctx.path}]`, error); + }, + }); + handle = serve(server, { port }); + consola.success(`Server listening on http://localhost:${String(port)}`); + } catch (error) { + await gracefulShutdown(); + throw error; + } +} + // ----------------------------------------------------------------------------- +interface ShutdownOptions { + /** Lower-case name of the thing being stopped (e.g. "worker", "server"). */ + noun: string; + /** App-level stop — `worker.stop` or `handle.close`. */ + stopApp: () => Promise; + /** Backend whose `stop()` runs last, even if `stopApp` throws. */ + backend: { stop: () => Promise }; +} + +/** + * Wire SIGINT/SIGTERM to a graceful shutdown. `stopApp` runs first; the + * backend is stopped even if `stopApp` throws. + * @param options - What to stop on shutdown + * @returns The shutdown function + */ +function registerGracefulShutdown( + options: ShutdownOptions, +): () => Promise { + let shuttingDown = false; + /** + * Stop the app and backend; idempotent against repeat signals. + * @returns Resolves when both are stopped + */ + async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; + consola.warn(`Shutting down ${options.noun}...`); + try { + await options.stopApp(); + } finally { + await options.backend.stop(); + } + const capitalized = + options.noun.charAt(0).toUpperCase() + options.noun.slice(1); + consola.success(`${capitalized} stopped`); + } + // `once` so repeated worker/serverStart invocations (e.g. programmatic use, + // tests) don't accumulate listeners and trigger MaxListenersExceededWarning. + process.once("SIGINT", () => void shutdown()); + process.once("SIGTERM", () => void shutdown()); + return shutdown; +} + /** * Get workflow directories from config. * @param config - The loaded config @@ -827,11 +918,6 @@ async function discoverWorkflowsInDirs( return { files, workflows }; } -/** - * Get the config template for a backend choice. - * @param backendChoice - The selected backend choice - * @returns The config template string - */ /** * Get the client template for a backend choice. * @param backendChoice - The selected backend choice diff --git a/packages/cli/package.json b/packages/cli/package.json index c6bd3852..91b3900a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,6 +35,7 @@ "nypm": "^0.6.5" }, "devDependencies": { + "@openworkflow/server": "*", "openworkflow": "*", "vitest": "^4.0.18" }, diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b3db5ed0..58ef4037 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "dist" }, - "references": [{ "path": "../openworkflow" }], + "references": [{ "path": "../openworkflow" }, { "path": "../server" }], "include": ["**/*.ts"], "exclude": ["dist"] } diff --git a/packages/openworkflow/core/error.test.ts b/packages/openworkflow/core/error.test.ts index 38d442cb..e911a845 100644 --- a/packages/openworkflow/core/error.test.ts +++ b/packages/openworkflow/core/error.test.ts @@ -1,4 +1,9 @@ -import { deserializeError, serializeError, wrapError } from "./error.js"; +import { + BackendError, + deserializeError, + serializeError, + wrapError, +} from "./error.js"; import { describe, expect, test } from "vitest"; describe("serializeError", () => { @@ -126,3 +131,13 @@ describe("deserializeError", () => { expect(restored.name).toBe(original.name); }); }); + +describe("BackendError", () => { + test("sets code, message, and name", () => { + const error = new BackendError("NOT_FOUND", "run not found"); + expect(error.code).toBe("NOT_FOUND"); + expect(error.message).toBe("run not found"); + expect(error.name).toBe("BackendError"); + expect(error).toBeInstanceOf(Error); + }); +}); diff --git a/packages/openworkflow/core/error.ts b/packages/openworkflow/core/error.ts index 4fed6953..931c5cd4 100644 --- a/packages/openworkflow/core/error.ts +++ b/packages/openworkflow/core/error.ts @@ -7,6 +7,34 @@ export interface SerializedError { [key: string]: JsonValue; } +/** + * Runtime tuple of backend error codes; {@link BackendErrorCode} is derived + * from it. + */ +export const BACKEND_ERROR_CODES = ["NOT_FOUND", "CONFLICT"] as const; + +export type BackendErrorCode = (typeof BACKEND_ERROR_CODES)[number]; + +/** + * Type guard for {@link BackendErrorCode}. + * @param code - The string to test + * @returns True if `code` is a known backend error code + */ +export function isBackendErrorCode(code: string): code is BackendErrorCode { + return (BACKEND_ERROR_CODES as readonly string[]).includes(code); +} + +// eslint-disable-next-line functional/no-classes, functional/no-class-inheritance +export class BackendError extends Error { + readonly code: BackendErrorCode; + + constructor(code: BackendErrorCode, message: string) { + super(message); + this.name = "BackendError"; + this.code = code; + } +} + /** * Serialize an error to a JSON-compatible format. * @param error - The error to serialize (can be Error instance or any value) diff --git a/packages/openworkflow/core/step-attempt.ts b/packages/openworkflow/core/step-attempt.ts index 3730db67..67e14978 100644 --- a/packages/openworkflow/core/step-attempt.ts +++ b/packages/openworkflow/core/step-attempt.ts @@ -4,15 +4,19 @@ import type { JsonValue } from "./json.js"; import type { Result } from "./result.js"; import { err, ok } from "./result.js"; +/** Runtime tuple of step kinds; {@link StepKind} is derived from it. */ +export const STEP_KINDS = [ + "function", + "sleep", + "workflow", + "signal-send", + "signal-wait", +] as const; + /** * The kind of step in a workflow. */ -export type StepKind = - | "function" - | "sleep" - | "workflow" - | "signal-send" - | "signal-wait"; +export type StepKind = (typeof STEP_KINDS)[number]; /** * Status of a step attempt through its lifecycle. diff --git a/packages/openworkflow/http.ts b/packages/openworkflow/http.ts new file mode 100644 index 00000000..fd41de9a --- /dev/null +++ b/packages/openworkflow/http.ts @@ -0,0 +1 @@ +export { BackendHttp, type BackendHttpOptions } from "./http/backend.js"; diff --git a/packages/openworkflow/http/backend.ts b/packages/openworkflow/http/backend.ts new file mode 100644 index 00000000..e7c0ed26 --- /dev/null +++ b/packages/openworkflow/http/backend.ts @@ -0,0 +1,423 @@ +import type { + Backend, + CancelWorkflowRunParams, + ClaimWorkflowRunParams, + CompleteStepAttemptParams, + CompleteWorkflowRunParams, + CreateStepAttemptParams, + CreateWorkflowRunParams, + ExtendWorkflowRunLeaseParams, + FailStepAttemptParams, + FailWorkflowRunParams, + GetSignalDeliveryParams, + GetStepAttemptParams, + GetWorkflowRunParams, + ListStepAttemptsParams, + ListWorkflowRunsParams, + PaginatedResponse, + RescheduleWorkflowRunAfterFailedStepAttemptParams, + SendSignalParams, + SendSignalResult, + SetStepAttemptChildWorkflowRunParams, + SleepWorkflowRunParams, + WorkflowRunCounts, +} from "../core/backend.js"; +import { BackendError, isBackendErrorCode } from "../core/error.js"; +import type { JsonValue } from "../core/json.js"; +import type { StepAttempt } from "../core/step-attempt.js"; +import type { WorkflowRun } from "../core/workflow-run.js"; + +const WORKFLOW_RUN_DATE_FIELDS = [ + "availableAt", + "deadlineAt", + "startedAt", + "finishedAt", + "createdAt", + "updatedAt", +] as const; + +const STEP_ATTEMPT_DATE_FIELDS = [ + "startedAt", + "finishedAt", + "createdAt", + "updatedAt", +] as const; + +/** + * Mutate ISO-8601 string fields in `raw` into `Date` instances in place. + * @param raw - Raw JSON object from the server + * @param fields - Names of date fields to convert + */ +function parseDates( + raw: Record, + fields: readonly string[], +): void { + for (const field of fields) { + const value = raw[field]; + if (typeof value === "string") { + raw[field] = new Date(value); + } + } +} + +/** + * Parse a JSON workflow run payload, converting date fields. + * @param raw - Raw JSON object from the server + * @returns A fully-typed {@link WorkflowRun} + */ +function parseWorkflowRun(raw: Record): WorkflowRun { + parseDates(raw, WORKFLOW_RUN_DATE_FIELDS); + return raw as unknown as WorkflowRun; +} + +/** + * Parse a JSON step attempt payload, converting date fields. + * @param raw - Raw JSON object from the server + * @returns A fully-typed {@link StepAttempt} + */ +function parseStepAttempt(raw: Record): StepAttempt { + parseDates(raw, STEP_ATTEMPT_DATE_FIELDS); + return raw as unknown as StepAttempt; +} + +/** + * Build a `?limit=&after=&before=` query string (empty if no params set). + * @param params - Pagination parameters + * @param params.limit - Page size + * @param params.after - Cursor for the next page + * @param params.before - Cursor for the previous page + * @returns Query string including the leading `?`, or an empty string + */ +function buildPaginationQuery(params: { + limit?: number; + after?: string; + before?: string; +}): string { + const search = new URLSearchParams(); + if (params.limit !== undefined) search.set("limit", String(params.limit)); + if (params.after) search.set("after", params.after); + if (params.before) search.set("before", params.before); + const qs = search.toString(); + return qs ? `?${qs}` : ""; +} + +/** + * Parse a `{ data, pagination }` list response, mapping each item. + * @param res - Fetch response containing the paginated body + * @param parseItem - Per-item parser + * @returns The parsed page + */ +async function parsePaginatedResponse( + res: globalThis.Response, + parseItem: (raw: Record) => T, +): Promise> { + const body = (await res.json()) as { + data: Record[]; + pagination: { next: string | null; prev: string | null }; + }; + return { + data: body.data.map((r) => parseItem(r)), + pagination: body.pagination, + }; +} + +/** + * Options for {@link BackendHttp}. + */ +export interface BackendHttpOptions { + /** Base URL of the OpenWorkflow server (e.g. "http://localhost:3000"). */ + url: string; + /** Custom fetch implementation. Defaults to `globalThis.fetch`. */ + fetch?: typeof globalThis.fetch; +} + +/** + * Backend implementation that communicates with an OpenWorkflow HTTP server. + */ +export class BackendHttp implements Backend { + private readonly baseUrl: string; + private readonly _fetch: typeof globalThis.fetch; + + constructor(options: BackendHttpOptions) { + let url = options.url; + while (url.endsWith("/")) { + url = url.slice(0, -1); + } + this.baseUrl = url; + this._fetch = options.fetch ?? globalThis.fetch; + } + + async createWorkflowRun( + params: Readonly, + ): Promise { + return this.postWorkflowRun("/v0/workflow-runs", { + workflowName: params.workflowName, + version: params.version, + idempotencyKey: params.idempotencyKey, + config: params.config, + context: params.context, + input: params.input, + parentStepAttemptNamespaceId: params.parentStepAttemptNamespaceId, + parentStepAttemptId: params.parentStepAttemptId, + availableAt: params.availableAt?.toISOString() ?? null, + deadlineAt: params.deadlineAt?.toISOString() ?? null, + }); + } + + async getWorkflowRun( + params: Readonly, + ): Promise { + const res = await this.fetch(`/v0/workflow-runs/${params.workflowRunId}`); + if (res.status === 404) return null; + await this.assertOk(res); + return parseWorkflowRun((await res.json()) as Record); + } + + async listWorkflowRuns( + params: Readonly, + ): Promise> { + const path = `/v0/workflow-runs${buildPaginationQuery(params)}`; + const res = await this.fetch(path); + await this.assertOk(res); + return parsePaginatedResponse(res, parseWorkflowRun); + } + + async countWorkflowRuns(): Promise { + const res = await this.fetch("/v0/workflow-runs:count"); + await this.assertOk(res); + return (await res.json()) as WorkflowRunCounts; + } + + async claimWorkflowRun( + params: Readonly, + ): Promise { + const res = await this.fetchPost("/v0/workflow-runs:claim", params); + if (res.status === 204) return null; + await this.assertOk(res); + return parseWorkflowRun((await res.json()) as Record); + } + + async extendWorkflowRunLease( + params: Readonly, + ): Promise { + return this.postWorkflowRun( + `/v0/workflow-runs/${params.workflowRunId}:extendLease`, + { + workerId: params.workerId, + leaseDurationMs: params.leaseDurationMs, + }, + ); + } + + async sleepWorkflowRun( + params: Readonly, + ): Promise { + return this.postWorkflowRun( + `/v0/workflow-runs/${params.workflowRunId}:sleep`, + { + workerId: params.workerId, + availableAt: params.availableAt.toISOString(), + }, + ); + } + + async completeWorkflowRun( + params: Readonly, + ): Promise { + return this.postWorkflowRun( + `/v0/workflow-runs/${params.workflowRunId}:complete`, + { + workerId: params.workerId, + output: params.output, + }, + ); + } + + async failWorkflowRun( + params: Readonly, + ): Promise { + return this.postWorkflowRun( + `/v0/workflow-runs/${params.workflowRunId}:fail`, + { + workerId: params.workerId, + error: params.error, + retryPolicy: params.retryPolicy, + ...(params.attempts === undefined ? {} : { attempts: params.attempts }), + ...(params.deadlineAt === undefined + ? {} + : { deadlineAt: params.deadlineAt?.toISOString() ?? null }), + }, + ); + } + + async rescheduleWorkflowRunAfterFailedStepAttempt( + params: Readonly, + ): Promise { + return this.postWorkflowRun( + `/v0/workflow-runs/${params.workflowRunId}:reschedule`, + { + workerId: params.workerId, + error: params.error, + availableAt: params.availableAt.toISOString(), + }, + ); + } + + async cancelWorkflowRun( + params: Readonly, + ): Promise { + return this.postWorkflowRun( + `/v0/workflow-runs/${params.workflowRunId}:cancel`, + {}, + ); + } + + async createStepAttempt( + params: Readonly, + ): Promise { + return this.postStepAttempt( + `/v0/workflow-runs/${params.workflowRunId}/step-attempts`, + { + workerId: params.workerId, + stepName: params.stepName, + kind: params.kind, + config: params.config, + context: params.context, + }, + ); + } + + async getStepAttempt( + params: Readonly, + ): Promise { + const res = await this.fetch(`/v0/step-attempts/${params.stepAttemptId}`); + if (res.status === 404) return null; + await this.assertOk(res); + return parseStepAttempt((await res.json()) as Record); + } + + async listStepAttempts( + params: Readonly, + ): Promise> { + const path = `/v0/workflow-runs/${params.workflowRunId}/step-attempts${buildPaginationQuery(params)}`; + const res = await this.fetch(path); + await this.assertOk(res); + return parsePaginatedResponse(res, parseStepAttempt); + } + + async completeStepAttempt( + params: Readonly, + ): Promise { + return this.postStepAttempt( + `/v0/step-attempts/${params.stepAttemptId}:complete`, + { + workflowRunId: params.workflowRunId, + workerId: params.workerId, + output: params.output, + }, + ); + } + + async failStepAttempt( + params: Readonly, + ): Promise { + return this.postStepAttempt( + `/v0/step-attempts/${params.stepAttemptId}:fail`, + { + workflowRunId: params.workflowRunId, + workerId: params.workerId, + error: params.error, + }, + ); + } + + async setStepAttemptChildWorkflowRun( + params: Readonly, + ): Promise { + return this.postStepAttempt( + `/v0/step-attempts/${params.stepAttemptId}:setChildWorkflowRun`, + { + workflowRunId: params.workflowRunId, + workerId: params.workerId, + childWorkflowRunNamespaceId: params.childWorkflowRunNamespaceId, + childWorkflowRunId: params.childWorkflowRunId, + }, + ); + } + + async sendSignal( + params: Readonly, + ): Promise { + const res = await this.fetchPost("/v0/signals:send", params); + await this.assertOk(res); + return (await res.json()) as SendSignalResult; + } + + async getSignalDelivery( + params: Readonly, + ): Promise { + const res = await this.fetch( + `/v0/signal-deliveries/${params.stepAttemptId}`, + ); + if (res.status === 204) return undefined; + await this.assertOk(res); + return (await res.json()) as JsonValue; + } + + async stop(): Promise { + // No persistent connection to close. + } + + private async fetch(path: string): Promise { + return this._fetch(`${this.baseUrl}${path}`); + } + + private async fetchPost( + path: string, + body: Record, + ): Promise { + return this._fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + private async postWorkflowRun( + path: string, + body: Record, + ): Promise { + const res = await this.fetchPost(path, body); + await this.assertOk(res); + return parseWorkflowRun((await res.json()) as Record); + } + + private async postStepAttempt( + path: string, + body: Record, + ): Promise { + const res = await this.fetchPost(path, body); + await this.assertOk(res); + return parseStepAttempt((await res.json()) as Record); + } + + private async assertOk(res: Response): Promise { + if (res.ok) return; + const body = await res.text(); + let message = body; + let code: string | undefined; + try { + const parsed = JSON.parse(body) as { + error?: { message?: string; code?: string }; + }; + if (parsed.error?.message) message = parsed.error.message; + code = parsed.error?.code; + } catch { + // body was not JSON; fall through with the raw text as the message + } + if (code !== undefined && isBackendErrorCode(code)) { + throw new BackendError(code, message); + } + throw new Error(message); + } +} diff --git a/packages/openworkflow/internal.ts b/packages/openworkflow/internal.ts index 57d1606f..a24e0f82 100644 --- a/packages/openworkflow/internal.ts +++ b/packages/openworkflow/internal.ts @@ -1,14 +1,27 @@ // workflow -export type { Workflow } from "./core/workflow-definition.js"; +export type { RetryPolicy, Workflow } from "./core/workflow-definition.js"; export { isWorkflow } from "./core/workflow-definition.js"; // backend export * from "./core/backend.js"; +export { + BackendError, + type BackendErrorCode, + isBackendErrorCode, + type SerializedError, +} from "./core/error.js"; + +// duration +export type { DurationString } from "./core/duration.js"; +export { parseDuration } from "./core/duration.js"; // core +export type { JsonValue } from "./core/json.js"; export type { WorkflowRun, WorkflowRunStatus } from "./core/workflow-run.js"; export type { StepAttempt, + StepAttemptContext, StepAttemptStatus, StepKind, } from "./core/step-attempt.js"; +export { STEP_KINDS } from "./core/step-attempt.js"; diff --git a/packages/openworkflow/package.json b/packages/openworkflow/package.json index 88abc8c5..4717b46c 100644 --- a/packages/openworkflow/package.json +++ b/packages/openworkflow/package.json @@ -30,6 +30,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./http": { + "types": "./dist/http.d.ts", + "default": "./dist/http.js" + }, "./internal": { "types": "./dist/internal.d.ts", "default": "./dist/internal.js" diff --git a/packages/server/errors.ts b/packages/server/errors.ts new file mode 100644 index 00000000..4d3c0d0f --- /dev/null +++ b/packages/server/errors.ts @@ -0,0 +1,145 @@ +import type { Context } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import type { BackendError } from "openworkflow/internal"; +import { isBackendErrorCode } from "openworkflow/internal"; +import { z } from "zod/v4"; + +// Route handlers throw; the global `app.onError` hook runs `errorToResponse` +// to produce a consistent wire shape for every error. + +/** Thrown by route handlers on request validation failure. Maps to HTTP 400. */ +export class HttpValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "HttpValidationError"; + } +} + +/** + * The wire format for every error response returned by the server. + * `code` is set only for typed `BackendError`s so clients can branch on it. + */ +export interface ErrorResponseBody { + error: { + message: string; + code?: string; + }; +} + +/** Hook invoked for unexpected server-side errors (not `BackendError`/validation). */ +export type ServerErrorHook = ( + error: unknown, + context: { path: string; method: string }, +) => void; + +export interface ErrorToResponseOptions { + exposeInternalErrors?: boolean; + onError?: ServerErrorHook; +} + +/** + * Build the JSON Response for a caught error. + * @param error - The caught error + * @param c - Hono context + * @param options - Behavior options + * @returns JSON Response + */ +export function errorToResponse( + error: unknown, + c: Context, + options: ErrorToResponseOptions = {}, +): Response | Promise { + if (error instanceof HttpValidationError) { + return c.json( + { error: { message: error.message } }, + 400, + ); + } + + if (isBackendError(error)) { + const status = backendErrorStatus(error); + return c.json( + { error: { message: error.message, code: error.code } }, + status, + ); + } + + // Hono body-limit and similar middleware throw HTTPException. Preserve the + // status/headers they chose, but normalize the body to the documented JSON + // error wire shape so clients can parse every error response uniformly. + if (error instanceof HTTPException) { + const original = error.getResponse(); + const headers = new Headers(original.headers); + headers.set("content-type", "application/json; charset=utf-8"); + const message = error.message || original.statusText || "HTTP error"; + const body: ErrorResponseBody = { error: { message } }; + return Response.json(body, { + status: original.status, + headers, + }); + } + + // Anything else is unexpected: surface it to the caller for logging, and + // either pass through a safe subset (Error.message) or scrub entirely, + // depending on `exposeInternalErrors`. + options.onError?.(error, { path: c.req.path, method: c.req.method }); + + const message = + options.exposeInternalErrors && error instanceof Error + ? error.message + : "Internal server error"; + return c.json({ error: { message } }, 500); +} + +/** + * Duck-typed BackendError guard — works across realms (TS source vs compiled). + * @param error - The value to test + * @returns True if `error` is a BackendError with a known code + */ +function isBackendError(error: unknown): error is BackendError { + if (!(error instanceof Error)) return false; + if (error.name !== "BackendError") return false; + const candidate = (error as { code?: unknown }).code; + return typeof candidate === "string" && isBackendErrorCode(candidate); +} + +/** + * Map a {@link BackendError} code to its HTTP status. + * @param error - The backend error to map + * @returns The HTTP status to return to the client + */ +function backendErrorStatus(error: BackendError): ContentfulStatusCode { + switch (error.code) { + case "NOT_FOUND": { + return 404; + } + case "CONFLICT": { + return 409; + } + } +} + +/** + * Parse and validate a request body against a Zod schema. + * @param c - Hono context + * @param schema - Zod schema + * @returns Parsed data + * @throws {HttpValidationError} On malformed JSON or schema failure. + */ +export async function parseJsonBody( + c: Context, + schema: z.ZodType, +): Promise { + let raw: unknown; + try { + raw = await c.req.json(); + } catch { + throw new HttpValidationError("Request body must be valid JSON."); + } + const parsed = schema.safeParse(raw); + if (!parsed.success) { + throw new HttpValidationError(z.prettifyError(parsed.error)); + } + return parsed.data; +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 00000000..f3133a3f --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,38 @@ +{ + "name": "@openworkflow/server", + "private": true, + "description": "HTTP server for OpenWorkflow — exposes the Backend interface as a REST API", + "type": "module", + "exports": { + ".": { + "types": "./dist/server.d.ts", + "default": "./dist/server.js" + } + }, + "files": [ + "dist", + "!*.test.*", + "!*.testsuite.*", + "!*.tsbuildinfo" + ], + "scripts": { + "build": "npm run clean && tsc", + "clean": "rm -rf dist", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@hono/node-server": "^1.19.14", + "hono": "^4.12.14", + "zod": "^4.3.6" + }, + "devDependencies": { + "openworkflow": "*", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "openworkflow": "^0.9.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/server/schemas.ts b/packages/server/schemas.ts new file mode 100644 index 00000000..560e478c --- /dev/null +++ b/packages/server/schemas.ts @@ -0,0 +1,113 @@ +import type { + DurationString, + JsonValue, + StepAttemptContext, +} from "openworkflow/internal"; +import { parseDuration, STEP_KINDS } from "openworkflow/internal"; +import { z } from "zod/v4"; + +// Request body schemas. ISO-8601 datetime strings are parsed into Date so +// route handlers can pass the body straight through to the backend. JSON +// payloads are typed directly as JsonValue to avoid zod's recursive +// inference blowing TypeScript's depth limit. + +const isoDatetime = z.iso.datetime().transform((s) => new Date(s)); + +const durationString = z + .string() + .refine((s) => parseDuration(s as DurationString).ok, { + message: "Invalid duration string", + }); + +const jsonValue = z.json() as unknown as z.ZodType; +const stepAttemptContext = z.json() as unknown as z.ZodType; + +const errorSchema = z.object({ + name: z.string().optional(), + message: z.string(), + stack: z.string().optional(), +}); + +export const createWorkflowRunSchema = z.object({ + workflowName: z.string(), + version: z.string().nullable(), + idempotencyKey: z.string().nullable(), + config: jsonValue, + context: jsonValue.nullable(), + input: jsonValue.nullable(), + parentStepAttemptNamespaceId: z.string().nullable().optional().default(null), + parentStepAttemptId: z.string().nullable().optional().default(null), + availableAt: isoDatetime.nullable().optional().default(null), + deadlineAt: isoDatetime.nullable().optional().default(null), +}); + +const workerLeaseFields = { + workerId: z.string(), + leaseDurationMs: z.number().int().positive(), +}; + +export const claimWorkflowRunSchema = z.object(workerLeaseFields); + +export const extendWorkflowRunLeaseSchema = z.object(workerLeaseFields); + +export const sleepWorkflowRunSchema = z.object({ + workerId: z.string(), + availableAt: isoDatetime, +}); + +export const completeWorkflowRunSchema = z.object({ + workerId: z.string(), + output: jsonValue.nullable(), +}); + +export const failWorkflowRunSchema = z.object({ + workerId: z.string(), + error: errorSchema, + retryPolicy: z.object({ + initialInterval: durationString, + backoffCoefficient: z.number().positive(), + maximumInterval: durationString, + maximumAttempts: z.number().int().positive(), + }), + attempts: z.number().int().nonnegative().optional(), + deadlineAt: isoDatetime.nullable().optional(), +}); + +export const rescheduleWorkflowRunSchema = z.object({ + workerId: z.string(), + error: errorSchema, + availableAt: isoDatetime, +}); + +export const createStepAttemptSchema = z.object({ + workerId: z.string(), + stepName: z.string(), + kind: z.enum(STEP_KINDS), + config: jsonValue, + context: stepAttemptContext.nullable(), +}); + +export const completeStepAttemptSchema = z.object({ + workflowRunId: z.string(), + workerId: z.string(), + output: jsonValue.nullable(), +}); + +export const failStepAttemptSchema = z.object({ + workflowRunId: z.string(), + workerId: z.string(), + error: errorSchema, +}); + +export const setStepAttemptChildWorkflowRunSchema = z.object({ + workflowRunId: z.string(), + workerId: z.string(), + childWorkflowRunNamespaceId: z.string(), + childWorkflowRunId: z.string(), +}); + +export const sendSignalSchema = z.object({ + signal: z.string(), + data: jsonValue.nullable(), + idempotencyKey: z.string().nullable(), +}); diff --git a/packages/server/server.test.ts b/packages/server/server.test.ts new file mode 100644 index 00000000..8760e2a0 --- /dev/null +++ b/packages/server/server.test.ts @@ -0,0 +1,680 @@ +import type { Backend } from "../openworkflow/core/backend.js"; +// Same realm as BackendHttp so `instanceof BackendError` checks are valid +// under vitest (the `openworkflow/internal` re-export is a different realm). +import { BackendError } from "../openworkflow/core/error.js"; +import { BackendHttp } from "../openworkflow/http/backend.js"; +import { BackendPostgres } from "../openworkflow/postgres/backend.js"; +import { DEFAULT_POSTGRES_URL } from "../openworkflow/postgres/postgres.js"; +import { testBackend } from "../openworkflow/testing/backend.testsuite.js"; +import { createServer } from "./server.js"; +import { randomUUID } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Creates a BackendHttp backed by an in-process Hono server over a real + * Postgres backend. Requests never leave the process — Hono's `app.fetch()` + * handles them directly via the Web Standard fetch interface. + * @returns BackendHttp and the underlying BackendPostgres for teardown + */ +async function createHttpBackend(): Promise<{ + backend: BackendHttp; + pgBackend: BackendPostgres; +}> { + const pgBackend = await BackendPostgres.connect(DEFAULT_POSTGRES_URL, { + namespaceId: randomUUID(), + }); + // The shared test suite asserts on Postgres error messages, which are + // plain Error instances (not BackendError). Opt into message passthrough + // so those assertions can be checked across the HTTP boundary. + const server = createServer(pgBackend, { exposeInternalErrors: true }); + + const backend = new BackendHttp({ + url: "http://localhost:0", + fetch: async (input, init) => { + const request = new Request(input, init); + return server.fetch(request); + }, + }); + + return { backend, pgBackend }; +} + +const pgBackends = new WeakMap(); + +// --------------------------------------------------------------------------- +// Full Backend test suite: BackendHttp → Server → BackendPostgres → Postgres +// --------------------------------------------------------------------------- + +testBackend({ + setup: async () => { + const { backend, pgBackend } = await createHttpBackend(); + pgBackends.set(backend, pgBackend); + return backend; + }, + teardown: async (backend) => { + const pgBackend = pgBackends.get(backend); + if (pgBackend) { + await pgBackend.stop(); + pgBackends.delete(backend); + } + await backend.stop(); + }, +}); + +// --------------------------------------------------------------------------- +// Server-specific tests (against a real Postgres backend) +// --------------------------------------------------------------------------- + +describe("Server", () => { + let pgBackend: BackendPostgres; + let fetch: (request: Request) => Response | Promise; + + beforeEach(async () => { + pgBackend = await BackendPostgres.connect(DEFAULT_POSTGRES_URL, { + namespaceId: randomUUID(), + }); + const server = createServer(pgBackend); + fetch = (req) => server.fetch(req); + }); + + afterEach(async () => { + await pgBackend.stop(); + }); + + // ----------------------------------------------------------------------- + // Liveness & readiness + // ----------------------------------------------------------------------- + + test("GET /healthz returns 200 ok without hitting backend", async () => { + const res = await fetch(new Request("http://localhost/healthz")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + + test("GET /readyz returns 200 ok when backend is reachable", async () => { + const res = await fetch(new Request("http://localhost/readyz")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + + // ----------------------------------------------------------------------- + // Routing sanity + // ----------------------------------------------------------------------- + + test("GET /v0/workflow-runs:count is routed to counts, not to getWorkflowRun", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs:count"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + for (const key of [ + "pending", + "running", + "completed", + "failed", + "canceled", + ]) { + expect(body[key]).toBe(0); + } + }); + + test("GET /v0/workflow-runs/:id returns 404 for non-existent run", async () => { + const res = await fetch( + new Request(`http://localhost/v0/workflow-runs/${randomUUID()}`), + ); + expect(res.status).toBe(404); + }); + + test("POST /v0/workflow-runs:claim returns 204 when nothing available", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs:claim", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workerId: randomUUID(), leaseDurationMs: 1000 }), + }), + ); + expect(res.status).toBe(204); + }); + + test("GET /v0/signal-deliveries/:id returns 204 when no delivery", async () => { + const res = await fetch( + new Request(`http://localhost/v0/signal-deliveries/${randomUUID()}`), + ); + expect(res.status).toBe(204); + }); + + // ----------------------------------------------------------------------- + // Verb routing: 404 for unknown/missing verbs + // ----------------------------------------------------------------------- + + test("POST /v0/workflow-runs/:id with no verb returns 404", async () => { + const res = await fetch( + new Request(`http://localhost/v0/workflow-runs/${randomUUID()}`, { + method: "POST", + }), + ); + expect(res.status).toBe(404); + }); + + test("POST /v0/workflow-runs/:id:unknownVerb returns 404", async () => { + const res = await fetch( + new Request(`http://localhost/v0/workflow-runs/${randomUUID()}:bogus`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }), + ); + expect(res.status).toBe(404); + }); + + test("POST /v0/step-attempts/:id with no verb returns 404", async () => { + const res = await fetch( + new Request(`http://localhost/v0/step-attempts/${randomUUID()}`, { + method: "POST", + }), + ); + expect(res.status).toBe(404); + }); + + test("POST /v0/step-attempts/:id:unknownVerb returns 404", async () => { + const res = await fetch( + new Request(`http://localhost/v0/step-attempts/${randomUUID()}:bogus`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }), + ); + expect(res.status).toBe(404); + }); + + test("GET /v0/step-attempts/:id returns 404 for non-existent attempt", async () => { + const res = await fetch( + new Request(`http://localhost/v0/step-attempts/${randomUUID()}`), + ); + expect(res.status).toBe(404); + }); + + // ----------------------------------------------------------------------- + // Backend-thrown errors propagate as 4xx/5xx + // ----------------------------------------------------------------------- + + test("POST /v0/workflow-runs/:id:cancel on non-existent run returns error", async () => { + const res = await fetch( + new Request(`http://localhost/v0/workflow-runs/${randomUUID()}:cancel`, { + method: "POST", + }), + ); + expect(res.status).toBeGreaterThanOrEqual(400); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Request validation (no Postgres required — pure protocol behavior) +// --------------------------------------------------------------------------- + +describe("Server request validation", () => { + const server = createServer(mockBackend()); + function fetch(req: Request): Response | Promise { + return server.fetch(req); + } + + test("POST /v0/workflow-runs with invalid body returns 400", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bad: "body" }), + }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toBeDefined(); + }); + + test("POST /v0/workflow-runs with non-JSON body returns 400 (not 500)", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toMatch(/json/i); + }); + + test("POST /v0/workflow-runs:claim with invalid body returns 400", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs:claim", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }), + ); + expect(res.status).toBe(400); + }); + + test("POST /v0/workflow-runs/:id:extendLease with invalid body returns 400", async () => { + const res = await fetch( + new Request( + `http://localhost/v0/workflow-runs/${randomUUID()}:extendLease`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bad: true }), + }, + ), + ); + expect(res.status).toBe(400); + }); + + test("POST /v0/workflow-runs rejects invalid availableAt date string", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflowName: "test", + version: null, + idempotencyKey: null, + config: {}, + context: null, + input: null, + availableAt: "not-a-date", + }), + }), + ); + expect(res.status).toBe(400); + }); + + test("POST /v0/workflow-runs/:id:sleep rejects invalid availableAt", async () => { + const res = await fetch( + new Request(`http://localhost/v0/workflow-runs/${randomUUID()}:sleep`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workerId: randomUUID(), + availableAt: "garbage", + }), + }), + ); + expect(res.status).toBe(400); + }); + + test("GET /v0/workflow-runs rejects non-numeric limit with 400", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs?limit=abc"), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toMatch(/limit/i); + }); + + test("GET /v0/workflow-runs rejects zero limit with 400", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs?limit=0"), + ); + expect(res.status).toBe(400); + }); + + test("GET /v0/workflow-runs rejects fractional limit with 400", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs?limit=1.5"), + ); + expect(res.status).toBe(400); + }); + + test("GET /v0/workflow-runs rejects both after and before with 400", async () => { + const res = await fetch( + new Request("http://localhost/v0/workflow-runs?after=a&before=b"), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toMatch(/mutually exclusive/i); + }); + + test("rejects payloads over the body size limit with 413", async () => { + const server = createServer(mockBackend(), { maxBodyBytes: 1024 }); + const res = await server.fetch( + new Request("http://localhost/v0/workflow-runs", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": String(2048), + }, + // payload doesn't need to be real - content-length is enough to trigger + body: "x".repeat(2048), + }), + ); + expect(res.status).toBe(413); + expect(res.headers.get("content-type")).toMatch(/application\/json/); + const body = (await res.json()) as { error: { message: string } }; + expect(typeof body.error.message).toBe("string"); + expect(body.error.message.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling — mock backend to exercise error paths +// --------------------------------------------------------------------------- + +function notImplemented(): never { + throw new Error("not implemented"); +} + +function mockBackend(overrides: Partial = {}): Backend { + return { + createWorkflowRun: vi.fn(notImplemented), + getWorkflowRun: vi.fn(notImplemented), + listWorkflowRuns: vi.fn(notImplemented), + countWorkflowRuns: vi.fn(notImplemented), + claimWorkflowRun: vi.fn(notImplemented), + extendWorkflowRunLease: vi.fn(notImplemented), + sleepWorkflowRun: vi.fn(notImplemented), + completeWorkflowRun: vi.fn(notImplemented), + failWorkflowRun: vi.fn(notImplemented), + rescheduleWorkflowRunAfterFailedStepAttempt: vi.fn(notImplemented), + cancelWorkflowRun: vi.fn(notImplemented), + createStepAttempt: vi.fn(notImplemented), + getStepAttempt: vi.fn(notImplemented), + listStepAttempts: vi.fn(notImplemented), + completeStepAttempt: vi.fn(notImplemented), + failStepAttempt: vi.fn(notImplemented), + setStepAttemptChildWorkflowRun: vi.fn(notImplemented), + sendSignal: vi.fn(notImplemented), + getSignalDelivery: vi.fn(notImplemented), + stop: vi.fn(), + ...overrides, + } as unknown as Backend; +} + +describe("Server error handling", () => { + test("maps BackendError NOT_FOUND to 404 with code", async () => { + const backend = mockBackend({ + getWorkflowRun: vi + .fn() + .mockRejectedValue(new BackendError("NOT_FOUND", "run not found")), + }); + const server = createServer(backend); + const res = await server.fetch( + new Request(`http://localhost/v0/workflow-runs/${randomUUID()}`), + ); + expect(res.status).toBe(404); + const body = (await res.json()) as { + error: { message: string; code?: string }; + }; + expect(body.error.code).toBe("NOT_FOUND"); + expect(body.error.message).toBe("run not found"); + }); + + test("maps BackendError CONFLICT to 409 with code", async () => { + const backend = mockBackend({ + createWorkflowRun: vi + .fn() + .mockRejectedValue(new BackendError("CONFLICT", "duplicate key")), + }); + const server = createServer(backend); + const res = await server.fetch( + new Request("http://localhost/v0/workflow-runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workflowName: "test", + version: null, + idempotencyKey: null, + config: {}, + context: null, + input: null, + }), + }), + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { + error: { message: string; code?: string }; + }; + expect(body.error.code).toBe("CONFLICT"); + }); + + test("scrubs non-BackendError messages to a generic 500 and invokes onError", async () => { + const onError = vi.fn(); + const backend = mockBackend({ + listWorkflowRuns: vi + .fn() + .mockRejectedValue(new Error("SELECT * FROM passwords")), + }); + const server = createServer(backend, { onError }); + const res = await server.fetch( + new Request("http://localhost/v0/workflow-runs"), + ); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toBe("Internal server error"); + expect(body.error.message).not.toContain("passwords"); + expect(onError).toHaveBeenCalledOnce(); + const [err] = onError.mock.calls[0] as [Error]; + expect(err.message).toBe("SELECT * FROM passwords"); + }); + + test("BackendError does not invoke onError hook", async () => { + const onError = vi.fn(); + const backend = mockBackend({ + getWorkflowRun: vi + .fn() + .mockRejectedValue(new BackendError("NOT_FOUND", "nope")), + }); + const server = createServer(backend, { onError }); + await server.fetch( + new Request(`http://localhost/v0/workflow-runs/${randomUUID()}`), + ); + expect(onError).not.toHaveBeenCalled(); + }); + + test("BackendError propagates through verb dispatch with correct status", async () => { + const backend = mockBackend({ + extendWorkflowRunLease: vi + .fn() + .mockRejectedValue(new BackendError("NOT_FOUND", "not found")), + }); + const server = createServer(backend); + const res = await server.fetch( + new Request( + `http://localhost/v0/workflow-runs/${randomUUID()}:extendLease`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workerId: randomUUID(), + leaseDurationMs: 5000, + }), + }, + ), + ); + expect(res.status).toBe(404); + }); + + test("readyz returns 503 when backend cannot serve requests", async () => { + const backend = mockBackend({ + countWorkflowRuns: vi.fn().mockRejectedValue(new Error("db down")), + }); + const server = createServer(backend); + const res = await server.fetch(new Request("http://localhost/readyz")); + expect(res.status).toBe(503); + }); + + test("logRequests: true wires Hono's logger middleware", () => { + expect(() => + createServer(mockBackend(), { logRequests: true }), + ).not.toThrow(); + }); + + test("fail verb round-trips deadlineAt and attempts end-to-end", async () => { + const failWorkflowRun = vi.fn().mockResolvedValue({}); + const backend = mockBackend({ failWorkflowRun }); + const server = createServer(backend); + const http = new BackendHttp({ + url: "http://localhost", + fetch: (input, init) => + Promise.resolve(server.fetch(new Request(input, init))), + }); + const deadline = new Date("2030-01-01T00:00:00.000Z"); + await http.failWorkflowRun({ + workflowRunId: randomUUID(), + workerId: randomUUID(), + error: { message: "boom" }, + retryPolicy: { + initialInterval: "1s", + backoffCoefficient: 2, + maximumInterval: "1m", + maximumAttempts: 3, + }, + attempts: 2, + deadlineAt: deadline, + }); + expect(failWorkflowRun).toHaveBeenCalledOnce(); + const [params] = failWorkflowRun.mock.calls[0] as [ + { deadlineAt?: Date | null; attempts?: number }, + ]; + if (!(params.deadlineAt instanceof Date)) { + throw new TypeError("expected deadlineAt to be a Date"); + } + expect(params.deadlineAt.toISOString()).toBe(deadline.toISOString()); + expect(params.attempts).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// BackendHttp round-trip behavior +// --------------------------------------------------------------------------- + +/** + * Build a BackendHttp instance that uses the supplied fetch stub. + * @param fetch - Fetch stub the backend should call + * @returns BackendHttp wired to the stub + */ +function backendWithFetch(fetch: typeof globalThis.fetch): BackendHttp { + return new BackendHttp({ url: "http://localhost:3000", fetch }); +} + +/** + * Build a fetch stub that always returns the given response. + * @param response - Response to return for every call + * @returns A fetch-compatible function + */ +function fetchReturning(response: Response): typeof globalThis.fetch { + // eslint-disable-next-line @typescript-eslint/require-await -- fetch is async + return async () => response.clone(); +} + +describe("BackendHttp", () => { + test("assembles URLs against a URL with trailing slash correctly", async () => { + const calls: string[] = []; + // eslint-disable-next-line @typescript-eslint/require-await -- fetch is async + async function fakeFetch(input: string | URL | Request): Promise { + let url: string; + if (typeof input === "string") url = input; + else if (input instanceof URL) url = input.href; + else url = input.url; + calls.push(url); + return new Response(null, { status: 404 }); + } + const backend = new BackendHttp({ + url: "http://localhost:3000/", + fetch: fakeFetch, + }); + await backend.getWorkflowRun({ workflowRunId: "abc" }); + expect(calls[0]).toBe("http://localhost:3000/v0/workflow-runs/abc"); + }); + + test("stop() resolves without error", async () => { + const backend = new BackendHttp({ url: "http://localhost:3000" }); + await expect(backend.stop()).resolves.toBeUndefined(); + }); + + test("re-throws BackendError when server returns a code field", async () => { + const backend = backendWithFetch( + fetchReturning( + Response.json( + { error: { message: "run not found", code: "NOT_FOUND" } }, + { status: 404 }, + ), + ), + ); + await expect( + backend.extendWorkflowRunLease({ + workflowRunId: randomUUID(), + workerId: randomUUID(), + leaseDurationMs: 1000, + }), + ).rejects.toMatchObject({ + name: "BackendError", + code: "NOT_FOUND", + message: "run not found", + }); + }); + + test("re-throws BackendError for CONFLICT", async () => { + const backend = backendWithFetch( + fetchReturning( + Response.json( + { error: { message: "duplicate", code: "CONFLICT" } }, + { status: 409 }, + ), + ), + ); + await expect( + backend.createWorkflowRun({ + workflowName: "w", + version: null, + idempotencyKey: null, + config: {}, + context: null, + input: null, + parentStepAttemptNamespaceId: null, + parentStepAttemptId: null, + availableAt: null, + deadlineAt: null, + }), + ).rejects.toBeInstanceOf(BackendError); + }); + + test("throws plain Error when server response has no code", async () => { + const backend = backendWithFetch( + fetchReturning( + Response.json({ error: { message: "boom" } }, { status: 500 }), + ), + ); + await expect(backend.countWorkflowRuns()).rejects.not.toBeInstanceOf( + BackendError, + ); + }); + + test("throws plain Error when server returns unrecognized code", async () => { + const backend = backendWithFetch( + fetchReturning( + Response.json( + { error: { message: "weird", code: "UNKNOWN_CODE" } }, + { status: 418 }, + ), + ), + ); + await expect(backend.countWorkflowRuns()).rejects.not.toBeInstanceOf( + BackendError, + ); + }); + + test("falls back to response text when body is not JSON", async () => { + const backend = backendWithFetch( + fetchReturning(new Response("plain text failure", { status: 500 })), + ); + await expect(backend.countWorkflowRuns()).rejects.toThrow( + /plain text failure/, + ); + }); +}); diff --git a/packages/server/server.ts b/packages/server/server.ts new file mode 100644 index 00000000..f2e9b59b --- /dev/null +++ b/packages/server/server.ts @@ -0,0 +1,462 @@ +import { + errorToResponse, + HttpValidationError, + parseJsonBody, + type ServerErrorHook, +} from "./errors.js"; +import { + claimWorkflowRunSchema, + completeStepAttemptSchema, + completeWorkflowRunSchema, + createStepAttemptSchema, + createWorkflowRunSchema, + extendWorkflowRunLeaseSchema, + failStepAttemptSchema, + failWorkflowRunSchema, + rescheduleWorkflowRunSchema, + sendSignalSchema, + setStepAttemptChildWorkflowRunSchema, + sleepWorkflowRunSchema, +} from "./schemas.js"; +import { serve as honoServe } from "@hono/node-server"; +import type { Context } from "hono"; +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import { logger } from "hono/logger"; +import type { + Backend, + CancelWorkflowRunParams, + CompleteStepAttemptParams, + CompleteWorkflowRunParams, + CreateStepAttemptParams, + CreateWorkflowRunParams, + ExtendWorkflowRunLeaseParams, + FailStepAttemptParams, + FailWorkflowRunParams, + GetSignalDeliveryParams, + GetStepAttemptParams, + GetWorkflowRunParams, + ListStepAttemptsParams, + ListWorkflowRunsParams, + RescheduleWorkflowRunAfterFailedStepAttemptParams, + RetryPolicy, + SerializedError, + SetStepAttemptChildWorkflowRunParams, + SleepWorkflowRunParams, +} from "openworkflow/internal"; + +/** + * The OpenWorkflow HTTP server handle. Public API is the Web Standard `fetch`. + */ +export interface OpenWorkflowServer { + /** Handle an incoming HTTP request (Web Standard fetch signature). */ + fetch(request: Request): Response | Promise; +} + +/** + * Options for {@link createServer}. + */ +export interface CreateServerOptions { + /** Maximum request body size, in bytes. Defaults to 1 MiB. */ + maxBodyBytes?: number; + /** Attach Hono's request logger middleware. Defaults to `false`. */ + logRequests?: boolean; + /** Hook invoked for unexpected server-side errors (not validation/`BackendError`). */ + onError?: ServerErrorHook; + /** + * Include the message of unexpected backend errors in the 500 response. + * Defaults to `false`; leaks implementation details if enabled in production. + */ + exposeInternalErrors?: boolean; +} + +/** + * Create an OpenWorkflow HTTP server backed by the given Backend. + * @param backend - Backend implementation to proxy + * @param options - Server options + * @returns Server with a Web Standard `fetch` handler + */ +export function createServer( + backend: Backend, + options: CreateServerOptions = {}, +): OpenWorkflowServer { + const app = new Hono(); + + if (options.logRequests) { + app.use("*", logger()); + } + app.use("*", bodyLimit({ maxSize: options.maxBodyBytes ?? 1_048_576 })); + app.onError((error, c) => + errorToResponse(error, c, { + ...(options.onError === undefined ? {} : { onError: options.onError }), + ...(options.exposeInternalErrors === undefined + ? {} + : { exposeInternalErrors: options.exposeInternalErrors }), + }), + ); + + // /healthz is liveness (process is up). /readyz pings the backend so load + // balancers don't route traffic to a replica whose DB connection is broken. + app.get("/healthz", (c) => c.json({ status: "ok" })); + app.get("/readyz", async (c) => { + try { + await backend.countWorkflowRuns(); + } catch (error) { + options.onError?.(error, { path: c.req.path, method: c.req.method }); + return c.json({ status: "unavailable" }, 503); + } + return c.json({ status: "ok" }); + }); + + registerWorkflowRunRoutes(app, backend); + registerStepAttemptRoutes(app, backend); + registerSignalRoutes(app, backend); + + return app; +} + +// Hono doesn't support a literal `:` inside a route that also has a `:param`, +// so `POST /resource/:id:verb` is captured as a single segment and dispatched +// via the `VerbHandler` tables below. + +type VerbHandler = ( + backend: Backend, + id: string, + c: Context, +) => Promise; + +/** + * Register `POST {pathPrefix}/:id:verb` routes that dispatch through `verbs`. + * @param app - The Hono app to mount on + * @param pathPrefix - Path prefix preceding the `:id:verb` segment + * @param backend - Backend instance passed to each verb handler + * @param verbs - Verb name → handler map + */ +function registerVerbRoute( + app: Hono, + pathPrefix: string, + backend: Backend, + verbs: Readonly>, +): void { + app.post(`${pathPrefix}/:idVerb`, async (c) => { + const parts = splitIdVerb(c.req.param("idVerb")); + if (!parts) return c.notFound(); + const [id, verb] = parts; + const handler = verbs[verb]; + if (!handler) return c.notFound(); + return handler(backend, id, c); + }); +} + +const WORKFLOW_RUN_VERBS: Readonly> = { + extendLease: async (backend, id, c) => { + const body = await parseJsonBody(c, extendWorkflowRunLeaseSchema); + const params: ExtendWorkflowRunLeaseParams = { workflowRunId: id, ...body }; + const run = await backend.extendWorkflowRunLease(params); + return c.json(run); + }, + sleep: async (backend, id, c) => { + const body = await parseJsonBody(c, sleepWorkflowRunSchema); + const params: SleepWorkflowRunParams = { + workflowRunId: id, + workerId: body.workerId, + availableAt: body.availableAt, + }; + const run = await backend.sleepWorkflowRun(params); + return c.json(run); + }, + complete: async (backend, id, c) => { + const body = await parseJsonBody(c, completeWorkflowRunSchema); + const params: CompleteWorkflowRunParams = { workflowRunId: id, ...body }; + const run = await backend.completeWorkflowRun(params); + return c.json(run); + }, + fail: async (backend, id, c) => { + const body = await parseJsonBody(c, failWorkflowRunSchema); + const params: FailWorkflowRunParams = { + workflowRunId: id, + workerId: body.workerId, + error: toSerializedError(body.error), + retryPolicy: body.retryPolicy as RetryPolicy, + ...(body.attempts === undefined ? {} : { attempts: body.attempts }), + ...(body.deadlineAt === undefined ? {} : { deadlineAt: body.deadlineAt }), + }; + const run = await backend.failWorkflowRun(params); + return c.json(run); + }, + reschedule: async (backend, id, c) => { + const body = await parseJsonBody(c, rescheduleWorkflowRunSchema); + const params: RescheduleWorkflowRunAfterFailedStepAttemptParams = { + workflowRunId: id, + workerId: body.workerId, + error: toSerializedError(body.error), + availableAt: body.availableAt, + }; + const run = + await backend.rescheduleWorkflowRunAfterFailedStepAttempt(params); + return c.json(run); + }, + cancel: async (backend, id, c) => { + const params: CancelWorkflowRunParams = { workflowRunId: id }; + const run = await backend.cancelWorkflowRun(params); + return c.json(run); + }, +}; + +/** + * Mount workflow-run routes under `/v0/workflow-runs`. + * @param app - The Hono app to mount on + * @param backend - Backend instance to delegate to + */ +function registerWorkflowRunRoutes(app: Hono, backend: Backend): void { + app.post("/v0/workflow-runs", async (c) => { + const body = await parseJsonBody(c, createWorkflowRunSchema); + const params: CreateWorkflowRunParams = { + workflowName: body.workflowName, + version: body.version, + idempotencyKey: body.idempotencyKey, + config: body.config, + context: body.context, + input: body.input, + parentStepAttemptNamespaceId: body.parentStepAttemptNamespaceId, + parentStepAttemptId: body.parentStepAttemptId, + availableAt: body.availableAt, + deadlineAt: body.deadlineAt, + }; + const run = await backend.createWorkflowRun(params); + return c.json(run, 201); + }); + + app.get("/v0/workflow-runs/:id", async (c) => { + const params: GetWorkflowRunParams = { workflowRunId: c.req.param("id") }; + const run = await backend.getWorkflowRun(params); + if (!run) { + return c.json({ error: { message: "Workflow run not found" } }, 404); + } + return c.json(run); + }); + + app.get("/v0/workflow-runs", async (c) => { + const params: ListWorkflowRunsParams = paginationQuery(c); + const result = await backend.listWorkflowRuns(params); + return c.json(result); + }); + + app.get("/v0/workflow-runs:count", async (c) => { + const counts = await backend.countWorkflowRuns(); + return c.json(counts); + }); + + app.post("/v0/workflow-runs:claim", async (c) => { + const body = await parseJsonBody(c, claimWorkflowRunSchema); + const run = await backend.claimWorkflowRun(body); + if (!run) return c.body(null, 204); + return c.json(run); + }); + + registerVerbRoute(app, "/v0/workflow-runs", backend, WORKFLOW_RUN_VERBS); +} + +const STEP_ATTEMPT_VERBS: Readonly> = { + complete: async (backend, id, c) => { + const body = await parseJsonBody(c, completeStepAttemptSchema); + const params: CompleteStepAttemptParams = { stepAttemptId: id, ...body }; + const step = await backend.completeStepAttempt(params); + return c.json(step); + }, + fail: async (backend, id, c) => { + const body = await parseJsonBody(c, failStepAttemptSchema); + const params: FailStepAttemptParams = { + stepAttemptId: id, + workflowRunId: body.workflowRunId, + workerId: body.workerId, + error: toSerializedError(body.error), + }; + const step = await backend.failStepAttempt(params); + return c.json(step); + }, + setChildWorkflowRun: async (backend, id, c) => { + const body = await parseJsonBody(c, setStepAttemptChildWorkflowRunSchema); + const params: SetStepAttemptChildWorkflowRunParams = { + stepAttemptId: id, + ...body, + }; + const step = await backend.setStepAttemptChildWorkflowRun(params); + return c.json(step); + }, +}; + +/** + * Mount step-attempt routes under `/v0/workflow-runs/:id/step-attempts` and `/v0/step-attempts`. + * @param app - The Hono app to mount on + * @param backend - Backend instance to delegate to + */ +function registerStepAttemptRoutes(app: Hono, backend: Backend): void { + app.post("/v0/workflow-runs/:id/step-attempts", async (c) => { + const body = await parseJsonBody(c, createStepAttemptSchema); + const params: CreateStepAttemptParams = { + workflowRunId: c.req.param("id"), + workerId: body.workerId, + stepName: body.stepName, + kind: body.kind, + config: body.config, + context: body.context, + }; + const step = await backend.createStepAttempt(params); + return c.json(step, 201); + }); + + app.get("/v0/step-attempts/:id", async (c) => { + const params: GetStepAttemptParams = { stepAttemptId: c.req.param("id") }; + const step = await backend.getStepAttempt(params); + if (!step) { + return c.json({ error: { message: "Step attempt not found" } }, 404); + } + return c.json(step as unknown); + }); + + app.get("/v0/workflow-runs/:id/step-attempts", async (c) => { + const params: ListStepAttemptsParams = { + workflowRunId: c.req.param("id"), + ...paginationQuery(c), + }; + const result = await backend.listStepAttempts(params); + return c.json(result); + }); + + registerVerbRoute(app, "/v0/step-attempts", backend, STEP_ATTEMPT_VERBS); +} + +/** + * Mount signal routes under `/v0/signals` and `/v0/signal-deliveries`. + * @param app - The Hono app to mount on + * @param backend - Backend instance to delegate to + */ +function registerSignalRoutes(app: Hono, backend: Backend): void { + app.post("/v0/signals:send", async (c) => { + const body = await parseJsonBody(c, sendSignalSchema); + const result = await backend.sendSignal(body); + return c.json(result); + }); + + app.get("/v0/signal-deliveries/:stepAttemptId", async (c) => { + const params: GetSignalDeliveryParams = { + stepAttemptId: c.req.param("stepAttemptId"), + }; + const result = await backend.getSignalDelivery(params); + if (result === undefined) return c.body(null, 204); + return c.json(result as unknown); + }); +} + +/** + * Extract pagination query params. + * @param c - Hono context + * @returns Pagination params + * @throws {HttpValidationError} On invalid `limit` or conflicting `after`/`before`. + */ +function paginationQuery(c: Context): { + limit?: number; + after?: string; + before?: string; +} { + const { limit, after, before } = c.req.query(); + if (after && before) { + throw new HttpValidationError( + "Query parameters `after` and `before` are mutually exclusive.", + ); + } + const result: { limit?: number; after?: string; before?: string } = {}; + if (limit) { + const parsed = Number(limit); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new HttpValidationError( + "Query parameter `limit` must be a positive integer.", + ); + } + result.limit = parsed; + } + if (after) result.after = after; + if (before) result.before = before; + return result; +} + +/** + * Drop undefined name/stack to satisfy `exactOptionalPropertyTypes`. + * @param err - Validated error payload from a request body + * @param err.name - Optional error name + * @param err.message - Error message + * @param err.stack - Optional stack trace + * @returns A {@link SerializedError} with no `undefined` properties + */ +function toSerializedError(err: { + name?: string | undefined; + message: string; + stack?: string | undefined; +}): SerializedError { + return { + message: err.message, + ...(err.name === undefined ? {} : { name: err.name }), + ...(err.stack === undefined ? {} : { stack: err.stack }), + }; +} + +/** + * Split an `{id}:{verb}` path segment; returns null if either side is empty. + * @param segment - Path segment of the form `id:verb` + * @returns A `[id, verb]` tuple, or null if the segment is malformed + */ +function splitIdVerb(segment: string): [id: string, verb: string] | null { + const idx = segment.lastIndexOf(":"); + if (idx === -1) return null; + const id = segment.slice(0, idx); + const verb = segment.slice(idx + 1); + if (!id || !verb) return null; + return [id, verb]; +} + +/** + * Options for {@link serve}. + */ +export interface ServeOptions { + /** Port to listen on (default: 3000). */ + port?: number; + /** Host/interface to bind to (default: `127.0.0.1`). */ + hostname?: string; +} + +/** + * Handle for a running Node.js HTTP server. + */ +export interface ServeHandle { + /** Gracefully close the server. Resolves when the socket is closed. */ + close(): Promise; +} + +/* v8 ignore start -- infrastructure: starts a real Node.js HTTP server */ +/** + * Start a Node.js HTTP server for the given OpenWorkflow server. + * @param server - OpenWorkflow server instance + * @param options - Server options + * @returns A handle for stopping the server gracefully + */ +export function serve( + server: OpenWorkflowServer, + options: ServeOptions = {}, +): ServeHandle { + const httpServer = honoServe({ + fetch: (request) => server.fetch(request), + port: options.port ?? 3000, + hostname: options.hostname ?? "127.0.0.1", + }); + return { + close: () => + new Promise((resolve, reject) => { + httpServer.close((err) => { + if (err) reject(err); + else resolve(); + }); + }), + }; +} +/* v8 ignore stop */ diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 00000000..b3db5ed0 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": ["../../tsconfig.base.json"], + "compilerOptions": { + "outDir": "dist" + }, + "references": [{ "path": "../openworkflow" }], + "include": ["**/*.ts"], + "exclude": ["dist"] +} diff --git a/tsconfig.json b/tsconfig.json index 1a59f758..f12497a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ { "path": "./packages/backend-postgres" }, { "path": "./packages/backend-sqlite" }, { "path": "./packages/cli" }, - { "path": "./packages/dashboard" } + { "path": "./packages/dashboard" }, + { "path": "./packages/server" } ], // project-wide settings for configs, workflows, etc.