Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

988 lines
39 KiB
TypeScript

/** Unit tests for EmbeddedWorkflowService CRUD and persistence against a real PGlite-backed Drizzle store. */
import { describe, expect, test } from 'bun:test';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGlite } from '@electric-sql/pglite';
import type { IAgentRuntime } from '@elizaos/core';
import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/pglite';
import * as dbSchema from '../../src/db/schema';
import { EmbeddedWorkflowService } from '../../src/services/embedded-workflow-service';
import { WorkflowService } from '../../src/services/workflow-service';
function runtime(
settings: Record<string, unknown> = {},
services: Record<string, unknown> = {},
db?: unknown
) {
const mockRuntime = {
agentId: 'agent-test',
character: { settings: {} },
db,
getSetting: (key: string) => settings[key] ?? null,
getService: (type: string) => services[type] ?? null,
} satisfies Partial<IAgentRuntime> & { db?: unknown };
return mockRuntime as IAgentRuntime;
}
async function persistentRuntime(
settings: Record<string, unknown> = {},
services: Record<string, unknown> = {}
) {
const dir = await mkdtemp(join(tmpdir(), 'embedded-workflow-service-'));
const client = new PGlite({ dataDir: join(dir, 'pglite') });
const db = drizzle(client, { schema: dbSchema });
return {
runtime: runtime({ WORKFLOW_SEED_DEFAULTS: false, ...settings }, services, db),
async close() {
await client.close();
await rm(dir, { recursive: true, force: true });
},
};
}
/**
* A seeding harness with the pieces the default-workflow seed needs: a
* persistent PGlite DB that survives service restarts, an in-memory task queue
* (so `armSchedules` has `createTask`/`getTasks`/`deleteTask`), and a
* persistent in-memory cache backing `getCache`/`setCache` so the once-per
* install seed marker survives a restart. `restart()` starts a fresh service
* against the SAME db + cache, simulating a process reboot.
*/
async function seedingHarness(settings: Record<string, unknown> = {}) {
const dir = await mkdtemp(join(tmpdir(), 'embedded-workflow-seed-'));
const client = new PGlite({ dataDir: join(dir, 'pglite') });
const db = drizzle(client, { schema: dbSchema });
const tasks: Array<Record<string, unknown>> = [];
const cache = new Map<string, unknown>();
const reports: Array<{ scope: string; error: unknown; context?: Record<string, unknown> }> = [];
const settingsMap: Record<string, unknown> = {
WORKFLOW_SEED_DEFAULTS: true,
...settings,
};
// When true, the next getCache/setCache call throws to simulate a transient
// cache outage (used to prove the fail-closed no-zombie-re-seed guarantee).
const control = {
failNextCacheRead: false,
failCacheWrite: false,
cacheWriteReturnsFalse: false,
failPriorDeletionCheck: false,
};
const runtimeDb = new Proxy(db, {
get(target, prop, receiver) {
if (prop !== 'select') return Reflect.get(target, prop, receiver);
return (...args: unknown[]) => {
const builder = Reflect.apply(target.select, target, args);
return new Proxy(builder, {
get(selectTarget, selectProp, selectReceiver) {
if (selectProp !== 'from') {
return Reflect.get(selectTarget, selectProp, selectReceiver);
}
return (table: unknown) => {
if (control.failPriorDeletionCheck && table === dbSchema.workflowRevisions) {
throw new Error('workflow revision query unavailable');
}
return Reflect.apply(selectTarget.from, selectTarget, [table]);
};
},
});
};
},
});
let taskSeq = 0;
const buildRuntime = () =>
({
agentId: 'agent-test',
character: { settings: {} },
db: runtimeDb,
getSetting: (key: string) => settingsMap[key] ?? null,
getService: () => null,
reportError: (scope: string, error: unknown, context?: Record<string, unknown>) => {
reports.push({ scope, error, context });
},
createTask: async (task: Record<string, unknown>) => {
taskSeq += 1;
tasks.push({ id: `task-${taskSeq}`, ...task });
},
getTasks: async () => tasks,
deleteTask: async (id: string) => {
const index = tasks.findIndex((task) => task.id === id);
if (index >= 0) tasks.splice(index, 1);
},
getCache: async <T>(key: string): Promise<T | undefined> => {
if (control.failNextCacheRead) {
throw new Error('cache unavailable');
}
return cache.has(key) ? (cache.get(key) as T) : undefined;
},
setCache: async <T>(key: string, value: T): Promise<boolean> => {
if (control.failCacheWrite) {
throw new Error('cache write unavailable');
}
// Simulate a cache backend that reports a non-persisted write via its
// boolean return rather than by throwing.
if (control.cacheWriteReturnsFalse) {
return false;
}
cache.set(key, value);
return true;
},
}) as unknown as IAgentRuntime;
return {
tasks,
cache,
control,
reports,
setSetting(key: string, value: unknown) {
settingsMap[key] = value;
},
async start() {
return EmbeddedWorkflowService.start(buildRuntime());
},
async listDefaultRows() {
return db
.select({ id: dbSchema.embeddedWorkflows.id })
.from(dbSchema.embeddedWorkflows)
.where(eq(dbSchema.embeddedWorkflows.id, DEFAULT_WORKFLOW_ID));
},
async close() {
await client.close();
await rm(dir, { recursive: true, force: true });
},
};
}
const DEFAULT_WORKFLOW_ID = 'system-device-health-check';
const SEED_MARKER_KEY = 'eliza:workflow:seeded-defaults:v1';
describe('EmbeddedWorkflowService', () => {
test('rejects workflows with unregistered nodes before activation', async () => {
const service = await EmbeddedWorkflowService.start(runtime());
await expect(
service.createWorkflow({
name: 'Unsupported',
nodes: [
{
id: 'unknown',
name: 'Unknown',
type: 'workflows-nodes-base.unknown',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
})
).rejects.toThrow('Embedded workflow runtime does not support node');
});
test('WorkflowService uses the embedded backend without external runtime settings', async () => {
const harness = await persistentRuntime({ WORKFLOW_BACKEND: 'embedded' });
const embedded = await EmbeddedWorkflowService.start(harness.runtime);
const serviceRuntime = runtime(
{ WORKFLOW_BACKEND: 'embedded' },
{ embedded_workflow_service: embedded },
harness.runtime.db
);
const service = await WorkflowService.start(serviceRuntime);
const workflows = await service.listWorkflows();
expect(workflows).toEqual([]);
await service.stop();
await embedded.stop();
await harness.close();
}, 60_000);
test('seeds and runs the no-LLM device health check workflow by default', async () => {
const tasks: Array<Record<string, unknown>> = [];
const harness = await persistentRuntime({ WORKFLOW_SEED_DEFAULTS: true });
const runtimeWithTasks = {
...harness.runtime,
agentId: 'agent-test',
createTask: async (task: Record<string, unknown>) => {
tasks.push({ id: `task-${tasks.length + 1}`, ...task });
},
getTasks: async () => tasks,
deleteTask: async (id: string) => {
const index = tasks.findIndex((task) => task.id === id);
if (index >= 0) tasks.splice(index, 1);
},
} as unknown as IAgentRuntime;
const service = await EmbeddedWorkflowService.start(runtimeWithTasks);
try {
const workflows = await service.listWorkflows();
const healthCheck = workflows.data.find(
(workflow) => workflow.id === 'system-device-health-check'
);
expect(healthCheck?.active).toBe(true);
expect(healthCheck?.nodes.map((node) => node.type)).toContain(
'workflows-nodes-base.deviceStatus'
);
expect(tasks).toHaveLength(1);
expect((tasks[0].metadata as { workflowId?: string }).workflowId).toBe(
'system-device-health-check'
);
const executions = await service.listExecutions({
workflowId: 'system-device-health-check',
limit: 1,
});
expect(executions.data).toHaveLength(1);
expect(executions.data[0].status).toBe('success');
const item =
executions.data[0].data?.resultData?.runData?.['Device Status']?.[0]?.data?.main?.[0]?.[0]
?.json;
expect(item?.memory).toMatchObject({
totalBytes: expect.any(Number),
freeBytes: expect.any(Number),
});
expect(item?.disk).toMatchObject({
mount: '/',
availableBytes: expect.any(Number),
});
} finally {
await service.stop();
await harness.close();
}
}, 90_000);
test('seeds exactly one default workflow on first run and records the marker', async () => {
const harness = await seedingHarness();
const service = await harness.start();
try {
const workflows = await service.listWorkflows();
const defaults = workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID);
// Exactly one default, and it is the only workflow present on a fresh install.
expect(defaults).toHaveLength(1);
expect(workflows.data).toHaveLength(1);
expect(defaults[0].active).toBe(true);
// Routed through the ONE scheduler: a single TRIGGER_DISPATCH core Task.
expect(harness.tasks).toHaveLength(1);
expect((harness.tasks[0].metadata as { workflowId?: string }).workflowId).toBe(
DEFAULT_WORKFLOW_ID
);
// Once-per-install marker was recorded.
expect(harness.cache.get(SEED_MARKER_KEY)).toMatchObject({
workflowId: DEFAULT_WORKFLOW_ID,
seededAt: expect.any(String),
});
} finally {
await service.stop();
await harness.close();
}
}, 90_000);
test('is idempotent across restarts — never re-seeds a second default', async () => {
const harness = await seedingHarness();
const first = await harness.start();
await first.stop();
// Reboot against the same db + cache. The marker + existing row must both
// suppress a second seed.
const second = await harness.start();
try {
const workflows = await second.listWorkflows();
expect(workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(1);
expect(workflows.data).toHaveLength(1);
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('respects a user deletion — no zombie re-seed after restart', async () => {
const harness = await seedingHarness();
const first = await harness.start();
// Simulate the user deleting the seeded default.
await first.deleteWorkflow(DEFAULT_WORKFLOW_ID);
let afterDelete = await first.listWorkflows();
expect(afterDelete.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(0);
await first.stop();
// Reboot: the persistent marker must stop the deleted default from coming back.
const second = await harness.start();
try {
afterDelete = await second.listWorkflows();
expect(afterDelete.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(0);
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('fails closed on a cache-read outage — a deleted default is not resurrected', async () => {
const harness = await seedingHarness();
const first = await harness.start();
await first.deleteWorkflow(DEFAULT_WORKFLOW_ID);
await first.stop();
// The marker persists in the cache, but the cache read throws on this boot.
// We must NOT fall back to the row check and re-seed the deleted default.
harness.control.failNextCacheRead = true;
const second = await harness.start();
try {
const workflows = await second.listWorkflows();
expect(workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(0);
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('aborts seeding when the marker cannot be persisted — no orphan default', async () => {
const harness = await seedingHarness();
// The marker write fails on first boot: seeding must abort with NO row
// inserted, so there is never an active default that lacks its marker.
harness.control.failCacheWrite = true;
const first = await harness.start();
let workflows = await first.listWorkflows();
expect(workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(0);
expect(harness.tasks).toHaveLength(0);
expect(harness.cache.get(SEED_MARKER_KEY)).toBeUndefined();
await first.stop();
// Cache recovers on the next boot: seeding retries cleanly and records the
// marker exactly once.
harness.control.failCacheWrite = false;
const second = await harness.start();
try {
workflows = await second.listWorkflows();
expect(workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(1);
expect(harness.cache.get(SEED_MARKER_KEY)).toMatchObject({
workflowId: DEFAULT_WORKFLOW_ID,
});
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('treats a false setCache result as a failed marker write — no orphan default', async () => {
const harness = await seedingHarness();
// The cache reports the marker write as not-persisted (returns false).
// Seeding must roll the row back, leaving neither row nor marker.
harness.control.cacheWriteReturnsFalse = true;
const first = await harness.start();
let workflows = await first.listWorkflows();
expect(workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(0);
expect(harness.cache.get(SEED_MARKER_KEY)).toBeUndefined();
await first.stop();
// Cache recovers: seeding retries and completes exactly once.
harness.control.cacheWriteReturnsFalse = false;
const second = await harness.start();
try {
workflows = await second.listWorkflows();
expect(workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(1);
expect(harness.cache.get(SEED_MARKER_KEY)).toMatchObject({
workflowId: DEFAULT_WORKFLOW_ID,
});
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('backfills the seed marker for a pre-marker existing default row', async () => {
// Simulate an install upgraded from the old row-existence-only seeding:
// seed once while pretending the cache has no setCache (so no marker is
// written), then reboot with a healthy cache and confirm the marker is
// backfilled without duplicating or re-seeding the row.
const harness = await seedingHarness();
// First boot writes the row but the marker write reports not-persisted, so
// the row exists with NO marker — the pre-marker upgrade state. We use the
// false-return path and then keep the row by writing it directly is complex;
// instead seed normally, then clear the marker to emulate the upgrade.
const first = await harness.start();
expect(
(await first.listWorkflows()).data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)
).toHaveLength(1);
await first.stop();
// Emulate a pre-marker upgrade: the row exists but the marker is absent.
harness.cache.delete(SEED_MARKER_KEY);
expect(harness.cache.get(SEED_MARKER_KEY)).toBeUndefined();
const second = await harness.start();
try {
// Row is untouched (not duplicated, not re-seeded) and the marker is back.
expect(
(await second.listWorkflows()).data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)
).toHaveLength(1);
expect(harness.cache.get(SEED_MARKER_KEY)).toMatchObject({
workflowId: DEFAULT_WORKFLOW_ID,
});
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('does not seed the default into an existing non-default workflow store', async () => {
const harness = await seedingHarness({ WORKFLOW_SEED_DEFAULTS: false });
const first = await harness.start();
await first.createWorkflow({
id: 'user-nightly-summary',
name: 'User nightly summary',
active: false,
nodes: [
{
id: 'manual',
name: 'Manual Trigger',
type: 'workflows-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
await first.stop();
harness.setSetting('WORKFLOW_SEED_DEFAULTS', true);
const second = await harness.start();
try {
const workflows = await second.listWorkflows();
expect(workflows.data.map((workflow) => workflow.id)).toEqual(['user-nightly-summary']);
expect(workflows.data.filter((workflow) => workflow.id === DEFAULT_WORKFLOW_ID)).toHaveLength(
0
);
expect(harness.tasks).toHaveLength(0);
expect(harness.cache.get(SEED_MARKER_KEY)).toMatchObject({
workflowId: DEFAULT_WORKFLOW_ID,
});
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('preserves a pre-marker deletion via the delete revision on upgrade', async () => {
// Simulate an install upgraded from a pre-marker build where the user had
// ALREADY deleted the default: seed, delete (leaving a `delete` revision),
// then clear the marker to emulate the pre-marker era. On reboot, neither a
// marker nor a row exists, but the delete revision must stop a re-seed.
const harness = await seedingHarness();
const first = await harness.start();
await first.deleteWorkflow(DEFAULT_WORKFLOW_ID);
await first.stop();
harness.cache.delete(SEED_MARKER_KEY);
expect(harness.cache.get(SEED_MARKER_KEY)).toBeUndefined();
const second = await harness.start();
try {
const workflows = await second.listWorkflows();
expect(workflows.data.filter((w) => w.id === DEFAULT_WORKFLOW_ID)).toHaveLength(0);
// The marker is backfilled so subsequent boots skip the revision query.
expect(harness.cache.get(SEED_MARKER_KEY)).toMatchObject({
workflowId: DEFAULT_WORKFLOW_ID,
});
} finally {
await second.stop();
await harness.close();
}
}, 90_000);
test('fails closed when the prior default deletion check fails', async () => {
const harness = await seedingHarness({ WORKFLOW_SEED_DEFAULTS: false });
const first = await harness.start();
await first.createWorkflow({
id: DEFAULT_WORKFLOW_ID,
name: 'Pre-marker default',
nodes: [
{
id: 'manual',
name: 'Manual Trigger',
type: 'workflows-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
await first.deleteWorkflow(DEFAULT_WORKFLOW_ID);
await first.stop();
harness.setSetting('WORKFLOW_SEED_DEFAULTS', true);
harness.control.failPriorDeletionCheck = true;
try {
await expect(harness.start()).rejects.toMatchObject({
code: 'WORKFLOW_DEFAULT_SEED_DELETION_CHECK_FAILED',
context: { workflowId: DEFAULT_WORKFLOW_ID },
});
expect(await harness.listDefaultRows()).toHaveLength(0);
expect(harness.tasks).toHaveLength(0);
expect(harness.reports).toHaveLength(1);
expect(harness.reports[0]).toMatchObject({
scope: 'EmbeddedWorkflowService.seedDefaultWorkflows',
context: { workflowId: DEFAULT_WORKFLOW_ID },
});
} finally {
await harness.close();
}
}, 90_000);
test('WORKFLOW_SEED_DEFAULTS=false disables seeding entirely', async () => {
const harness = await seedingHarness({ WORKFLOW_SEED_DEFAULTS: false });
const service = await harness.start();
try {
const workflows = await service.listWorkflows();
expect(workflows.data).toHaveLength(0);
expect(harness.cache.get(SEED_MARKER_KEY)).toBeUndefined();
} finally {
await service.stop();
await harness.close();
}
}, 60_000);
test('runs a schedule -> HTTP Request -> Set workflow in a child process', async () => {
const pluginRoot = join(import.meta.dir, '../..');
const script = `
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import { EmbeddedWorkflowService } from './src/services/embedded-workflow-service.ts';
import * as dbSchema from './src/db/schema.ts';
const dir = await mkdtemp(join(tmpdir(), 'embedded-workflows-child-'));
const client = new PGlite({ dataDir: join(dir, 'pglite') });
const db = drizzle(client, { schema: dbSchema });
const runtime = { db, getSetting: () => null, getService: () => null };
const service = await EmbeddedWorkflowService.start(runtime);
try {
globalThis.fetch = async (url, options) =>
new Response(JSON.stringify({ ok: true, url: String(url), method: options?.method ?? 'GET' }), {
headers: { 'content-type': 'application/json' },
status: 200,
});
const created = await service.createWorkflow({
name: 'P0 smoke',
nodes: [
{ id: 'schedule', name: 'Schedule Trigger', type: 'workflows-nodes-base.scheduleTrigger', typeVersion: 1.2, position: [0, 0], parameters: {} },
{ id: 'http', name: 'HTTP Request', type: 'workflows-nodes-base.httpRequest', typeVersion: 4.2, position: [200, 0], parameters: { url: 'https://example.test/ping', method: 'GET' } },
{ id: 'set', name: 'Set', type: 'workflows-nodes-base.set', typeVersion: 3.4, position: [400, 0], parameters: { assignments: { assignments: [{ name: 'source', value: 'embedded' }] } } },
],
connections: {
'Schedule Trigger': { main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]] },
'HTTP Request': { main: [[{ node: 'Set', type: 'main', index: 0 }]] },
},
});
const execution = await service.executeWorkflow(created.id);
const item = execution.data?.resultData?.runData?.Set?.[0]?.data?.main?.[0]?.[0]?.json;
if (execution.status !== 'success') throw new Error('Expected successful embedded execution');
if (item?.source !== 'embedded') throw new Error('Expected Set node to add source');
if (item?.body?.ok !== true) throw new Error('Expected HTTP response body to be preserved');
console.log('RESULT:' + JSON.stringify({ status: execution.status, item }));
} finally {
await service.stop();
await client.close();
await rm(dir, { recursive: true, force: true });
}
`;
const proc = Bun.spawn([process.execPath, '-e', script], {
cwd: pluginRoot,
env: { ...process.env, WORKFLOW_DIAGNOSTICS_ENABLED: 'false' },
stdout: 'ignore',
stderr: 'pipe',
});
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
expect(stderr).not.toContain('HTTP Request node requires');
expect(exitCode).toBe(0);
}, 60_000);
test('persists workflows across embedded service restarts', async () => {
const dir = await mkdtemp(join(tmpdir(), 'embedded-workflows-persist-'));
const dataDir = join(dir, 'pglite');
const firstClient = new PGlite({ dataDir });
const firstDb = drizzle(firstClient, { schema: dbSchema });
const first = await EmbeddedWorkflowService.start(runtime({}, {}, firstDb));
const created = await first.createWorkflow({
name: 'Persistent workflow',
nodes: [
{
id: 'manual',
name: 'Manual Trigger',
type: 'workflows-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
await first.stop();
await firstClient.close();
const secondClient = new PGlite({ dataDir });
const secondDb = drizzle(secondClient, { schema: dbSchema });
const second = await EmbeddedWorkflowService.start(runtime({}, {}, secondDb));
const loaded = await second.getWorkflow(created.id);
expect(loaded.name).toBe('Persistent workflow');
expect(loaded.id).toBe(created.id);
await second.stop();
await secondClient.close();
await rm(dir, { recursive: true, force: true });
}, 60_000);
test('captures workflow revisions and restores a previous version', async () => {
const harness = await persistentRuntime();
const service = await EmbeddedWorkflowService.start(harness.runtime);
try {
const created = await service.createWorkflow({
name: 'Revision base',
nodes: [
{
id: 'manual',
name: 'Manual Trigger',
type: 'workflows-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
});
const updated = await service.updateWorkflow(created.id, {
...created,
name: 'Revision updated',
nodes: [
...(created.nodes ?? []),
{
id: 'set',
name: 'Set',
type: 'workflows-nodes-base.set',
typeVersion: 3.4,
position: [200, 0],
parameters: {
assignments: { assignments: [{ name: 'restored', value: false }] },
},
},
],
connections: {
'Manual Trigger': { main: [[{ node: 'Set', type: 'main', index: 0 }]] },
},
});
const beforeRestore = await service.listWorkflowRevisions(created.id);
expect(beforeRestore.data).toHaveLength(1);
expect(beforeRestore.data[0].name).toBe('Revision base');
expect(beforeRestore.data[0].versionId).toBe(created.versionId);
expect(beforeRestore.data[0].operation).toBe('update');
const restored = await service.restoreWorkflowRevision(created.id, created.versionId);
expect(restored.name).toBe('Revision base');
expect(restored.nodes.map((node) => node.name)).toEqual(['Manual Trigger']);
expect(restored.versionId).not.toBe(created.versionId);
expect(restored.versionId).not.toBe(updated.versionId);
const afterRestore = await service.listWorkflowRevisions(created.id);
expect(afterRestore.data[0].name).toBe('Revision updated');
expect(afterRestore.data[0].operation).toBe('restore');
} finally {
await service.stop();
await harness.close();
}
}, 60_000);
test('runs Code node in the QuickJS sandbox', async () => {
const pluginRoot = join(import.meta.dir, '../..');
const resultDir = await mkdtemp(join(tmpdir(), 'embedded-workflows-code-result-'));
const resultPath = join(resultDir, 'result.json');
const script = `
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import { EmbeddedWorkflowService } from './src/services/embedded-workflow-service.ts';
import * as dbSchema from './src/db/schema.ts';
const dir = await mkdtemp(join(tmpdir(), 'embedded-workflows-code-'));
const client = new PGlite({ dataDir: join(dir, 'pglite') });
const db = drizzle(client, { schema: dbSchema });
const runtime = { agentId: 'agent-test', character: { settings: {} }, db, getSetting: () => null, getService: () => null };
const service = await EmbeddedWorkflowService.start(runtime);
try {
const created = await service.createWorkflow({
name: 'QuickJS code',
nodes: [
{ id: 'manual', name: 'Manual Trigger', type: 'workflows-nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {} },
{ id: 'code', name: 'Code', type: 'workflows-nodes-base.code', typeVersion: 2, position: [200, 0], parameters: { jsCode: 'return items.map((item) => ({ json: { ok: true, trigger: item.json.trigger } }));' } },
],
connections: {
'Manual Trigger': { main: [[{ node: 'Code', type: 'main', index: 0 }]] },
},
});
const execution = await service.executeWorkflow(created.id);
const item = execution.data?.resultData?.runData?.Code?.[0]?.data?.main?.[0]?.[0]?.json;
if (execution.status !== 'success') throw new Error('Expected successful Code execution');
if (item?.ok !== true) throw new Error('Expected Code node to set ok=true');
if (item?.trigger !== 'manual') throw new Error('Expected manual trigger data to reach Code node');
await writeFile(process.env.WORKFLOW_CODE_RESULT_PATH, JSON.stringify({ status: execution.status, item }));
} finally {
await service.stop();
await client.close();
await rm(dir, { recursive: true, force: true });
}
`;
try {
const proc = Bun.spawn([process.execPath, '-e', script], {
cwd: pluginRoot,
env: {
...process.env,
WORKFLOW_CODE_RESULT_PATH: resultPath,
WORKFLOW_DIAGNOSTICS_ENABLED: 'false',
},
stdout: 'ignore',
stderr: 'pipe',
});
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const result = JSON.parse(await readFile(resultPath, 'utf8')) as {
status?: string;
item?: { ok?: boolean; trigger?: string };
};
expect(result.status).toBe('success');
expect(result.item?.ok).toBe(true);
expect(result.item?.trigger).toBe('manual');
} finally {
await rm(resultDir, { recursive: true, force: true });
}
}, 90_000);
test('returns persisted error executions for non-throwing planning failures', async () => {
const harness = await persistentRuntime();
const service = await EmbeddedWorkflowService.start(harness.runtime);
try {
const created = await service.createWorkflow({
name: 'Cyclic graph',
nodes: [
{
id: 'set-a',
name: 'Set A',
type: 'workflows-nodes-base.set',
typeVersion: 3.4,
position: [0, 0],
parameters: { assignments: { assignments: [{ name: 'a', value: true }] } },
},
{
id: 'set-b',
name: 'Set B',
type: 'workflows-nodes-base.set',
typeVersion: 3.4,
position: [200, 0],
parameters: { assignments: { assignments: [{ name: 'b', value: true }] } },
},
],
connections: {
'Set A': { main: [[{ node: 'Set B', type: 'main', index: 0 }]] },
'Set B': { main: [[{ node: 'Set A', type: 'main', index: 0 }]] },
},
});
const execution = await service.executeWorkflow(created.id, { throwOnError: false });
const persisted = await service.getExecution(execution.id);
expect(execution.status).toBe('error');
expect(execution.finished).toBe(true);
expect(execution.data?.resultData?.error?.message).toContain(
'Unable to resolve workflow execution order'
);
expect(persisted.status).toBe('error');
expect(persisted.data?.resultData?.error?.message).toContain(
'Unable to resolve workflow execution order'
);
} finally {
await service.stop();
await harness.close();
}
}, 60_000);
test('persists node execution through Smithers step storage', async () => {
const pluginRoot = join(import.meta.dir, '../..');
const resultDir = await mkdtemp(join(tmpdir(), 'embedded-workflows-smithers-result-'));
const resultPath = join(resultDir, 'result.json');
const script = `
import { Database } from 'bun:sqlite';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import { EmbeddedWorkflowService } from './src/services/embedded-workflow-service.ts';
import * as dbSchema from './src/db/schema.ts';
const dir = await mkdtemp(join(tmpdir(), 'embedded-workflows-smithers-'));
const client = new PGlite({ dataDir: join(dir, 'pglite') });
const db = drizzle(client, { schema: dbSchema });
const runtime = { agentId: 'agent-test', character: { settings: {} }, db, getSetting: () => null, getService: () => null };
const service = await EmbeddedWorkflowService.start(runtime);
let smithersDbPath = null;
try {
const created = await service.createWorkflow({
name: 'Smithers persistence',
nodes: [
{ id: 'manual', name: 'Manual Trigger', type: 'workflows-nodes-base.manualTrigger', typeVersion: 1, position: [0, 0], parameters: {} },
{ id: 'set', name: 'Set', type: 'workflows-nodes-base.set', typeVersion: 3.4, position: [200, 0], parameters: { assignments: { assignments: [{ name: 'smithersRecorded', value: true }] } } },
],
connections: {
'Manual Trigger': { main: [[{ node: 'Set', type: 'main', index: 0 }]] },
},
});
const execution = await service.executeWorkflow(created.id);
const item = execution.data?.resultData?.runData?.Set?.[0]?.data?.main?.[0]?.[0]?.json;
const engine = execution.data?.resultData?.engine;
smithersDbPath = join(process.cwd(), '.eliza', 'smithers', created.id + '.sqlite');
const smithersDb = new Database(smithersDbPath, { readonly: true });
try {
const tables = smithersDb
.query("select name from sqlite_master where type = 'table' and name like 'smithers_%' order by name")
.all()
.map((row) => row.name);
const persistedSetRows = smithersDb
.query('select payload from smithers_0001_set where node_id = ? order by iteration')
.all('0001-set');
await writeFile(
process.env.WORKFLOW_SMITHERS_RESULT_PATH,
JSON.stringify({
status: execution.status,
item,
engine,
tables,
persistedSetRowsLength: persistedSetRows.length,
})
);
} finally {
smithersDb.close();
}
} finally {
await service.stop();
await client.close();
await rm(dir, { recursive: true, force: true });
if (smithersDbPath) {
await Promise.all([
rm(smithersDbPath, { force: true }),
rm(smithersDbPath + '-wal', { force: true }),
rm(smithersDbPath + '-shm', { force: true }),
]);
}
}
`;
try {
const proc = Bun.spawn([process.execPath, '-e', script], {
cwd: pluginRoot,
env: {
...process.env,
WORKFLOW_SMITHERS_RESULT_PATH: resultPath,
WORKFLOW_DIAGNOSTICS_ENABLED: 'false',
},
stdout: 'ignore',
stderr: 'pipe',
});
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const result = JSON.parse(await readFile(resultPath, 'utf8')) as {
status?: string;
item?: { smithersRecorded?: boolean };
engine?: { provider?: string; nodes?: number; levels?: number; maxConcurrency?: number };
tables?: string[];
persistedSetRowsLength?: number;
};
expect(result.status).toBe('success');
expect(result.item?.smithersRecorded).toBe(true);
expect(result.engine).toMatchObject({
provider: 'smithers',
nodes: 2,
levels: 2,
maxConcurrency: 1,
});
expect(result.tables).toContain('smithers_0000_manual');
expect(result.tables).toContain('smithers_0001_set');
expect(result.tables).toContain('smithers_eliza_workflow_result');
expect(result.persistedSetRowsLength).toBe(1);
} finally {
await rm(resultDir, { recursive: true, force: true });
}
}, 60_000);
test('executes active embedded webhooks through the plugin service', async () => {
const pluginRoot = join(import.meta.dir, '../..');
const resultDir = await mkdtemp(join(tmpdir(), 'embedded-workflows-webhook-result-'));
const resultPath = join(resultDir, 'result.json');
const script = `
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGlite } from '@electric-sql/pglite';
import { drizzle } from 'drizzle-orm/pglite';
import { EmbeddedWorkflowService } from './src/services/embedded-workflow-service.ts';
import * as dbSchema from './src/db/schema.ts';
const dir = await mkdtemp(join(tmpdir(), 'embedded-workflows-webhook-'));
const client = new PGlite({ dataDir: join(dir, 'pglite') });
const db = drizzle(client, { schema: dbSchema });
const runtime = { agentId: 'agent-test', character: { settings: {} }, db, getSetting: () => null, getService: () => null };
const service = await EmbeddedWorkflowService.start(runtime);
try {
const created = await service.createWorkflow({
name: 'Webhook workflow',
nodes: [
{ id: 'webhook', name: 'Webhook', type: 'workflows-nodes-base.webhook', typeVersion: 2, position: [0, 0], parameters: { path: 'incoming', httpMethod: 'POST' } },
{ id: 'set', name: 'Set', type: 'workflows-nodes-base.set', typeVersion: 3.4, position: [200, 0], parameters: { assignments: { assignments: [{ name: 'handled', value: true }] } } },
],
connections: {
Webhook: { main: [[{ node: 'Set', type: 'main', index: 0 }]] },
},
});
await service.activateWorkflow(created.id);
const execution = await service.executeWebhook('incoming', { payload: 'ok' }, 'POST');
const item = execution.data?.resultData?.runData?.Set?.[0]?.data?.main?.[0]?.[0]?.json;
await writeFile(process.env.WORKFLOW_WEBHOOK_RESULT_PATH, JSON.stringify({ status: execution.status, item }));
} finally {
await service.stop();
await client.close();
await rm(dir, { recursive: true, force: true });
}
`;
try {
const proc = Bun.spawn([process.execPath, '-e', script], {
cwd: pluginRoot,
env: {
...process.env,
WORKFLOW_WEBHOOK_RESULT_PATH: resultPath,
WORKFLOW_DIAGNOSTICS_ENABLED: 'false',
},
stdout: 'ignore',
stderr: 'pipe',
});
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
const result = JSON.parse(await readFile(resultPath, 'utf8')) as {
status?: string;
item?: { payload?: string; handled?: boolean };
};
expect(result.status).toBe('success');
expect(result.item?.payload).toBe('ok');
expect(result.item?.handled).toBe(true);
} finally {
await rm(resultDir, { recursive: true, force: true });
}
}, 60_000);
});