feat: add pluggable workflow orchestrator controls

This commit is contained in:
john
2026-07-24 20:59:01 +08:00
parent 3fd1db8c2f
commit 46ea22b342
16 changed files with 1327 additions and 2 deletions
+66
View File
@@ -0,0 +1,66 @@
# Memind Workflow Orchestrator
This directory is the extraction boundary for Memind's durable workflow runtime.
It is intentionally source-colocated with Memind while remaining framework-neutral
at its public edge.
## Dependency rule
Allowed:
```text
Memind -> orchestrator contracts / client
Orchestrator -> contracts / executor adapters
Executor adapters -> versioned internal APIs
```
Forbidden:
```text
Orchestrator -> server.mjs
Orchestrator -> user-auth.mjs
Orchestrator -> Memind business tables
Orchestrator -> production filesystem paths
Memind -> LangGraph StateGraph / Command / checkpoint types
```
LangGraph is an implementation of the generic workflow engine contract. The
Memind-facing protocol is `orchestrator-run-v1` plus `orchestrator-event-v1`.
## Current stage
Phase 1 establishes:
- framework-neutral run and event contracts;
- a plug-in workflow engine registry;
- a remote workflow engine client;
- versioned admin configuration;
- deterministic Off / Shadow / Canary / Active routing decisions;
- an environment-level emergency kill switch.
The default mode is `off`. No production task is routed to LangGraph in this
phase. Runtime dispatch wiring and a separately deployed Orchestrator worker are
subsequent phases.
## Admin configuration
The memindadm page is `/ops/admin/orchestrator`.
Supported modes:
- `off`: Native Agent Run only.
- `shadow`: Native executes; the configured engine may observe.
- `canary`: user allowlist and deterministic rollout percentage.
- `active`: the primary engine handles workflow-allowlisted tasks.
`MEMIND_ORCHESTRATOR_KILL_SWITCH=1` always forces Native selection.
## Extraction test
The service is ready to move into a separate repository only when:
1. it can run against a mock Memind control plane;
2. it owns its checkpoint database and credentials;
3. task inputs use resource references rather than host paths;
4. executor calls use versioned adapters;
5. moving the service requires only changing `ORCHESTRATOR_URL`.
+296
View File
@@ -0,0 +1,296 @@
import {
DEFAULT_ORCHESTRATED_WORKFLOWS,
ORCHESTRATOR_MODE,
WORKFLOW_ENGINE,
normalizeStringList,
normalizeWorkflowEngineId,
normalizeWorkflowName,
stableRolloutBucket,
} from './contracts.mjs';
const CONFIG_TABLE = 'h5_orchestrator_admin_config';
const CONFIG_SCOPE = 'global';
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function clampInteger(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, Math.floor(number)));
}
function normalizeMode(value, fallback = ORCHESTRATOR_MODE.OFF) {
const normalized = String(value ?? '').trim().toLowerCase();
return Object.values(ORCHESTRATOR_MODE).includes(normalized) ? normalized : fallback;
}
function normalizeServiceUrl(value) {
const raw = String(value ?? '').trim().replace(/\/$/, '');
if (!raw) return '';
try {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) return '';
url.username = '';
url.password = '';
url.hash = '';
return url.toString().replace(/\/$/, '').slice(0, 2048);
} catch {
return '';
}
}
function parseJsonLike(value, fallback) {
if (value == null || value === '') return fallback;
if (typeof value === 'object') return value;
try {
return JSON.parse(String(value));
} catch {
return fallback;
}
}
function clone(value) {
if (typeof structuredClone === 'function') return structuredClone(value);
return JSON.parse(JSON.stringify(value));
}
export function defaultOrchestratorConfig() {
return {
mode: ORCHESTRATOR_MODE.OFF,
primaryEngine: WORKFLOW_ENGINE.LANGGRAPH,
fallbackEngine: WORKFLOW_ENGINE.NATIVE,
serviceUrl: '',
requestTimeoutMs: 5000,
rolloutPercent: 0,
userAllowlist: [],
workflowAllowlist: [...DEFAULT_ORCHESTRATED_WORKFLOWS],
fallbackToNative: true,
requireHealthy: true,
};
}
export function normalizeOrchestratorConfig(input = {}, fallback = defaultOrchestratorConfig()) {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const base = fallback && typeof fallback === 'object' ? fallback : defaultOrchestratorConfig();
const workflowAllowlist = normalizeStringList(
source.workflowAllowlist ?? base.workflowAllowlist,
{ normalize: (item) => normalizeWorkflowName(item, ''), itemLimit: 128 },
);
return {
mode: normalizeMode(source.mode, base.mode),
primaryEngine: normalizeWorkflowEngineId(source.primaryEngine, base.primaryEngine),
fallbackEngine: normalizeWorkflowEngineId(source.fallbackEngine, base.fallbackEngine),
serviceUrl: normalizeServiceUrl(source.serviceUrl ?? base.serviceUrl),
requestTimeoutMs: clampInteger(
source.requestTimeoutMs,
base.requestTimeoutMs,
500,
60_000,
),
rolloutPercent: clampInteger(source.rolloutPercent, base.rolloutPercent, 0, 100),
userAllowlist: normalizeStringList(source.userAllowlist ?? base.userAllowlist, {
itemLimit: 128,
}),
workflowAllowlist: workflowAllowlist.length
? workflowAllowlist
: [...DEFAULT_ORCHESTRATED_WORKFLOWS],
fallbackToNative: normalizeBoolean(source.fallbackToNative, base.fallbackToNative),
requireHealthy: normalizeBoolean(source.requireHealthy, base.requireHealthy),
};
}
function runtimeState(config, env = process.env) {
const killSwitch = normalizeBoolean(env.MEMIND_ORCHESTRATOR_KILL_SWITCH, false);
const configured = config.primaryEngine !== WORKFLOW_ENGINE.LANGGRAPH || Boolean(config.serviceUrl);
let reason = null;
if (killSwitch) reason = 'kill_switch';
else if (config.mode === ORCHESTRATOR_MODE.OFF) reason = 'mode_off';
else if (!configured) reason = 'service_url_missing';
return {
killSwitch,
configured,
effective: !reason,
reason,
executesLangGraph: !reason
&& [ORCHESTRATOR_MODE.CANARY, ORCHESTRATOR_MODE.ACTIVE].includes(config.mode)
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH,
shadowsLangGraph: !reason
&& config.mode === ORCHESTRATOR_MODE.SHADOW
&& config.primaryEngine === WORKFLOW_ENGINE.LANGGRAPH,
};
}
function engineCatalog(config) {
return [
{
id: WORKFLOW_ENGINE.NATIVE,
label: 'Native Agent Run',
kind: 'built-in',
configured: true,
capabilities: ['existing-runtime', 'tool-gateway'],
},
{
id: WORKFLOW_ENGINE.LANGGRAPH,
label: 'LangGraph Orchestrator',
kind: 'remote',
configured: Boolean(config.serviceUrl),
capabilities: ['durable-execution', 'interrupt', 'streaming'],
},
];
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_scope VARCHAR(32) PRIMARY KEY,
config_json JSON NOT NULL,
config_version INT NOT NULL DEFAULT 1,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function loadStoredState(pool) {
await ensureConfigTable(pool);
const [rows] = await pool.query(
`SELECT config_json, config_version, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_scope = ?
LIMIT 1`,
[CONFIG_SCOPE],
);
const row = rows[0];
if (!row) return null;
return {
config: normalizeOrchestratorConfig(parseJsonLike(row.config_json, {})),
configVersion: Math.max(1, Number(row.config_version ?? 1) || 1),
updatedBy: row.updated_by ?? null,
updatedAt: Number(row.updated_at ?? 0) || null,
};
}
export function createOrchestratorAdminConfigService(pool, { env = process.env } = {}) {
async function loadEffectiveState() {
const stored = await loadStoredState(pool);
if (stored) return { ...stored, source: 'admin-db' };
const config = normalizeOrchestratorConfig({
mode: env.MEMIND_ORCHESTRATOR_MODE,
serviceUrl: env.MEMIND_ORCHESTRATOR_URL,
});
const fromEnv = Boolean(env.MEMIND_ORCHESTRATOR_MODE || env.MEMIND_ORCHESTRATOR_URL);
return {
config,
configVersion: 1,
updatedBy: null,
updatedAt: null,
source: fromEnv ? 'env' : 'default',
};
}
return {
ensureSchema() {
return ensureConfigTable(pool);
},
async getAdminConfig() {
const state = await loadEffectiveState();
return {
...state,
runtime: runtimeState(state.config, env),
engines: engineCatalog(state.config),
};
},
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
const stored = await loadStoredState(pool);
const current = stored?.config ?? defaultOrchestratorConfig();
const nextConfig = normalizeOrchestratorConfig(
{ ...current, ...(patch?.config ?? patch) },
current,
);
const configVersion = (stored?.configVersion ?? 0) + 1;
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE}
(config_scope, config_json, config_version, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
config_version = VALUES(config_version),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[CONFIG_SCOPE, JSON.stringify(nextConfig), configVersion, updatedBy, now],
);
return this.getAdminConfig();
},
async getRuntimeState() {
const state = await loadEffectiveState();
return {
...state,
runtime: runtimeState(state.config, env),
engines: engineCatalog(state.config),
};
},
async selectEngine({
runId,
requestId,
userId = null,
workflowName,
} = {}) {
const state = await loadEffectiveState();
const config = state.config;
const runtime = runtimeState(config, env);
const normalizedWorkflow = normalizeWorkflowName(workflowName, '');
const workflowMatched = config.workflowAllowlist.includes(normalizedWorkflow);
const base = {
engine: WORKFLOW_ENGINE.NATIVE,
shadowEngine: null,
fallbackEngine: config.fallbackToNative ? config.fallbackEngine : null,
mode: config.mode,
workflowMatched,
configVersion: state.configVersion,
reason: runtime.reason,
};
if (!runtime.effective || !workflowMatched) {
return { ...base, reason: runtime.reason ?? 'workflow_not_enabled' };
}
if (config.mode === ORCHESTRATOR_MODE.SHADOW) {
return {
...base,
shadowEngine: config.primaryEngine,
reason: 'shadow',
};
}
if (config.mode === ORCHESTRATOR_MODE.ACTIVE) {
return { ...base, engine: config.primaryEngine, reason: 'active' };
}
if (config.mode === ORCHESTRATOR_MODE.CANARY) {
const explicitlyAllowed = Boolean(userId && config.userAllowlist.includes(String(userId)));
const rolloutKey = runId || requestId || `${userId ?? ''}:${normalizedWorkflow}`;
const bucket = stableRolloutBucket(rolloutKey);
const percentAllowed = bucket < config.rolloutPercent;
return explicitlyAllowed || percentAllowed
? { ...base, engine: config.primaryEngine, reason: explicitlyAllowed ? 'user_allowlist' : 'percentage', bucket }
: { ...base, reason: 'canary_not_selected', bucket };
}
return base;
},
};
}
export const orchestratorAdminConfigInternals = {
CONFIG_SCOPE,
CONFIG_TABLE,
engineCatalog,
runtimeState,
};
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createOrchestratorAdminConfigService,
defaultOrchestratorConfig,
normalizeOrchestratorConfig,
} from './admin-config.mjs';
function createPool() {
let row = null;
return {
async query(sql, params = []) {
if (sql.includes('CREATE TABLE')) return [[], []];
if (sql.includes('SELECT config_json')) return [[...(row ? [row] : [])], []];
if (sql.includes('INSERT INTO')) {
row = {
config_json: params[1],
config_version: params[2],
updated_by: params[3],
updated_at: params[4],
};
return [{ affectedRows: 1 }, []];
}
throw new Error(`Unexpected SQL: ${sql}`);
},
};
}
test('orchestrator config defaults to a disabled native-safe runtime', async () => {
const service = createOrchestratorAdminConfigService(createPool(), { env: {} });
const state = await service.getAdminConfig();
assert.equal(state.config.mode, 'off');
assert.equal(state.config.primaryEngine, 'langgraph');
assert.equal(state.runtime.effective, false);
assert.equal(state.runtime.reason, 'mode_off');
assert.equal(state.engines.find((engine) => engine.id === 'native').configured, true);
assert.equal(state.engines.find((engine) => engine.id === 'langgraph').configured, false);
});
test('orchestrator config normalizes unsafe values and keeps a workflow allowlist', () => {
const normalized = normalizeOrchestratorConfig({
mode: 'active',
serviceUrl: 'file:///tmp/graph',
requestTimeoutMs: 1,
rolloutPercent: 500,
workflowAllowlist: ['code-run-v1', 'INVALID WORKFLOW', 'code-run-v1'],
});
assert.equal(normalized.serviceUrl, '');
assert.equal(normalized.requestTimeoutMs, 500);
assert.equal(normalized.rolloutPercent, 100);
assert.deepEqual(normalized.workflowAllowlist, ['code-run-v1']);
});
test('orchestrator config persists versioned admin updates and selects canary users', async () => {
const service = createOrchestratorAdminConfigService(createPool(), { env: {} });
const updated = await service.updateAdminConfig({
...defaultOrchestratorConfig(),
mode: 'canary',
serviceUrl: 'http://127.0.0.1:8093',
rolloutPercent: 0,
userAllowlist: ['user-1'],
}, { updatedBy: 'admin-1' });
assert.equal(updated.configVersion, 1);
assert.equal(updated.updatedBy, 'admin-1');
assert.equal(updated.runtime.effective, true);
const selected = await service.selectEngine({
runId: 'run-1',
userId: 'user-1',
workflowName: 'code-run-v1',
});
assert.equal(selected.engine, 'langgraph');
assert.equal(selected.reason, 'user_allowlist');
const native = await service.selectEngine({
runId: 'run-2',
userId: 'user-2',
workflowName: 'code-run-v1',
});
assert.equal(native.engine, 'native');
assert.equal(native.reason, 'canary_not_selected');
});
test('orchestrator emergency kill switch always forces native selection', async () => {
const service = createOrchestratorAdminConfigService(createPool(), {
env: { MEMIND_ORCHESTRATOR_KILL_SWITCH: '1' },
});
await service.updateAdminConfig({
mode: 'active',
serviceUrl: 'http://127.0.0.1:8093',
});
const selected = await service.selectEngine({
runId: 'run-1',
userId: 'user-1',
workflowName: 'code-run-v1',
});
assert.equal(selected.engine, 'native');
assert.equal(selected.reason, 'kill_switch');
});
+104
View File
@@ -0,0 +1,104 @@
import crypto from 'node:crypto';
export const ORCHESTRATOR_MODE = Object.freeze({
OFF: 'off',
SHADOW: 'shadow',
CANARY: 'canary',
ACTIVE: 'active',
});
export const WORKFLOW_ENGINE = Object.freeze({
NATIVE: 'native',
LANGGRAPH: 'langgraph',
});
export const DEFAULT_ORCHESTRATED_WORKFLOWS = Object.freeze([
'code-run-v1',
]);
const ENGINE_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const WORKFLOW_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,127}$/;
function normalizeIdentifier(value, pattern, fallback) {
const normalized = String(value ?? '').trim().toLowerCase();
return pattern.test(normalized) ? normalized : fallback;
}
export function normalizeWorkflowEngineId(value, fallback = WORKFLOW_ENGINE.NATIVE) {
return normalizeIdentifier(value, ENGINE_ID_PATTERN, fallback);
}
export function normalizeWorkflowName(value, fallback = '') {
return normalizeIdentifier(value, WORKFLOW_NAME_PATTERN, fallback);
}
export function normalizeStringList(value, {
limit = 200,
itemLimit = 128,
normalize = (item) => String(item ?? '').trim(),
} = {}) {
if (!Array.isArray(value)) return [];
return [...new Set(value
.map((item) => normalize(item).slice(0, itemLimit))
.filter(Boolean))]
.slice(0, limit);
}
export function normalizeRunSpec(input = {}) {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const requestId = String(source.requestId ?? '').trim();
const runId = String(source.runId ?? crypto.randomUUID()).trim();
const workflowName = normalizeWorkflowName(
source.workflow?.name ?? source.workflowName,
);
if (!requestId) throw new Error('RunSpec requires requestId');
if (!runId) throw new Error('RunSpec requires runId');
if (!workflowName) throw new Error('RunSpec requires workflow.name');
return {
version: 'orchestrator-run-v1',
runId,
requestId,
workflow: {
name: workflowName,
version: Math.max(1, Number(source.workflow?.version ?? 1) || 1),
},
subject: {
tenantId: String(source.subject?.tenantId ?? '').trim() || null,
userId: String(source.subject?.userId ?? '').trim() || null,
},
input: source.input && typeof source.input === 'object' ? structuredClone(source.input) : {},
policy: source.policy && typeof source.policy === 'object' ? structuredClone(source.policy) : {},
limits: source.limits && typeof source.limits === 'object' ? structuredClone(source.limits) : {},
metadata: source.metadata && typeof source.metadata === 'object'
? structuredClone(source.metadata)
: {},
};
}
export function buildRunEvent({
eventId = crypto.randomUUID(),
runId,
sequence,
type,
timestamp = Date.now(),
data = null,
} = {}) {
const normalizedRunId = String(runId ?? '').trim();
const normalizedType = String(type ?? '').trim();
if (!normalizedRunId) throw new Error('RunEvent requires runId');
if (!normalizedType) throw new Error('RunEvent requires type');
return {
version: 'orchestrator-event-v1',
eventId: String(eventId),
runId: normalizedRunId,
sequence: Math.max(0, Number(sequence ?? 0) || 0),
type: normalizedType,
timestamp: Number(timestamp) || Date.now(),
data: data == null ? null : structuredClone(data),
};
}
export function stableRolloutBucket(value) {
const hash = crypto.createHash('sha256').update(String(value ?? '')).digest();
return hash.readUInt32BE(0) % 100;
}
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildRunEvent,
normalizeRunSpec,
stableRolloutBucket,
} from './contracts.mjs';
import {
createRemoteWorkflowEngine,
createWorkflowEngineRegistry,
} from './engine-registry.mjs';
test('orchestrator contracts normalize run specs without exposing framework types', () => {
const spec = normalizeRunSpec({
runId: 'run-1',
requestId: 'request-1',
workflow: { name: 'code-run-v1', version: 2 },
subject: { userId: 'user-1' },
input: { instruction: 'fix tests' },
});
assert.equal(spec.version, 'orchestrator-run-v1');
assert.equal(spec.workflow.name, 'code-run-v1');
assert.equal(spec.workflow.version, 2);
assert.equal(spec.subject.userId, 'user-1');
const event = buildRunEvent({
runId: spec.runId,
sequence: 1,
type: 'workflow.started',
});
assert.equal(event.version, 'orchestrator-event-v1');
assert.equal(event.type, 'workflow.started');
assert.equal(stableRolloutBucket('run-1'), stableRolloutBucket('run-1'));
});
test('workflow engine registry enforces the framework-neutral contract', () => {
const engine = {
id: 'native',
start() {},
resume() {},
cancel() {},
getState() {},
async *streamEvents() {},
};
const registry = createWorkflowEngineRegistry([engine]);
assert.equal(registry.has('native'), true);
assert.equal(registry.get('native'), engine);
assert.throws(
() => registry.register({ id: 'broken' }),
/missing methods/,
);
});
test('remote workflow engine speaks only the versioned orchestrator HTTP contract', async () => {
const calls = [];
const engine = createRemoteWorkflowEngine({
baseUrl: 'http://orchestrator.internal',
fetchImpl: async (url, init) => {
calls.push({ url, init });
return {
ok: true,
async json() {
return url.endsWith('/events') ? { events: [{ type: 'run.succeeded' }] } : { ok: true };
},
};
},
});
await engine.start({ runId: 'run-1' });
await engine.resume('run-1', { approvalId: 'approval-1', decision: 'approve' });
await engine.cancel('run-1');
await engine.getState('run-1');
const events = [];
for await (const event of engine.streamEvents('run-1')) events.push(event);
assert.deepEqual(calls.map((call) => [call.init?.method ?? 'GET', call.url]), [
['POST', 'http://orchestrator.internal/v1/runs'],
['POST', 'http://orchestrator.internal/v1/runs/run-1/resume'],
['POST', 'http://orchestrator.internal/v1/runs/run-1/cancel'],
['GET', 'http://orchestrator.internal/v1/runs/run-1'],
['GET', 'http://orchestrator.internal/v1/runs/run-1/events'],
]);
assert.deepEqual(events, [{ type: 'run.succeeded' }]);
});
+133
View File
@@ -0,0 +1,133 @@
import {
WORKFLOW_ENGINE,
normalizeWorkflowEngineId,
} from './contracts.mjs';
const REQUIRED_ENGINE_METHODS = Object.freeze([
'start',
'resume',
'cancel',
'getState',
'streamEvents',
]);
function validateEngine(engine) {
if (!engine || typeof engine !== 'object') {
throw new Error('Workflow engine must be an object');
}
const id = normalizeWorkflowEngineId(engine.id, '');
if (!id) throw new Error('Workflow engine requires a valid id');
const missing = REQUIRED_ENGINE_METHODS.filter((method) => typeof engine[method] !== 'function');
if (missing.length) {
throw new Error(`Workflow engine ${id} missing methods: ${missing.join(', ')}`);
}
return id;
}
export function createWorkflowEngineRegistry(initialEngines = []) {
const engines = new Map();
function register(engine) {
const id = validateEngine(engine);
if (engines.has(id)) throw new Error(`Workflow engine already registered: ${id}`);
engines.set(id, engine);
return engine;
}
for (const engine of initialEngines) register(engine);
return {
register,
get(id) {
return engines.get(normalizeWorkflowEngineId(id, '')) ?? null;
},
has(id) {
return engines.has(normalizeWorkflowEngineId(id, ''));
},
list() {
return [...engines.values()].map((engine) => ({
id: engine.id,
label: engine.label ?? engine.id,
kind: engine.kind ?? 'plugin',
capabilities: Array.isArray(engine.capabilities) ? [...engine.capabilities] : [],
}));
},
};
}
function createHttpError(response, action) {
const error = new Error(`Workflow engine ${action} failed with HTTP ${response?.status ?? 'unknown'}`);
error.code = 'WORKFLOW_ENGINE_HTTP_ERROR';
error.status = response?.status ?? null;
return error;
}
function joinUrl(baseUrl, pathname) {
return `${String(baseUrl).replace(/\/$/, '')}/${String(pathname).replace(/^\//, '')}`;
}
export function createRemoteWorkflowEngine({
id = WORKFLOW_ENGINE.LANGGRAPH,
label = 'LangGraph',
baseUrl,
serviceToken = null,
timeoutMs = 5000,
fetchImpl = globalThis.fetch,
} = {}) {
const normalizedId = normalizeWorkflowEngineId(id, '');
const normalizedBaseUrl = String(baseUrl ?? '').trim().replace(/\/$/, '');
if (!normalizedId) throw new Error('Remote workflow engine requires a valid id');
if (!normalizedBaseUrl) throw new Error(`Remote workflow engine ${normalizedId} requires baseUrl`);
if (typeof fetchImpl !== 'function') throw new Error('Remote workflow engine requires fetch');
async function request(pathname, init = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(100, Number(timeoutMs) || 5000));
try {
const response = await fetchImpl(joinUrl(normalizedBaseUrl, pathname), {
...init,
headers: {
accept: 'application/json',
...(init.body ? { 'content-type': 'application/json' } : {}),
...(serviceToken ? { authorization: `Bearer ${serviceToken}` } : {}),
...(init.headers ?? {}),
},
signal: controller.signal,
});
if (!response?.ok) throw createHttpError(response, pathname);
return response.json();
} finally {
clearTimeout(timer);
}
}
return {
id: normalizedId,
label,
kind: 'remote',
capabilities: ['durable-execution', 'interrupt', 'streaming'],
async start(spec) {
return request('/v1/runs', { method: 'POST', body: JSON.stringify(spec) });
},
async resume(runId, input) {
return request(`/v1/runs/${encodeURIComponent(runId)}/resume`, {
method: 'POST',
body: JSON.stringify(input ?? {}),
});
},
async cancel(runId, input = {}) {
return request(`/v1/runs/${encodeURIComponent(runId)}/cancel`, {
method: 'POST',
body: JSON.stringify(input),
});
},
async getState(runId) {
return request(`/v1/runs/${encodeURIComponent(runId)}`);
},
async *streamEvents(runId, cursor = null) {
const query = cursor == null ? '' : `?after=${encodeURIComponent(cursor)}`;
const result = await request(`/v1/runs/${encodeURIComponent(runId)}/events${query}`);
for (const event of Array.isArray(result?.events) ? result.events : []) yield event;
},
};
}