Upgrade OpenWorkflow to 0.11.0 #69

Merged
eleith merged 1 commits from upgrade/openworkflow-0.11 into main 2026-10-20 15:11:23 +00:00
17 changed files with 790 additions and 178 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@when/web': patch
'@when/worker': patch
'@when/cli': patch
---
Upgrade OpenWorkflow to 0.11.0 with single-slot child-workflow regression coverage. Route worker engine diagnostics through structured logging and expose explicit queue connection shutdown. Existing appointment workflows and maintenance timers are unchanged in this step.
+1
View File
@@ -33,6 +33,7 @@ blob-report/
playwright/.cache/
# Per-user / local tool state
/.plans/
/.claude/
/.antigravitycli/
.nono.json
+1 -1
View File
@@ -61,7 +61,7 @@
"bits-ui": "2.18.1",
"kysely": "0.28.17",
"mrmime": "2.0.1",
"openworkflow": "0.9.0",
"openworkflow": "0.11.0",
"pino": "10.3.1",
"prom-client": "15.1.3",
"takumi-js": "2.2.0",
@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { openDb, runMigrations } from '@when/db';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import { systemClock } from '$lib/server/clock';
let mockDb: Awaited<ReturnType<typeof makeDb>> | null = null;
vi.mock('$lib/server/state', () => ({
@@ -38,6 +39,8 @@ function loadEvent(): Parameters<typeof load>[0] {
}
describe('admin +page.server load', () => {
afterEach(() => vi.restoreAllMocks());
test('returns purgedCount: 0 when no purged appointments exist', async () => {
mockDb = await makeDb();
await mockDb
@@ -74,6 +77,7 @@ describe('admin +page.server load', () => {
});
test('calculates lifetimeMinutes accurately without floating-point truncation', async () => {
vi.spyOn(systemClock, 'now').mockReturnValue(new Date('2026-09-10T12:00:00Z'));
mockDb = await makeDb();
await mockDb
.insertInto('appointments')
+24 -18
View File
@@ -1,16 +1,16 @@
# @when/worker
The `when` worker — a long-running Node service that runs jobs produced by the
web app (and, later, jobs it schedules itself). Built on
[openworkflow](https://openworkflow.dev) over `node:sqlite`, so it shares the
`when.sqlite` app DB and owns the `queue.sqlite` job DB.
The `when` worker is a long-running Node service for appointment side effects,
calendar maintenance, and on-demand service probes. Built on
[OpenWorkflow](https://openworkflow.dev) `0.11.0` over `node:sqlite`, it shares the
`when.sqlite` app DB and the `openworkflow.sqlite` queue with producers.
Its job is sending appointment emails: web enqueues a `send-appointment-email` run when an
appointment is created, confirmed, cancelled, rescheduled, or declined, and the
worker renders the email(s), sends them over SMTP, and records the per-channel
outcome on the appointment (`email_notification_status`). On boot it loads
config, migrates the app DB, connects the queue, registers its workflows, starts
polling, and serves `/healthz`.
Web enqueues `reconcile-appointment` when an appointment changes. The worker
coordinates calendar/video updates, renders emails, sends them over SMTP, and
records outcomes in the appointment action log and service status. It also handles
purge, calendar-repair requests, video-link generation, and service probes. On boot
it loads config, migrates the app DB, connects the queue, registers workflows,
starts polling with one execution slot, and serves `/healthz`.
## Run it
@@ -55,12 +55,18 @@ Relative paths resolve against the config file's directory, so web and worker
- `pnpm start` — `node dist/src/index.js`.
- `pnpm check` / `pnpm lint` / `pnpm test` / `pnpm test:coverage`.
## Recurring jobs (future)
## Engine integration
openworkflow has no native cron yet. When the worker needs periodic work — e.g. a
calendar refresh — schedule it itself: a `setInterval` that calls
`getOpenWorkflow().runWorkflow(spec, input, { idempotencyKey })` with a
time-bucketed key (e.g. `calendar-refresh:2026-06-05T12`), so a given bucket runs
at most once even if the interval double-fires or the process restarts (the same
pattern luzzle uses for its purge job). Swap to native cron once openworkflow
ships it.
Engine polling, schedule, heartbeat, and execution diagnostics go through the
worker's Pino logger. The adapter emits operational categories without forwarding
arbitrary engine error text or payloads that may contain credentials or guest links.
The existing job metrics describe **handler executions**, including replays. An
escaped handler execution may mean durable suspension rather than a terminal job
failure. Email metrics count physical SMTP attempt outcomes, not an exactly-once
delivery ledger.
OpenWorkflow `0.11.0` supports persisted cron schedules. This engine-upgrade step
still uses the existing process timers for refresh, expiry, and repair; migrating
those tasks to awaited cron workflows is a separate application change. Likewise,
upgrading the engine alone does not change the current workflow error handling.
+1 -1
View File
@@ -27,7 +27,7 @@
"eta": "4.6.0",
"kysely": "0.28.17",
"nodemailer": "9.0.3",
"openworkflow": "0.9.0",
"openworkflow": "0.11.0",
"pino": "10.3.1",
"prom-client": "15.1.3",
"ts-ics": "2.4.4"
+5 -1
View File
@@ -7,6 +7,7 @@ import { applyConfig } from './services/config-reload.js';
import { setWorkerContext, type WorkerContext } from './services/context.js';
import { createHealthServer } from './services/health.js';
import { logger } from './services/logger.js';
import { createEngineLogger } from './services/engine-logger.js';
import { registerWorkflows } from './workflows/index.js';
import { registerSyncCalendarsWorkflow } from './workflows/sync-calendars.js';
import { refreshCycle } from './calendar/refresh.js';
@@ -48,7 +49,10 @@ async function main(): Promise<void> {
}
}, CALENDAR_SYNC_FLOOR_MS);
const client = initOpenWorkflow({ dbPath: config.database.queue });
const client = initOpenWorkflow({
dbPath: config.database.queue,
logger: createEngineLogger(logger)
});
registerWorkflows();
registerSyncCalendarsWorkflow(calendarSync);
const worker = client.newWorker();
@@ -0,0 +1,91 @@
import pino from 'pino';
import { describe, expect, test } from 'vitest';
import { createEngineLogger } from './engine-logger.js';
function captureLogs() {
let output = '';
const log = pino(
{ level: 'debug', base: null, timestamp: false },
{
write(chunk: string) {
output += chunk;
}
}
);
return {
engine: createEngineLogger(log),
output: () => output,
entries: () =>
output
.trim()
.split('\n')
.map((line) => JSON.parse(line))
};
}
const secret = 'SECRET_CREDENTIAL_SENTINEL';
const capabilityUrl = 'https://example.test/appointment?token=CAPABILITY_SENTINEL';
const unsafeError = new Error(`${secret} ${capabilityUrl}`, {
cause: new Error(`nested cause ${secret}`)
});
unsafeError.name = secret;
unsafeError.stack = `unsafe stack ${capabilityUrl}`;
const unsafeArgs = [
secret,
capabilityUrl,
unsafeError,
{ nested: { password: secret, envelope: { html: capabilityUrl }, error: unsafeError } }
];
function expectNoSecrets(output: string) {
expect(output).not.toContain(secret);
expect(output).not.toContain('CAPABILITY_SENTINEL');
}
describe('createEngineLogger', () => {
test.each([
['Worker tick failed:', 'Workflow engine worker poll failed'],
['Worker schedule tick failed:', 'Workflow engine schedule poll failed'],
['Heartbeat failed:', 'Workflow engine heartbeat failed'],
[
'Critical error during workflow execution for run 936e818f-c6ab-4a1d-8af6-19d349482b50:',
'Workflow engine execution failed'
]
])('classifies %s without forwarding any input', (message, expected) => {
const logs = captureLogs();
logs.engine.error(`${message} ${secret} ${capabilityUrl}`, ...unsafeArgs);
expect(logs.entries()).toEqual([{ level: 50, msg: expected }]);
expectNoSecrets(logs.output());
});
test.each([
['debug', 20],
['info', 30],
['warn', 40],
['error', 50]
] as const)('maps unknown %s output to fixed text at the same level', (method, level) => {
const logs = captureLogs();
logs.engine[method](`unrecognized ${secret} ${capabilityUrl}`, ...unsafeArgs);
expect(logs.entries()).toEqual([{ level, msg: `Workflow engine ${method}` }]);
expectNoSecrets(logs.output());
});
test('does not inspect or serialize unexpected message objects or arguments', () => {
const logs = captureLogs();
const hostile = {
toString() {
throw new Error('must not stringify');
},
toJSON() {
throw new Error('must not serialize');
}
};
Reflect.apply(logs.engine.info, logs.engine, [hostile, ...unsafeArgs]);
logs.engine.warn('Heartbeat failed:', hostile, ...unsafeArgs);
expect(logs.entries()).toEqual([
{ level: 30, msg: 'Workflow engine info' },
{ level: 40, msg: 'Workflow engine heartbeat failed' }
]);
expectNoSecrets(logs.output());
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { Logger as EngineLogger } from 'openworkflow';
import type { Logger as PinoLogger } from './logger.js';
const operationalMessages = [
['Worker tick failed:', 'Workflow engine worker poll failed'],
['Worker schedule tick failed:', 'Workflow engine schedule poll failed'],
['Heartbeat failed:', 'Workflow engine heartbeat failed'],
['Critical error during workflow execution for run ', 'Workflow engine execution failed']
] as const;
function safeMessage(message: string, level: 'debug' | 'info' | 'warn' | 'error'): string {
if (typeof message === 'string') {
for (const [prefix, fixedMessage] of operationalMessages) {
if (message.startsWith(prefix)) return fixedMessage;
}
}
return `Workflow engine ${level}`;
}
/** Keep operational categories, never engine text or arguments that may contain secrets. */
export function createEngineLogger(log: PinoLogger): EngineLogger {
return {
debug: (message) => log.debug(safeMessage(message, 'debug')),
info: (message) => log.info(safeMessage(message, 'info')),
warn: (message) => log.warn(safeMessage(message, 'warn')),
error: (message) => log.error(safeMessage(message, 'error'))
};
}
+4 -4
View File
@@ -44,28 +44,28 @@ for (const metric of metricsToPrune) {
export const jobsTotal = new Counter({
name: 'when_jobs_total',
help: 'Total number of background jobs processed.',
help: 'Workflow handler executions by return (success) or escape (failure, including durable suspension), not terminal job outcomes.',
labelNames: ['job_name', 'status'],
registers: [register]
});
export const jobDuration = new Histogram({
name: 'when_job_duration_seconds',
help: 'Execution duration of background jobs.',
help: 'Duration of each workflow handler execution, including executions that durably suspend.',
labelNames: ['job_name'],
registers: [register]
});
export const jobsActive = new Gauge({
name: 'when_jobs_active',
help: 'Number of background jobs currently running.',
help: 'Number of active workflow handler executions; durably suspended runs are excluded.',
labelNames: ['job_name'],
registers: [register]
});
export const emailsTotal = new Counter({
name: 'when_emails_total',
help: 'Total number of emails sent.',
help: 'Physical SMTP send attempts by outcome, not unique email deliveries.',
labelNames: ['recipient_type', 'status', 'email_kind'],
registers: [register]
});
@@ -0,0 +1,374 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { OpenWorkflow, defineWorkflowSpec, type RetryPolicy, type Worker } from 'openworkflow';
import { BackendSqlite } from 'openworkflow/sqlite';
import { reconcileAppointment } from '@when/jobs';
import { describe, expect, test, vi } from 'vitest';
type Role = 'guest' | 'host';
const roles: Role[] = ['guest', 'host'];
// Keep the real budgets; only shorten native delays, not the number of attempts.
const deliveryRetry: RetryPolicy = {
maximumAttempts: 5,
initialInterval: '10ms',
backoffCoefficient: 2,
maximumInterval: '20ms'
};
interface Delivery {
send(role: Role): Promise<void>;
status(role: Role): Promise<void>;
parkRole?: Role;
waitTimeout?: number;
}
function openQueue(path: string, delivery: Delivery) {
const backend = BackendSqlite.connect(path);
const client = new OpenWorkflow({ backend });
const child = defineWorkflowSpec<{ role: Role }, 'sent'>({
name: 'delivery-child',
retryPolicy: { maximumAttempts: 3 }
});
const parent = defineWorkflowSpec<Record<string, never>, 'sent'[]>({
name: 'delivery-parent',
retryPolicy: {
...reconcileAppointment.retryPolicy,
initialInterval: '10ms',
maximumInterval: '20ms'
}
});
client.implementWorkflow(child, async ({ input, step }) => {
if (input.role === delivery.parkRole) {
await step.waitForSignal({ name: 'release', signal: `release:${input.role}` });
}
await step.run({ name: 'smtp', retryPolicy: deliveryRetry }, async () => {
await delivery.send(input.role);
return 'sent';
});
await step.run({ name: 'status', retryPolicy: deliveryRetry }, async () => {
await delivery.status(input.role);
return 'recorded';
});
return 'sent';
});
client.implementWorkflow(parent, ({ step }) =>
Promise.all(
roles.map((role) =>
step.runWorkflow(child, { role }, { name: role, timeout: delivery.waitTimeout })
)
)
);
const worker = client.newWorker({ concurrency: 1 });
return {
backend,
client,
worker,
parent,
async close() {
try {
await worker.stop();
} finally {
await backend.stop();
}
}
};
}
type Queue = ReturnType<typeof openQueue>;
async function runs(queue: Queue) {
const page = await queue.backend.listWorkflowRuns({ limit: 100 });
expect(page.pagination.next).toBeNull();
return page.data;
}
async function attempts(queue: Queue, workflowRunId: string, stepName?: string) {
const page = await queue.backend.listStepAttempts({ workflowRunId, limit: 100 });
expect(page.pagination.next).toBeNull();
return page.data.filter((attempt) => stepName === undefined || attempt.stepName === stepName);
}
async function readRun(queue: Queue, workflowRunId: string) {
const run = await queue.backend.getWorkflowRun({ workflowRunId });
expect(run).not.toBeNull();
return run!;
}
async function driveUntil(worker: Worker, assertion: () => Promise<void>) {
await vi.waitFor(
async () => {
await worker.tick();
// Observe a finished/parked pass, not an intermediate database write.
await worker.stop();
await assertion();
},
{ timeout: 3000, interval: 5 }
);
}
// No background poll loop: each tick admits work only when the assertion needs it.
async function admitted(queue: Queue, parentId: string) {
expect(await queue.worker.tick()).toBe(1);
await queue.worker.stop();
await vi.waitFor(
async () => {
const parent = await readRun(queue, parentId);
expect(parent).toMatchObject({ status: 'running', workerId: null });
const children = (await runs(queue)).filter((run) => run.id !== parentId);
expect(children).toHaveLength(2);
expect(children.map((run) => run.input)).toEqual(
expect.arrayContaining(roles.map((role) => ({ role })))
);
const links = await attempts(queue, parentId);
expect(links).toHaveLength(2);
expect(links.map((attempt) => attempt.childWorkflowRunId).sort()).toEqual(
children.map((run) => run.id).sort()
);
},
{ timeout: 3000, interval: 5 }
);
}
async function childFor(queue: Queue, role: Role) {
const children = (await runs(queue)).filter((run) => run.workflowName === 'delivery-child');
expect(children).toHaveLength(2);
const child = children.find((run) => JSON.stringify(run.input) === JSON.stringify({ role }));
expect(child).toBeDefined();
return child!;
}
function fakeDelivery(
failures: Record<Role, number>,
statusFailures: Partial<Record<Role, number>> = {}
) {
const sends = { guest: 0, host: 0 };
const statuses = { guest: 0, host: 0 };
return {
sends,
statuses,
send: vi.fn(async (role: Role) => {
sends[role] += 1;
if (sends[role] <= failures[role]) throw new Error(`SMTP ${role} unavailable`);
}),
status: vi.fn(async (role: Role) => {
statuses[role] += 1;
if (statuses[role] <= (statusFailures[role] ?? 0)) {
throw new Error(`status ${role} unavailable`);
}
})
};
}
describe('native parallel child workflows at concurrency one', () => {
test('admits both children, parks the parent and gives each child its full native budget', async () => {
const delivery = fakeDelivery({ guest: 2, host: 4 });
const queue = openQueue(':memory:', delivery);
try {
expect(queue.parent.retryPolicy?.maximumAttempts).toBe(3);
const parent = await queue.client.runWorkflow(queue.parent, {});
await admitted(queue, parent.workflowRun.id);
await driveUntil(queue.worker, async () => {
expect(await readRun(queue, parent.workflowRun.id)).toMatchObject({
status: 'completed',
output: ['sent', 'sent']
});
});
expect(await parent.result({ timeoutMs: 500 })).toEqual(['sent', 'sent']);
expect(delivery.sends).toEqual({ guest: 3, host: 5 });
for (const role of roles) {
const child = await childFor(queue, role);
expect(child).toMatchObject({ status: 'completed', output: 'sent' });
const smtp = await attempts(queue, child.id, 'smtp');
expect(smtp.filter((attempt) => attempt.status === 'failed')).toHaveLength(
role === 'guest' ? 2 : 4
);
expect(smtp.filter((attempt) => attempt.status === 'completed')).toHaveLength(1);
}
expect(await runs(queue)).toHaveLength(3);
expect(await attempts(queue, parent.workflowRun.id)).toHaveLength(2);
} finally {
await queue.close();
}
});
test.each(roles)(
'%s exhaustion leaves its sibling independent and eventually fails the parent',
async (failedRole) => {
const siblingRole = failedRole === 'guest' ? 'host' : 'guest';
const delivery = fakeDelivery({ [failedRole]: 5, [siblingRole]: 4 } as Record<Role, number>);
const queue = openQueue(':memory:', { ...delivery, parkRole: siblingRole });
try {
const parent = await queue.client.runWorkflow(queue.parent, {});
await admitted(queue, parent.workflowRun.id);
await driveUntil(queue.worker, async () => {
expect(await childFor(queue, failedRole)).toMatchObject({
status: 'failed',
output: null,
error: { message: `SMTP ${failedRole} unavailable` }
});
expect(await childFor(queue, siblingRole)).toMatchObject({
status: 'running',
workerId: null
});
});
const failed = await childFor(queue, failedRole);
expect(failed.status).toBe('failed');
expect(await attempts(queue, failed.id, 'smtp')).toHaveLength(5);
expect(
(await attempts(queue, failed.id, 'smtp')).every((attempt) => attempt.status === 'failed')
).toBe(true);
expect(delivery.sends[failedRole]).toBe(5);
expect(delivery.sends[siblingRole]).toBe(0);
// Failure propagation can wait for the sibling; it must not cancel it.
expect((await readRun(queue, parent.workflowRun.id)).status).not.toBe('completed');
const sibling = await childFor(queue, siblingRole);
expect(sibling).toMatchObject({ status: 'running', workerId: null });
expect(await attempts(queue, sibling.id, 'release')).toHaveLength(1);
expect(await queue.client.sendSignal({ signal: `release:${siblingRole}` })).toEqual({
workflowRunIds: [sibling.id]
});
await driveUntil(queue.worker, async () => {
expect(await readRun(queue, sibling.id)).toMatchObject({
status: 'completed',
output: 'sent'
});
expect(await readRun(queue, parent.workflowRun.id)).toMatchObject({
status: 'failed',
output: null,
error: { message: `SMTP ${failedRole} unavailable` }
});
});
await expect(parent.result({ timeoutMs: 500 })).rejects.toThrow(
`SMTP ${failedRole} unavailable`
);
expect(delivery.sends).toEqual({ guest: 5, host: 5 });
expect(await attempts(queue, sibling.id, 'smtp')).toHaveLength(5);
expect((await readRun(queue, parent.workflowRun.id)).status).toBe('failed');
expect(await runs(queue)).toHaveLength(3);
} finally {
await queue.close();
}
}
);
test('both children can exhaust five attempts independently', async () => {
const delivery = fakeDelivery({ guest: 5, host: 5 });
const queue = openQueue(':memory:', delivery);
try {
const parent = await queue.client.runWorkflow(queue.parent, {});
await admitted(queue, parent.workflowRun.id);
await driveUntil(queue.worker, async () => {
expect((await runs(queue)).map((run) => run.status)).toEqual([
'failed',
'failed',
'failed'
]);
});
expect(delivery.sends).toEqual({ guest: 5, host: 5 });
for (const role of roles) {
expect(await attempts(queue, (await childFor(queue, role)).id, 'smtp')).toHaveLength(5);
}
} finally {
await queue.close();
}
});
test('reopens a file-backed queue during child retry without duplicating children or saved SMTP success', async () => {
const directory = await mkdtemp(join(tmpdir(), 'when-children-'));
const delivery = fakeDelivery({ guest: 0, host: 1 }, { host: 2 });
let queue = openQueue(join(directory, 'queue.sqlite'), delivery);
try {
const parent = await queue.client.runWorkflow(queue.parent, {});
const parentId = parent.workflowRun.id;
await admitted(queue, parentId);
await driveUntil(queue.worker, async () => {
const host = await childFor(queue, 'host');
expect(host).toMatchObject({ status: 'pending', workerId: null });
expect(await attempts(queue, host.id, 'smtp')).toHaveLength(1);
});
const childIds = (await runs(queue)).map((run) => run.id).sort();
await queue.close();
queue = openQueue(join(directory, 'queue.sqlite'), delivery);
await driveUntil(queue.worker, async () => {
const host = await childFor(queue, 'host');
expect(host).toMatchObject({ status: 'pending', workerId: null });
expect(await attempts(queue, host.id, 'status')).toHaveLength(1);
expect(await attempts(queue, host.id, 'smtp')).toHaveLength(2);
expect((await childFor(queue, 'guest')).status).toBe('completed');
});
const host = await childFor(queue, 'host');
const savedSmtp = await attempts(queue, host.id, 'smtp');
const savedGuest = await attempts(queue, (await childFor(queue, 'guest')).id);
await queue.close();
queue = openQueue(join(directory, 'queue.sqlite'), delivery);
await driveUntil(queue.worker, async () => {
expect(await readRun(queue, parentId)).toMatchObject({
status: 'completed',
output: ['sent', 'sent']
});
});
expect((await runs(queue)).map((run) => run.id).sort()).toEqual(childIds);
expect(await attempts(queue, parentId)).toHaveLength(2);
expect(await attempts(queue, host.id, 'smtp')).toEqual(savedSmtp);
expect(await attempts(queue, (await childFor(queue, 'guest')).id)).toEqual(savedGuest);
expect((await attempts(queue, host.id, 'status')).map((attempt) => attempt.status)).toEqual([
'failed',
'failed',
'completed'
]);
expect(delivery.sends).toEqual({ guest: 1, host: 2 });
expect(delivery.statuses).toEqual({ guest: 1, host: 3 });
} finally {
try {
await queue.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
}
});
test.each(['cancel', 'timeout'] as const)(
'parent %s leaves its already-started child alive',
async (mode) => {
const delivery = fakeDelivery({ guest: 0, host: 0 });
const queue = openQueue(':memory:', {
...delivery,
parkRole: 'host',
waitTimeout: mode === 'timeout' ? 50 : undefined
});
try {
const parent = await queue.client.runWorkflow(queue.parent, {});
await admitted(queue, parent.workflowRun.id);
await driveUntil(queue.worker, async () => {
const host = await childFor(queue, 'host');
expect(host).toMatchObject({ status: 'running', workerId: null });
expect(await attempts(queue, host.id, 'release')).toHaveLength(1);
});
if (mode === 'cancel') await parent.cancel();
await driveUntil(queue.worker, async () => {
expect((await readRun(queue, parent.workflowRun.id)).status).toBe(
mode === 'cancel' ? 'canceled' : 'failed'
);
});
await expect(parent.result({ timeoutMs: 500 })).rejects.toThrow(
mode === 'cancel' ? /cancel/i : /Timed out waiting for child workflow/
);
const host = await childFor(queue, 'host');
expect(host.status).toBe('running');
await queue.client.sendSignal({ signal: 'release:host' });
await driveUntil(queue.worker, async () => {
expect(await readRun(queue, host.id)).toMatchObject({
status: 'completed',
output: 'sent'
});
});
expect(delivery.sends.host).toBe(1);
expect(await runs(queue)).toHaveLength(3);
} finally {
await queue.close();
}
}
);
});
+34 -14
View File
@@ -1,23 +1,43 @@
# @when/jobs
Shared job/workflow contract for the `when` workspace, built on
[openworkflow](https://openworkflow.dev) (durable execution on `node:sqlite`,
zero native addons).
Shared workflow contracts for the `when` workspace, built on
[OpenWorkflow](https://openworkflow.dev) `0.11.0` over `node:sqlite`.
This package is the single source of truth for **what** jobs exist and their
input/output shapes. The producer (`@when/web`) triggers runs from a spec; the
worker (`@when/worker`) provides the implementation. Keeping the spec here lets
both sides share types and resolve the workflow by name without web depending on
the worker.
The producer (`@when/web`, or `@when/cli` for service probes) enqueues work from a
shared spec; `@when/worker` registers the implementation. Web never imports worker
implementations. Initializing a producer client does not start a worker or register
recurring schedules.
## API
- `sendAppointmentEmail` — the `send-appointment-email` workflow spec.
- Types: `AppointmentEmailKind`, `SendAppointmentEmailInput`, `SendAppointmentEmailResult`.
- `createBackend(path, opts?)` / `createClient(path, opts?)` — connect a
`node:sqlite` openworkflow backend / build an `OpenWorkflow` client over it.
- `reconcileAppointment` — coordinate appointment calendar/video updates and email.
- `purgeAppointment` — clean up purged appointment chains.
- `syncCalendars` — request the worker's calendar repair scan.
- `testEmail`, `testProvider`, `listProviderCalendars`, `testCalendar`,
`generateVideoChat` — on-demand worker operations.
- `initOpenWorkflow({ dbPath, logger? })` — open and migrate the queue database and
initialize the process-wide client. The first call wins, including its logger.
- `getOpenWorkflow()` — return that client; throws before initialization or after
closing.
- `closeOpenWorkflow()` — idempotently close the queue connection, **after stopping
its worker**. Closing an initialized client is terminal for that process. Calling
this before initialization is a no-op.
- `getWorkflowRun(db, id)`, `getStepAttempts(db, id)` — read queue history through a
caller-owned `DatabaseSync` connection.
Specs and their input/output types live in `src/specs.ts`. The `./specs` entrypoint
exports contracts without the process-wide client helpers.
Queue migrations run on connection, including producer connections. Existing step
results survive the engine upgrade, but that does not make a changed application
workflow graph safe to replay: drain old appointment work before deploying changed
handlers. Back up both SQLite databases consistently before an engine upgrade.
## Scripts
- `pnpm build` — typecheck (`tsc --noEmit`).
- `pnpm test` / `pnpm test:coverage` / `pnpm lint`.
- `build` — typecheck and compile to `dist/`.
- `check` — typecheck without emitting.
- `test` / `test:coverage` — unit and real-engine integration tests.
- `lint` — Prettier and ESLint.
Tests use temporary queues, never a personal deployment database.
+1 -1
View File
@@ -30,7 +30,7 @@
"dependencies": {
"@when/config": "workspace:*",
"@when/db": "workspace:*",
"openworkflow": "0.9.0"
"openworkflow": "0.11.0"
},
"devDependencies": {
"@eslint/js": "catalog:",
+1
View File
@@ -31,6 +31,7 @@ export type {
} from './specs.js';
export {
initOpenWorkflow,
closeOpenWorkflow,
getOpenWorkflow,
getWorkflowRun,
getStepAttempts
+166 -108
View File
@@ -1,128 +1,186 @@
import { describe, expect, test } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import {
initOpenWorkflow,
getOpenWorkflow,
getWorkflowRun,
getStepAttempts
} from './openworkflow.js';
import { OpenWorkflow, defineWorkflowSpec } from 'openworkflow';
import { BackendSqlite } from 'openworkflow/sqlite';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { getWorkflowRun, getStepAttempts } from './openworkflow.js';
import { reconcileAppointment } from './specs.js';
import type { ReconcileAppointmentInput } from './specs.js';
describe('client singleton', () => {
test('getOpenWorkflow throws before initialization', () => {
expect(() => getOpenWorkflow()).toThrow(/has not been initialized/);
let api: typeof import('./index.js');
beforeEach(async () => {
vi.resetModules();
api = await import('./index.js');
});
test('initOpenWorkflow returns a reusable singleton', () => {
// :memory: backend auto-migrates on connect.
const client = initOpenWorkflow({ dbPath: ':memory:' });
expect(client).toBeDefined();
expect(getOpenWorkflow()).toBe(client);
// Idempotent: a second init keeps the first instance.
expect(initOpenWorkflow({ dbPath: ':memory:' })).toBe(client);
afterEach(async () => {
await api.closeOpenWorkflow();
});
test('the client can enqueue a run (producer-only path)', async () => {
const input: ReconcileAppointmentInput = {
appointmentId: 'appt-1',
test('get throws before initialization; closing before init does not poison later init', async () => {
expect(() => api.getOpenWorkflow()).toThrow(/has not been initialized/);
await api.closeOpenWorkflow();
const client = api.initOpenWorkflow({ dbPath: ':memory:' });
expect(api.getOpenWorkflow()).toBe(client);
});
test('first initialization wins and a producer can enqueue without worker registration', async () => {
const client = api.initOpenWorkflow({ dbPath: ':memory:' });
expect(api.initOpenWorkflow({ dbPath: ':memory:' })).toBe(client);
expect(api.getOpenWorkflow()).toBe(client);
const handle = await client.runWorkflow(reconcileAppointment, {
appointmentId: 'synthetic-appointment',
emailKind: 'confirmed'
};
const handle = await getOpenWorkflow().runWorkflow(reconcileAppointment, input);
});
expect(handle.workflowRun.id).toBeTruthy();
expect(handle.workflowRun.status).toBe('pending');
});
test('public close is shared, idempotent and terminal for init/get and the retained client', async () => {
const client = api.initOpenWorkflow({ dbPath: ':memory:' });
const closing = api.closeOpenWorkflow();
expect(api.closeOpenWorkflow()).toBe(closing);
expect(() => api.getOpenWorkflow()).toThrow(/has been closed/);
expect(() => api.initOpenWorkflow({ dbPath: ':memory:' })).toThrow(/has been closed/);
await closing;
expect(api.closeOpenWorkflow()).toBe(closing);
await expect(
client.runWorkflow(reconcileAppointment, { appointmentId: 'after-close' })
).rejects.toThrow();
});
});
describe('read helpers', () => {
function seedQueueDb(): DatabaseSync {
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE workflow_runs (
id TEXT PRIMARY KEY,
workflow_name TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
input TEXT,
output TEXT,
finished_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE step_attempts (
id TEXT PRIMARY KEY,
workflow_run_id TEXT NOT NULL,
step_name TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
error TEXT,
created_at TEXT NOT NULL
);
`);
db.prepare(
`INSERT INTO workflow_runs (id, workflow_name, status, error, input, output, finished_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
'run-1',
'reconcile-appointment',
'completed',
null,
'{}',
'"reconciled"',
null,
'2026-01-01T10:00:00Z'
);
db.prepare(
`INSERT INTO step_attempts (id, workflow_run_id, step_name, status, started_at, finished_at, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
'step-1',
'run-1',
'smtp:jane@example.com',
'completed',
'2026-01-01T10:00:01Z',
'2026-01-01T10:00:02Z',
null,
'2026-01-01T10:00:01Z'
);
db.prepare(
`INSERT INTO step_attempts (id, workflow_run_id, step_name, status, started_at, finished_at, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
'step-2',
'run-1',
'status',
'completed',
'2026-01-01T10:00:03Z',
'2026-01-01T10:00:04Z',
null,
'2026-01-01T10:00:03Z'
);
return db;
}
describe('real SQLite engine integration', () => {
let directory: string;
let raw: DatabaseSync;
let backend: BackendSqlite;
let client: OpenWorkflow;
let worker: ReturnType<OpenWorkflow['newWorker']> | undefined;
test('getWorkflowRun returns a run by id, or null when missing', () => {
const db = seedQueueDb();
const run = getWorkflowRun(db, 'run-1');
expect(run).not.toBeNull();
expect(run!.workflow_name).toBe('reconcile-appointment');
expect(run!.status).toBe('completed');
expect(getWorkflowRun(db, 'missing')).toBeNull();
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'when-queue-read-'));
const path = join(directory, 'queue.sqlite');
backend = BackendSqlite.connect(path);
client = new OpenWorkflow({ backend });
raw = new DatabaseSync(path);
});
test("getStepAttempts lists a run's steps in chronological order", () => {
const db = seedQueueDb();
const steps = getStepAttempts(db, 'run-1');
expect(steps).toHaveLength(2);
expect(steps[0]).toEqual({
phase: 'smtp:jane@example.com',
status: 'completed',
started_at: '2026-01-01T10:00:01Z',
finished_at: '2026-01-01T10:00:02Z',
message: null
afterEach(async () => {
try {
await worker?.stop();
await backend.stop();
} finally {
worker = undefined;
vi.restoreAllMocks();
vi.useRealTimers();
raw.close();
await rm(directory, { recursive: true, force: true });
}
});
test('raw readers expose real run and chronological step rows, including missing ids', async () => {
const workflow = client.defineWorkflow({ name: 'reader-probe' }, async ({ step }) => {
await step.run({ name: 'first' }, async () => {
await new Promise((resolve) => setTimeout(resolve, 2));
return 'saved';
});
return step.run({ name: 'second' }, () => 'done');
});
expect(steps[1].phase).toBe('status');
const handle = await workflow.run({});
expect(getWorkflowRun(raw, handle.workflowRun.id)).toMatchObject({ status: 'pending' });
expect(getStepAttempts(raw, handle.workflowRun.id)).toEqual([]);
worker = client.newWorker({ concurrency: 1 });
expect(await worker.tick()).toBe(1);
await worker.stop();
const run = getWorkflowRun(raw, handle.workflowRun.id)!;
expect(run).toMatchObject({
workflow_name: 'reader-probe',
status: 'completed',
input: '{}',
output: '"done"',
error: null
});
expect(run.finished_at).toEqual(expect.any(String));
const steps = getStepAttempts(raw, handle.workflowRun.id);
expect(steps).toEqual(
['first', 'second'].map((phase) => ({
phase,
status: 'completed',
message: null,
started_at: expect.any(String),
finished_at: expect.any(String)
}))
);
expect(steps[0].started_at! <= steps[1].started_at!).toBe(true);
expect(getWorkflowRun(raw, 'missing')).toBeNull();
expect(getStepAttempts(raw, 'missing')).toEqual([]);
});
test.each([
['completed', 10],
['completed', 11],
['failed', 10],
['failed', 11],
['canceled', 10],
['canceled', 11]
] as const)(
'result() observes %s when its read crosses the %ims timeout boundary',
async (status, elapsed) => {
vi.useFakeTimers({ toFake: ['Date'] });
const start = new Date('2026-02-01T00:00:00.000Z');
vi.setSystemTime(start);
const workflow = client.defineWorkflow(
{ name: 'boundary-probe', retryPolicy: { maximumAttempts: 1 } },
() => {
if (status === 'failed') throw new Error('synthetic boundary failure');
return 'finished';
}
);
const handle = await workflow.run({});
if (status === 'canceled') {
await handle.cancel();
} else {
worker = client.newWorker({ concurrency: 1 });
expect(await worker.tick()).toBe(1);
await worker.stop();
}
expect(getWorkflowRun(raw, handle.workflowRun.id)!.status).toBe(status);
// Model a backend read completing at/after the deadline. Exercise the actual
// returned handle, not a local reproduction of its polling algorithm.
const read = backend.getWorkflowRun.bind(backend);
vi.spyOn(backend, 'getWorkflowRun').mockImplementation(async (params) => {
const run = await read(params);
vi.setSystemTime(start.getTime() + elapsed);
return run;
});
if (status === 'completed') {
await expect(handle.result({ timeoutMs: 10 })).resolves.toBe('finished');
} else {
await expect(handle.result({ timeoutMs: 10 })).rejects.toThrow(
status === 'failed' ? /synthetic boundary failure/ : /canceled/
);
}
}
);
test('result() still times out if a read crosses the boundary with a nonterminal run', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const start = new Date('2026-02-01T00:00:00.000Z');
vi.setSystemTime(start);
const handle = await client.runWorkflow(
defineWorkflowSpec({ name: 'unregistered-producer' }),
{}
);
const read = backend.getWorkflowRun.bind(backend);
vi.spyOn(backend, 'getWorkflowRun').mockImplementation(async (params) => {
const run = await read(params);
vi.setSystemTime(start.getTime() + 11);
return run;
});
await expect(handle.result({ timeoutMs: 10 })).rejects.toThrow(/Timed out/);
expect(getWorkflowRun(raw, handle.workflowRun.id)!.status).toBe('pending');
});
});
+16 -19
View File
@@ -1,29 +1,34 @@
import { OpenWorkflow } from 'openworkflow';
import { OpenWorkflow, type Logger } from 'openworkflow';
import { BackendSqlite } from 'openworkflow/sqlite';
import type { DatabaseSync } from 'node:sqlite';
let instance: OpenWorkflow | null = null;
let backend: BackendSqlite | null = null;
let closePromise: Promise<void> | null = null;
export interface InitOpenWorkflowOptions {
/** Path to the openworkflow `node:sqlite` queue DB (or `:memory:`). */
dbPath: string;
logger?: Logger;
}
/**
* Initialize the process-wide openworkflow client over a `node:sqlite` backend.
* Idempotent: the first call wins; later calls return the same instance. The
* worker calls this at boot; the web producer keeps its own client (it lives in
* a separate process), so the singleton is per-process either way.
*/
export function initOpenWorkflow({ dbPath }: InitOpenWorkflowOptions): OpenWorkflow {
export function initOpenWorkflow({ dbPath, logger }: InitOpenWorkflowOptions): OpenWorkflow {
if (closePromise) throw new Error('openworkflow client has been closed');
if (!instance) {
instance = new OpenWorkflow({ backend: BackendSqlite.connect(dbPath) });
backend = BackendSqlite.connect(dbPath);
instance = new OpenWorkflow({ backend, logger });
}
return instance;
}
/** The initialized client. Throws if `initOpenWorkflow` hasn't been called. */
// Stop the worker before closing its database connection.
export function closeOpenWorkflow(): Promise<void> {
if (!backend) return Promise.resolve();
closePromise ??= backend.stop();
return closePromise;
}
export function getOpenWorkflow(): OpenWorkflow {
if (closePromise) throw new Error('openworkflow client has been closed');
if (!instance) {
throw new Error(
'openworkflow client has not been initialized; call initOpenWorkflow({ dbPath }) first'
@@ -32,7 +37,6 @@ export function getOpenWorkflow(): OpenWorkflow {
return instance;
}
/** A row from openworkflow's `workflow_runs` table (the columns web cares about). */
export interface WorkflowRunRow {
id: string;
workflow_name: string;
@@ -44,7 +48,6 @@ export interface WorkflowRunRow {
created_at: string;
}
/** A flattened step attempt, for surfacing run progress in the UI. */
export interface StepAttemptRow {
phase: string;
status: string;
@@ -53,11 +56,6 @@ export interface StepAttemptRow {
message: string | null;
}
/**
* Look up a single workflow run by id. Read-only over a raw `DatabaseSync`
* handle to the queue DB, so consumers (web) can surface run status without
* pulling in the openworkflow engine.
*/
export function getWorkflowRun(db: DatabaseSync, id: string): WorkflowRunRow | null {
const row = db
.prepare(
@@ -68,7 +66,6 @@ export function getWorkflowRun(db: DatabaseSync, id: string): WorkflowRunRow | n
return row ? (row as unknown as WorkflowRunRow) : null;
}
/** List a run's step attempts in chronological order. */
export function getStepAttempts(db: DatabaseSync, workflowRunId: string): StepAttemptRow[] {
const rows = db
.prepare(
+31 -10
View File
@@ -159,8 +159,8 @@ importers:
specifier: 2.0.1
version: 2.0.1
openworkflow:
specifier: 0.9.0
version: 0.9.0
specifier: 0.11.0
version: 0.11.0(@opentelemetry/api@1.9.1)
pino:
specifier: 10.3.1
version: 10.3.1
@@ -277,8 +277,8 @@ importers:
specifier: 9.0.3
version: 9.0.3
openworkflow:
specifier: 0.9.0
version: 0.9.0
specifier: 0.11.0
version: 0.11.0(@opentelemetry/api@1.9.1)
pino:
specifier: 10.3.1
version: 10.3.1
@@ -470,8 +470,8 @@ importers:
specifier: workspace:*
version: link:../db
openworkflow:
specifier: 0.9.0
version: 0.9.0
specifier: 0.11.0
version: 0.11.0(@opentelemetry/api@1.9.1)
devDependencies:
'@eslint/js':
specifier: 'catalog:'
@@ -1608,6 +1608,10 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
cron-parser@5.10.1:
resolution: {integrity: sha512-pKRrRagItwk9rGIStcUyMxFX34x56zoX3KDf9HJ4ttwkXIK1qxK63EvXvEDmlxI9AdPJ+Y7TEKRftRlW5eSS7A==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2048,6 +2052,10 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -2108,12 +2116,15 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
openworkflow@0.9.0:
resolution: {integrity: sha512-Ii6qkAh4ozbTHH5w7PdjjNYGF7u8xV2rsvTaBleo21rRlS8sEzrvDdxWsHawiY1VLfI1nfuRf8I3NDerlrugtg==}
engines: {node: '>=20'}
openworkflow@0.11.0:
resolution: {integrity: sha512-fdDfRjuNMLV+p3svZShrV5i/ZPbBS8GruWLlthk8Ezt3ueYPwVKk0mcD+knAmQ4J9RASBsY5JqiatiLtEcwgVw==}
engines: {node: '>=22.22.0'}
peerDependencies:
'@opentelemetry/api': '1'
postgres: ^3.4.9
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
postgres:
optional: true
@@ -3549,6 +3560,10 @@ snapshots:
cookie@0.6.0: {}
cron-parser@5.10.1:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -3944,6 +3959,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
luxon@3.7.2: {}
lz-string@1.5.0: {}
magic-string@0.30.21:
@@ -3995,7 +4012,11 @@ snapshots:
dependencies:
wrappy: 1.0.2
openworkflow@0.9.0: {}
openworkflow@0.11.0(@opentelemetry/api@1.9.1):
dependencies:
cron-parser: 5.10.1
optionalDependencies:
'@opentelemetry/api': 1.9.1
optionator@0.9.4:
dependencies: