feat(worker): introduce the OpenWorkflow service foundation #11

Merged
eleith merged 2 commits from feat/worker-service into main 2026-11-05 15:22:10 +00:00
34 changed files with 1209 additions and 9 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@goto/web", "@goto/cli"]],
"fixed": [["@goto/web", "@goto/cli", "@goto/worker"]],
"linked": [],
"access": "restricted",
"baseBranch": "main",
+7
View File
@@ -0,0 +1,7 @@
---
'@goto/worker': minor
'@goto/web': minor
'@goto/cli': minor
---
Introduce the standalone OpenWorkflow worker service with health checks, graceful shutdown, separate workflow storage, and matching CI/CD. Analytics workflows will be introduced separately.
+116
View File
@@ -0,0 +1,116 @@
name: CI/CD (Worker Service)
on:
push:
branches: [main]
tags: ['@goto/worker@*']
paths:
- '*'
- .changeset/**
- .github/workflows/**
- .woodpecker/**
- apps/worker/**
- packages/config/**
- tools/**
pull_request:
branches: [main]
paths:
- '*'
- .changeset/**
- .github/workflows/**
- .woodpecker/**
- apps/worker/**
- packages/config/**
- tools/**
permissions:
contents: read
jobs:
check:
name: Worker checks
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.9.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 26.5.0
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check editor schema
run: pnpm schema:check
- name: Build worker and dependencies
run: pnpm --filter '@goto/worker...' build
- name: Check types
run: pnpm --filter @goto/worker check
- name: Lint worker
run: pnpm --filter @goto/worker lint
- name: Unit tests
run: pnpm --filter @goto/worker test:coverage
- name: Compiled service smoke test
run: pnpm --filter @goto/worker test:smoke
build-push:
name: Publish worker image
if: >-
github.event_name == 'push' &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/@goto/worker@'))
needs: check
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 26.5.0
- name: Check release version
if: github.ref_type == 'tag'
run: |
node --input-type=module <<'NODE'
import { readFileSync } from 'node:fs';
const tag = process.env.GITHUB_REF_NAME ?? '';
const version = JSON.parse(readFileSync('apps/worker/package.json', 'utf8')).version;
const stableVersion = /^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$/;
if (!stableVersion.test(version) || tag !== '@goto/worker@' + version) {
throw new Error('Expected a stable @goto/worker@<version> tag matching apps/worker/package.json');
}
NODE
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/eleith/goto-worker
flavor: latest=false
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=match,pattern=^@goto/worker@(\d+\.\d+\.\d+)$,group=1
type=match,pattern=^@goto/worker@(\d+\.\d+)\.\d+$,group=1
type=match,pattern=^@goto/worker@(\d+)\.\d+\.\d+$,group=1
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push worker image
uses: docker/build-push-action@v7
with:
context: .
file: apps/worker/Dockerfile
target: prod
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+133
View File
@@ -0,0 +1,133 @@
workspace:
base: /workspace
path: src
when:
- event: push
branch: main
path:
include:
- '*'
- .changeset/**
- .github/workflows/**
- .woodpecker/**
- apps/worker/**
- packages/config/**
- tools/**
- event: pull_request
path:
include:
- '*'
- .changeset/**
- .github/workflows/**
- .woodpecker/**
- apps/worker/**
- packages/config/**
- tools/**
- event: tag
ref: 'refs/tags/@goto/worker@*'
steps:
prepare:
image: node:26.5.0-bookworm
environment:
COREPACK_HOME: /workspace/src/.pnpm-store/corepack
commands:
- npm install --global corepack@0.36.0
- corepack enable
- corepack install --global pnpm@11.9.0
- pnpm config set store-dir .pnpm-store
- pnpm install --frozen-lockfile
- pnpm schema:check
- pnpm --filter '@goto/worker...' build
lint:
image: node:26.5.0-bookworm
environment:
COREPACK_HOME: /workspace/src/.pnpm-store/corepack
commands:
- npm install --global corepack@0.36.0
- corepack enable
- pnpm --filter @goto/worker check
- pnpm --filter @goto/worker lint
test:
image: node:26.5.0-bookworm
environment:
COREPACK_HOME: /workspace/src/.pnpm-store/corepack
commands:
- npm install --global corepack@0.36.0
- corepack enable
- pnpm --filter @goto/worker test:coverage
- pnpm --filter @goto/worker test:smoke
check-version-and-extract-tags:
image: node:26.5.0-bookworm
commands:
- |
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from 'node:fs';
const tag = process.env.CI_COMMIT_TAG ?? '';
const version = JSON.parse(readFileSync('apps/worker/package.json', 'utf8')).version;
const stableVersion = /^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)$/;
if (!stableVersion.test(version) || tag !== '@goto/worker@' + version) {
throw new Error('Expected a stable @goto/worker@<version> tag matching apps/worker/package.json');
}
const [major, minor] = version.split('.');
writeFileSync('.tags', [version, major + '.' + minor, major].join('\n') + '\n');
NODE
when:
- event: tag
ref: 'refs/tags/@goto/worker@*'
build-docker-main:
image: woodpeckerci/plugin-docker-buildx:6.0.3
settings:
repo: git.eleith.com/eleith.private/goto-worker
username: eleith
registry: https://git.eleith.com
context: .
dockerfile: apps/worker/Dockerfile
target: prod
platforms: linux/amd64
tags:
- latest
password:
from_secret: gitea_docker_registry
when:
- event: push
branch: main
build-docker-tag:
image: woodpeckerci/plugin-docker-buildx:6.0.3
settings:
repo: git.eleith.com/eleith.private/goto-worker
username: eleith
registry: https://git.eleith.com
context: .
dockerfile: apps/worker/Dockerfile
target: prod
platforms: linux/amd64
tags_file: .tags
password:
from_secret: gitea_docker_registry
when:
- event: tag
ref: 'refs/tags/@goto/worker@*'
notify:
image: deblan/woodpecker-email:3.3.0
settings:
dsn:
from_secret: woodpecker_email_dsn
from:
address:
from_secret: woodpecker_email_from_address
name:
from_secret: woodpecker_email_from_name
recipients:
from_secret: woodpecker_email_recipient
recipients_only: true
when:
- event: [push, tag]
status: [failure, success]
+35
View File
@@ -40,6 +40,41 @@ services:
start_period: 60s
retries: 3
goto-worker:
image: goto-worker:dev
platform: linux/amd64
build:
context: ../..
dockerfile: apps/worker/Dockerfile
target: dev
args:
LOCAL_UID: ${LOCAL_UID:-1000}
LOCAL_GID: ${LOCAL_GID:-1000}
user: '${LOCAL_UID:-1000}:${LOCAL_GID:-1000}'
working_dir: /m/apps/worker
env_file:
- path: .env
required: false
environment:
NODE_ENV: development
PORT: 9000
volumes:
- ./config:/m/apps/worker/config:ro
- ./data:/m/apps/worker/data
- ../worker/src:/m/apps/worker/src:ro
- ../../packages/config/src:/m/packages/config/src:ro
restart: unless-stopped
healthcheck:
test:
- CMD
- node
- --eval
- fetch('http://localhost:9000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
goto-cli:
image: goto-cli:dev
platform: linux/amd64
+36
View File
@@ -37,6 +37,42 @@ services:
start_period: 5s
retries: 3
goto-worker:
image: goto-worker:local
platform: linux/amd64
build:
context: ../..
dockerfile: apps/worker/Dockerfile
target: prod
working_dir: /app
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
PORT: 9000
volumes:
- ./config:/app/config:ro
- ./data:/app/data
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
restart: unless-stopped
healthcheck:
test:
- CMD
- node
- --eval
- fetch('http://localhost:9000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
goto-cli:
image: goto-cli:local
platform: linux/amd64
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
FROM node:26.5.0-alpine AS base
WORKDIR /m
ENV CI=true \
HOME=/home/app \
PNPM_HOME=/home/app/.pnpm \
XDG_DATA_HOME=/home/app/.local/share \
XDG_CACHE_HOME=/home/app/.cache \
XDG_CONFIG_HOME=/home/app/.config
RUN npm install -g pnpm@11.9.0 \
&& mkdir -p /home/app/store \
&& printf 'store-dir=/home/app/store\n' > /home/app/.npmrc
FROM base AS dev
ARG LOCAL_UID=1000
ARG LOCAL_GID=1000
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
RUN mkdir -p /m/apps/worker/config /m/apps/worker/data /m/packages/config/dist \
&& chown -R ${LOCAL_UID}:${LOCAL_GID} /home/app /m/apps/worker/data /m/packages/config/dist
USER ${LOCAL_UID}:${LOCAL_GID}
WORKDIR /m/apps/worker
EXPOSE 9000
CMD ["pnpm", "run", "dev"]
FROM base AS builder
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm fetch --frozen-lockfile
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store pnpm install --offline --frozen-lockfile
RUN pnpm --filter "@goto/worker..." build
FROM builder AS pruner
RUN --mount=type=cache,id=pnpm-store,target=/home/app/store \
pnpm --config.hoist-workspace-packages=false --filter @goto/worker --prod deploy --legacy /pruned
FROM node:26.5.0-alpine AS prod
WORKDIR /app
ENV NODE_ENV=production \
PORT=9000 \
HOST=0.0.0.0
COPY --from=pruner /pruned/dist /app/dist
COPY --from=pruner /pruned/node_modules /app/node_modules
COPY --from=pruner /pruned/package.json /app/package.json
RUN mkdir -p /app/config /app/data && chown node:node /app/data
USER node
EXPOSE 9000
CMD ["node", "/app/dist/index.js"]
+154
View File
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DatabaseSync } from 'node:sqlite';
async function checkStartup(mode, port = 0) {
const directory = await mkdtemp(join(tmpdir(), 'goto-worker-startup-'));
let child;
let closed;
let exited = false;
let killed = false;
let output = '';
let message;
let deadline;
try {
await mkdir(join(directory, 'config'));
await writeFile(
join(directory, 'config/goto.yaml'),
JSON.stringify({
site: { url: 'http://admin.example.test' },
auth: { credentials: { username: 'smoke', password: 'private-worker-smoke-password' } },
domains: {}
})
);
const source = mode === 'source';
const entry = new URL(source ? '../src/index.ts' : '../dist/index.js', import.meta.url);
const register = fileURLToPath(new URL('../../../tools/register.mjs', import.meta.url));
const execArgv = source ? ['--conditions=development', '--import', register] : [];
child = fork(entry, [], {
cwd: directory,
env: {
PATH: process.env.PATH,
NODE_ENV: source ? 'development' : 'production',
TZ: 'UTC',
HOST: '127.0.0.1',
PORT: String(port),
SHUTDOWN_TIMEOUT: '5'
},
execArgv,
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
});
child.stdout.on('data', (chunk) => {
output = (output + chunk).slice(-50_000);
});
child.stderr.on('data', (chunk) => {
output = (output + chunk).slice(-50_000);
});
child.once('exit', () => {
exited = true;
});
closed = new Promise((resolve, reject) => {
child.once('close', (code, signal) => resolve({ code, signal }));
child.once('error', reject);
});
void closed.catch(() => {});
child.on('message', (value) => {
message = value;
});
const timedOut = new Promise((_, reject) => {
deadline = setTimeout(() => {
killed = true;
child.kill('SIGKILL');
reject(new Error('Worker smoke timed out'));
}, 15_000);
});
const finished = Promise.race([closed, timedOut]);
void finished.catch(() => {});
if (mode === 'occupied-port') {
assert.deepEqual(await finished, { code: 1, signal: null });
assert.equal(message, undefined, 'Failed startup announced readiness');
assert(output.includes('Unable to start worker service'));
} else {
const readiness = new Promise((resolve, reject) => {
child.once('message', resolve);
child.once('error', reject);
child.once('exit', () => reject(new Error('Worker exited before readiness')));
});
const ready = await Promise.race([
readiness,
finished.then(() => {
throw new Error('Worker closed before readiness');
})
]);
assert.equal(ready.type, 'ready');
const response = await fetch(`http://127.0.0.1:${ready.port}/healthz`, {
signal: AbortSignal.timeout(2000)
});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { status: 'ok' });
assert(!existsSync(join(directory, 'data/goto.sqlite')), 'Visit database was opened');
const database = new DatabaseSync(join(directory, 'data/worker.sqlite'), {
readOnly: true
});
try {
assert.equal(
database.prepare('SELECT count(*) AS count FROM workflow_runs').get().count,
0
);
} finally {
database.close();
}
if (mode === 'storage-change') {
const path = join(directory, 'config/goto.yaml');
const configuration = JSON.parse(await readFile(path, 'utf8'));
configuration.database = { worker_path: './data/restarted-worker.sqlite' };
await writeFile(`${path}.next`, JSON.stringify(configuration));
await rename(`${path}.next`, path);
} else {
child.kill('SIGTERM');
}
assert.deepEqual(await finished, { code: 0, signal: null });
if (mode === 'storage-change') {
assert(output.includes('Worker configuration change requires restart'));
assert(!existsSync(join(directory, 'data/restarted-worker.sqlite')));
}
}
assert.equal(killed, false, 'Worker required forced termination');
assert(!output.includes('private-worker-smoke-password'));
} finally {
clearTimeout(deadline);
if (child) {
if (!exited) child.kill('SIGKILL');
child.stdout?.destroy();
child.stderr?.destroy();
await closed?.catch(() => undefined);
}
await rm(directory, { recursive: true, force: true });
}
}
await checkStartup('compiled');
await checkStartup('source');
await checkStartup('storage-change');
const occupied = createServer();
await new Promise((resolve, reject) => {
occupied.once('error', reject);
occupied.listen(0, '127.0.0.1', resolve);
});
try {
const address = occupied.address();
assert(address && typeof address !== 'string');
await checkStartup('occupied-port', address.port);
} finally {
await new Promise((resolve) => occupied.close(resolve));
}
console.log(
'Compiled/source startup, config restart, empty queue, health, partial-startup cleanup, and shutdown passed'
);
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@goto/worker",
"version": "0.1.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"scripts": {
"dev": "node --conditions=development --watch --import ../../tools/register.mjs src/index.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"check": "tsc --noEmit -p tsconfig.json",
"lint": "prettier --ignore-path ../../.prettierignore --check . && eslint .",
"format": "prettier --ignore-path ../../.prettierignore --write .",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:smoke": "node e2e/startup.mjs"
},
"dependencies": {
"@goto/config": "workspace:*",
"openworkflow": "catalog:",
"tslog": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
+55
View File
@@ -0,0 +1,55 @@
import process from 'node:process';
import { startWorkerService, type WorkerService } from './services/startup.js';
import { logger } from './services/logger.js';
let starting: Promise<WorkerService> | undefined;
let stopping: Promise<void> | undefined;
let shutdownTimeout = 30_000;
function shutdown(): Promise<void> {
if (stopping) return stopping;
const deadline = setTimeout(() => process.exit(1), shutdownTimeout);
deadline.unref();
stopping = (async () => {
const service = await starting?.catch(() => undefined);
await service?.stop();
})()
.catch(() => {
process.exitCode = 1;
logger.error('Unable to shut down worker service');
})
.finally(() => {
clearTimeout(deadline);
if (process.connected) process.disconnect();
});
return stopping;
}
process.on('SIGTERM', () => void shutdown());
process.on('SIGINT', () => void shutdown());
process.on('disconnect', () => void shutdown());
try {
const value = process.env.SHUTDOWN_TIMEOUT ?? '30';
const seconds = Number(value);
if (!/^\d+(?:\.\d+)?$/.test(value) || !Number.isFinite(seconds) || seconds > 2_147_483.647) {
throw new Error('Invalid worker shutdown timeout');
}
shutdownTimeout = seconds * 1000;
starting = startWorkerService({}, shutdown);
const service = await starting;
if (stopping) {
await stopping;
} else {
logger.info('Worker service started');
if (process.send && process.connected) {
process.send({ type: 'ready', port: service.port }, (error) => {
if (error) void shutdown();
});
}
}
} catch {
process.exitCode = 1;
logger.error('Unable to start worker service');
await shutdown();
}
@@ -0,0 +1,58 @@
import { startConfigReloading, validateConfig } from '@goto/config';
import { afterEach, expect, test, vi } from 'vitest';
import { applyConfig, watchConfiguration } from './config-reload.js';
import type { ShutdownResources } from './lifecycle.js';
vi.mock('./logger.js', () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
vi.mock('@goto/config', async (importOriginal) => ({
...(await importOriginal<typeof import('@goto/config')>()),
startConfigReloading: vi.fn()
}));
function configuration(worker_path = './data/worker.sqlite') {
return validateConfig(
{
auth: { credentials: { username: 'test', password: 'config-test-password' } },
database: { worker_path },
domains: {}
},
{ env: {} }
);
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
test('accepts unchanged worker storage and requests restart for a new storage path', () => {
const initial = configuration();
const restart = vi.fn();
expect(applyConfig(configuration(), initial, restart)).toBe(true);
expect(restart).not.toHaveBeenCalled();
expect(applyConfig(configuration('./data/other.sqlite'), initial, restart)).toBe(false);
expect(restart).toHaveBeenCalledOnce();
});
test('initial reread restart waits until the monitor stop handle is owned', async () => {
vi.useFakeTimers();
const initial = configuration();
const stopWatch = vi.fn();
const resources: ShutdownResources = {};
const shutdown = vi.fn(async () => {
resources.stopWatch?.();
});
const monitor = Promise.withResolvers<() => void>();
vi.mocked(startConfigReloading).mockImplementationOnce(async (options) => {
options.onChange(configuration('./data/new.sqlite'), initial);
return monitor.promise;
});
const source = { path: '/config/goto.yaml', initial, load: async () => initial };
const watching = watchConfiguration(source, resources, shutdown);
await vi.runAllTimersAsync();
expect(shutdown).not.toHaveBeenCalled();
monitor.resolve(stopWatch);
await watching;
expect(shutdown).toHaveBeenCalledOnce();
expect(stopWatch).toHaveBeenCalledOnce();
});
+51
View File
@@ -0,0 +1,51 @@
import { startConfigReloading, type PreparedConfiguration } from '@goto/config';
import { logger } from './logger.js';
import type { ShutdownResources } from './lifecycle.js';
interface ConfigurationSource {
path: string;
initial: PreparedConfiguration;
load(path: string): Promise<PreparedConfiguration>;
}
export function applyConfig(
next: PreparedConfiguration,
previous: PreparedConfiguration,
onRestart: () => void
): boolean {
if (next.configuration.database.worker_path !== previous.configuration.database.worker_path) {
logger.warn('Worker configuration change requires restart');
onRestart();
return false;
}
logger.info('Worker configuration reloaded');
return true;
}
export async function watchConfiguration(
source: ConfigurationSource,
resources: ShutdownResources,
shutdown: () => Promise<void>
): Promise<void> {
let watchOwned = false;
let restartRequested = false;
function shutdownForRestart(): void {
void shutdown().catch(() => {
process.exitCode = 1;
logger.error('Unable to shut down worker after configuration change');
});
}
function requestRestart(): void {
if (restartRequested) return;
restartRequested = true;
if (watchOwned) shutdownForRestart();
}
resources.stopWatch = await startConfigReloading({
...source,
onChange: (next, previous) => applyConfig(next, previous, requestRestart),
onError: () =>
logger.error('Invalid worker configuration update; keeping current configuration')
});
watchOwned = true;
if (restartRequested) shutdownForRestart();
}
@@ -0,0 +1,32 @@
import { expect, test, vi } from 'vitest';
import { createEngineLogger } from './engine-logger.js';
test('keeps structured engine categories without raw messages', () => {
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const logger = createEngineLogger(log);
const privateError = new Error('private error details');
const privateInput = { password: 'private workflow input' };
logger.debug('private debug details', privateInput);
logger.info('private workflow inputs', privateInput);
logger.warn('Worker schedule tick failed: private schedule', privateError);
logger.error('private database path', privateError, privateInput);
expect(log.debug).toHaveBeenCalledExactlyOnceWith('Workflow engine debug');
expect(log.info).toHaveBeenCalledExactlyOnceWith('Workflow engine info');
expect(log.warn).toHaveBeenCalledExactlyOnceWith('Workflow engine schedule poll failed');
expect(log.error).toHaveBeenCalledExactlyOnceWith('Workflow engine error');
});
test('only includes a validated execution ID and rejects a trailing newline', () => {
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const logger = createEngineLogger(log);
const id = '00000000-0000-4000-8000-000000000001';
const message = `Critical error during workflow execution for run ${id}:`;
logger.error(message);
logger.error(`${message}\n`);
expect(log.error).toHaveBeenNthCalledWith(
1,
{ workflowRunId: id },
'Workflow engine execution failed'
);
expect(log.error).toHaveBeenNthCalledWith(2, 'Workflow engine execution failed');
});
+38
View File
@@ -0,0 +1,38 @@
import type { Logger as EngineLogger } from 'openworkflow';
import type { Logger } 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;
const executionError =
/^Critical error during workflow execution for run ([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}):$/;
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}`;
}
export function createEngineLogger(
log: Pick<Logger, 'debug' | 'info' | 'warn' | 'error'>
): 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) => {
const text = safeMessage(message, 'error');
const match = typeof message === 'string' ? executionError.exec(message) : null;
// Reject a trailing newline accepted by the regex's end anchor.
if (match && match[0] === message) log.error({ workflowRunId: match[1] }, text);
else log.error(text);
}
};
}
+13
View File
@@ -0,0 +1,13 @@
import { createServer, type Server } from 'node:http';
export function createHealthServer(): Server {
return createServer((request, response) => {
response.setHeader('cache-control', 'no-store');
if (request.method === 'GET' && request.url === '/healthz') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ status: 'ok' }));
return;
}
response.writeHead(404).end();
});
}
@@ -0,0 +1,49 @@
import { expect, test, vi } from 'vitest';
import { createShutdown } from './lifecycle.js';
test('shutdown is idempotent and waits for the worker before closing its backend', async () => {
const workerStopped = Promise.withResolvers<void>();
const workflowWorker = { stop: vi.fn(() => workerStopped.promise) };
const backend = { stop: vi.fn(async () => {}) };
const stopWatch = vi.fn();
const stop = createShutdown({ workflowWorker, backend, stopWatch });
const stopping = stop();
expect(stop()).toBe(stopping);
expect(workflowWorker.stop).toHaveBeenCalledOnce();
expect(stopWatch).toHaveBeenCalledOnce();
expect(stopWatch.mock.invocationCallOrder[0]).toBeLessThan(
workflowWorker.stop.mock.invocationCallOrder[0]!
);
expect(backend.stop).not.toHaveBeenCalled();
workerStopped.resolve();
await stopping;
expect(backend.stop).toHaveBeenCalledOnce();
});
test('a failed monitor stop still stops the worker and closes the backend', async () => {
const failure = new Error('Monitor stop failed');
const stopWatch = vi.fn(() => {
throw failure;
});
const workflowWorker = { stop: vi.fn(async () => {}) };
const backend = { stop: vi.fn(async () => {}) };
const stop = createShutdown({ stopWatch, workflowWorker, backend });
await expect(stop()).rejects.toBe(failure);
expect(workflowWorker.stop).toHaveBeenCalledOnce();
expect(backend.stop).toHaveBeenCalledOnce();
});
test('a failed worker stop still closes the workflow backend', async () => {
const failure = new Error('Worker stop failed');
const workflowWorker = {
stop: vi.fn(async () => {
throw failure;
})
};
const backend = { stop: vi.fn(async () => {}) };
const stop = createShutdown({ workflowWorker, backend });
await expect(stop()).rejects.toBe(failure);
expect(backend.stop).toHaveBeenCalledOnce();
});
+45
View File
@@ -0,0 +1,45 @@
import type { Server } from 'node:http';
import type { Worker as WorkflowWorker } from 'openworkflow';
import type { BackendSqlite } from 'openworkflow/sqlite';
export interface ShutdownResources {
workflowWorker?: Pick<WorkflowWorker, 'stop'>;
server?: Server;
backend?: Pick<BackendSqlite, 'stop'>;
stopWatch?: () => void;
}
export function createShutdown(resources: ShutdownResources): () => Promise<void> {
let stopping: Promise<void> | undefined;
return () => (stopping ??= stopResources(resources));
}
async function stopResources(resources: ShutdownResources): Promise<void> {
const failures: unknown[] = [];
try {
resources.stopWatch?.();
} catch (error) {
failures.push(error);
}
const serverClosed = new Promise<Error | undefined>((resolve) => {
try {
if (resources.server?.listening) resources.server.close(resolve);
else resolve(undefined);
} catch (error) {
resolve(error instanceof Error ? error : new Error('Health server shutdown failed'));
}
});
try {
await resources.workflowWorker?.stop();
} catch (error) {
failures.push(error);
}
const serverError = await serverClosed;
if (serverError) failures.push(serverError);
try {
await resources.backend?.stop();
} catch (error) {
failures.push(error);
}
if (failures.length > 0) throw failures[0];
}
+12
View File
@@ -0,0 +1,12 @@
import { Logger as TsLogger } from 'tslog';
let logType: 'pretty' | 'json' = 'pretty';
if (process.env.NODE_ENV === 'production') logType = 'json';
export const logger = new TsLogger({
name: 'goto-worker',
type: logType,
stack: { capture: 'off' }
});
export type Logger = typeof logger;
+56
View File
@@ -0,0 +1,56 @@
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Worker } from 'openworkflow';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { startWorkerService } from './startup.js';
let directory: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'goto-worker-'));
await mkdir(join(directory, 'config'));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(directory, { recursive: true, force: true });
});
test('starts an empty workflow service and health server without opening visit storage', async () => {
await writeFile(
join(directory, 'config/goto.yaml'),
JSON.stringify({
site: { url: 'http://admin.example.test' },
auth: { credentials: { username: 'test', password: 'worker-test-only-password' } },
database: { path: './data/visits.sqlite', worker_path: './state/worker.sqlite' },
domains: {}
})
);
const start = vi.spyOn(Worker.prototype, 'start');
const service = await startWorkerService({
cwd: directory,
env: { HOST: '127.0.0.1', PORT: '0' }
});
try {
expect(start).toHaveBeenCalledOnce();
const response = await fetch(`http://127.0.0.1:${service.port}/healthz`, {
signal: AbortSignal.timeout(2000)
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ status: 'ok' });
expect(existsSync(join(directory, 'state/worker.sqlite'))).toBe(true);
expect(existsSync(join(directory, 'data/visits.sqlite'))).toBe(false);
} finally {
await service.stop();
}
});
test('invalid configuration fails before creating workflow storage', async () => {
await writeFile(join(directory, 'config/goto.yaml'), 'auth: {}\ndomains: {}\n');
await expect(startWorkerService({ cwd: directory, env: {} })).rejects.toThrow(
'Invalid configuration'
);
expect(existsSync(join(directory, 'data'))).toBe(false);
});
+81
View File
@@ -0,0 +1,81 @@
import { mkdir } from 'node:fs/promises';
import { once } from 'node:events';
import { dirname } from 'node:path';
import { loadConfigFile, resolveConfigPath, type ConfigOptions } from '@goto/config';
import { OpenWorkflow } from 'openworkflow';
import { BackendSqlite } from 'openworkflow/sqlite';
import { createEngineLogger } from './engine-logger.js';
import { createHealthServer } from './health.js';
import { createShutdown, type ShutdownResources } from './lifecycle.js';
import { watchConfiguration } from './config-reload.js';
import { logger } from './logger.js';
export interface WorkerService {
port: number;
stop(): Promise<void>;
}
export async function startWorkerService(
options: ConfigOptions = {},
onRestartRequired?: () => Promise<void>
): Promise<WorkerService> {
const captured = {
env: { ...(options.env ?? process.env) },
cwd: options.cwd ?? process.cwd()
};
const value = captured.env.PORT ?? '9000';
const port = Number(value);
if (!/^\d+$/.test(value) || !Number.isInteger(port) || port > 65535) {
throw new Error('Invalid worker health port');
}
const path = resolveConfigPath(captured);
const loadConfiguration = () => loadConfigFile(path, captured);
const prepared = await loadConfiguration();
const databasePath = prepared.configuration.database.worker_path;
const resources: ShutdownResources = {};
const stop = createShutdown(resources);
try {
await startWorkflowWorker(databasePath, resources);
const host = captured.env.HOST ?? '0.0.0.0';
const healthPort = await startHealthServer(host, port, resources);
const source = { path, initial: prepared, load: loadConfiguration };
const requestRestart = onRestartRequired ?? stop;
await watchConfiguration(source, resources, requestRestart);
return { port: healthPort, stop };
} catch (error) {
await stop();
throw error;
}
}
async function startWorkflowWorker(
databasePath: string,
resources: ShutdownResources
): Promise<void> {
const directory = dirname(databasePath);
await mkdir(directory, { recursive: true });
const backend = BackendSqlite.connect(databasePath);
resources.backend = backend;
const engineLogger = createEngineLogger(logger);
const engine = new OpenWorkflow({ backend, logger: engineLogger });
const workflowWorker = engine.newWorker();
resources.workflowWorker = workflowWorker;
await workflowWorker.start();
logger.info('Workflow worker started');
}
async function startHealthServer(
host: string,
port: number,
resources: ShutdownResources
): Promise<number> {
const server = createHealthServer();
resources.server = server;
const listening = once(server, 'listening');
server.listen({ host, port });
await listening;
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected worker health address');
logger.info({ port: address.port }, 'Worker health server listening');
return address.port;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "src/**/*.test.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "vitest.config.ts"],
"exclude": ["dist"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts']
}
}
});
+1
View File
@@ -18,6 +18,7 @@ auth:
database:
path: ./data/goto.sqlite
worker_path: ./data/worker.sqlite
analytics:
enabled: true
+2 -1
View File
@@ -9,6 +9,7 @@
},
"scripts": {
"dev": "pnpm build:packages && pnpm --filter @goto/web dev",
"dev:worker": "pnpm --filter @goto/worker dev",
"cli": "pnpm build:packages && pnpm --filter @goto/cli build && node apps/cli/dist/index.js",
"schema:generate": "pnpm --filter @goto/config schema:generate",
"schema:check": "pnpm --filter @goto/config schema:check",
@@ -20,7 +21,7 @@
"test": "pnpm -r test",
"test:coverage": "pnpm -r test:coverage",
"test:e2e": "pnpm --filter @goto/web test:e2e",
"verify": "pnpm schema:check && pnpm build && pnpm check && pnpm lint && pnpm test:coverage && pnpm test:e2e",
"verify": "pnpm schema:check && pnpm build && pnpm check && pnpm lint && pnpm test:coverage && pnpm test:e2e && pnpm --filter @goto/worker test:smoke",
"changeset": "changeset",
"version:apps": "changeset version",
"tag:apps": "changeset git-tag"
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
+11
View File
@@ -270,6 +270,17 @@
"pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}"
}
]
},
"worker_path": {
"default": "./data/worker.sqlite",
"type": "string",
"anyOf": [
{ "type": "string", "pattern": "\\S", "minLength": 1 },
{
"type": "string",
"pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}"
}
]
}
}
},
+8 -2
View File
@@ -22,7 +22,7 @@ describe('configuration loading', () => {
expect(configuration).toMatchObject({
version: 1,
site: { url: 'http://localhost:3000', title: 'Goto' },
database: { path: './data/goto.sqlite' },
database: { path: './data/goto.sqlite', worker_path: './data/worker.sqlite' },
analytics: { enabled: true, retention_days: 90 },
defaults: { status_code: 302, forward_query: false, fallback: 404 }
});
@@ -246,7 +246,10 @@ describe('configuration loading', () => {
join(configDirectory, 'custom.yaml'),
JSON.stringify({
...configurationInput(),
database: { path: './data/custom.sqlite' }
database: {
path: './data/custom.sqlite',
worker_path: './data/custom-worker.sqlite'
}
})
);
const loaded = await loadConfigFile(undefined, {
@@ -254,6 +257,9 @@ describe('configuration loading', () => {
env: { CONFIG_PATH: 'deployment/config/custom.yaml' }
});
expect(loaded.configuration.database.path).toBe(join(root, 'deployment/data/custom.sqlite'));
expect(loaded.configuration.database.worker_path).toBe(
join(root, 'deployment/data/custom-worker.sqlite')
);
const explicit = await loadConfigFile('deployment/config/custom.yaml', {
cwd: root,
env: { CONFIG_PATH: 'missing.yaml' }
+4 -4
View File
@@ -23,10 +23,10 @@ export async function loadConfigFile(
throw new ConfigError();
}
const prepared = parseConfig(source, options);
prepared.configuration.database.path = resolve(
resolveDeploymentRoot(configPath),
prepared.configuration.database.path
);
const deploymentRoot = resolveDeploymentRoot(configPath);
const database = prepared.configuration.database;
database.path = resolve(deploymentRoot, database.path);
database.worker_path = resolve(deploymentRoot, database.worker_path);
return prepared;
}
+2 -1
View File
@@ -178,7 +178,8 @@ const AuthSchema = Type.Union(
const DatabaseSchema = Type.Object(
{
path: Type.String({ ...nonBlankText, default: './data/goto.sqlite' })
path: Type.String({ ...nonBlankText, default: './data/goto.sqlite' }),
worker_path: Type.String({ ...nonBlankText, default: './data/worker.sqlite' })
},
{ $id: 'Database', additionalProperties: false }
);
+71
View File
@@ -60,6 +60,9 @@ catalogs:
kysely:
specifier: 0.28.17
version: 0.28.17
openworkflow:
specifier: 0.11.0
version: 0.11.0
piscina:
specifier: 5.3.2
version: 5.3.2
@@ -75,6 +78,9 @@ catalogs:
svelte-check:
specifier: 4.7.7
version: 4.7.7
tslog:
specifier: 5.2.0
version: 5.2.0
typescript:
specifier: 6.0.3
version: 6.0.3
@@ -207,6 +213,34 @@ importers:
specifier: 'catalog:'
version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1))
apps/worker:
dependencies:
'@goto/config':
specifier: workspace:*
version: link:../../packages/config
openworkflow:
specifier: 'catalog:'
version: 0.11.0
tslog:
specifier: 'catalog:'
version: 5.2.0
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.12.4
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.1.7(vitest@4.1.7)
prettier:
specifier: 'catalog:'
version: 3.8.3
typescript:
specifier: 'catalog:'
version: 6.0.3
vitest:
specifier: 'catalog:'
version: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1))
packages/config:
dependencies:
'@sinclair/typebox':
@@ -1206,6 +1240,10 @@ packages:
resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==}
engines: {node: '>=22'}
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'}
@@ -1588,6 +1626,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'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1632,6 +1674,18 @@ packages:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
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
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1839,6 +1893,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tslog@5.2.0:
resolution: {integrity: sha512-bD0FpBIFZ1OInp7uzQmEn1SJe6feTkVTCudw/27ILuISbVZtTryXqauxyCWTa1I8ZgTm9OGQ6YrJPKM+6QTeJA==}
engines: {node: '>=22'}
hasBin: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -2783,6 +2842,10 @@ snapshots:
cookie@2.0.1: {}
cron-parser@5.10.1:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -3133,6 +3196,8 @@ snapshots:
dependencies:
p-locate: 5.0.0
luxon@3.7.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -3169,6 +3234,10 @@ snapshots:
obug@2.2.1: {}
openworkflow@0.11.0:
dependencies:
cron-parser: 5.10.1
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -3373,6 +3442,8 @@ snapshots:
tslib@2.8.1:
optional: true
tslog@5.2.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
+2
View File
@@ -21,11 +21,13 @@ catalog:
globals: 17.6.0
gunshi: 0.35.1
kysely: 0.28.17
openworkflow: 0.11.0
piscina: 5.3.2
prettier: 3.8.3
prettier-plugin-svelte: 3.5.2
svelte: 5.57.3
svelte-check: 4.7.7
tslog: 5.2.0
typescript: 6.0.3
typescript-eslint: 8.60.0
vite: 8.0.16
+14
View File
@@ -0,0 +1,14 @@
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
export function resolve(specifier, context, nextResolve) {
const relative = specifier.startsWith('./') || specifier.startsWith('../');
if (relative && specifier.endsWith('.js') && context.parentURL) {
const tsSpecifier = specifier.slice(0, -3) + '.ts';
const tsUrl = new URL(tsSpecifier, context.parentURL);
if (existsSync(fileURLToPath(tsUrl))) {
return nextResolve(tsSpecifier, context);
}
}
return nextResolve(specifier, context);
}
+5
View File
@@ -0,0 +1,5 @@
// Development only: Node strips types; this hook resolves .js source imports to .ts.
import { registerHooks } from 'node:module';
import { resolve } from './hooks.mjs';
registerHooks({ resolve });