426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
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) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
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
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
/**
|
|
* #12362 WI-7 residual — the LifeOps `dispatch_workflow` workflow step drives
|
|
* a REAL embedded workflow execution end to end.
|
|
*
|
|
* The subject under test is the seam between the two workflow layers: a
|
|
* LifeOps owner-workflow step (`kind: "dispatch_workflow"`, contributed by
|
|
* plugin-personal-assistant's default WorkflowStepRegistry pack) resolves the
|
|
* REAL `WORKFLOW_DISPATCH` service and lands a row in the REAL
|
|
* `workflow.embedded_executions` table (PGlite-backed EmbeddedWorkflowService).
|
|
*
|
|
* The step is invoked exactly the way the LifeOps dispatcher
|
|
* (`WorkflowsDomain.executeWorkflowDefinition`,
|
|
* plugin-personal-assistant/src/lifeops/domains/workflows-service.ts) invokes
|
|
* it: registry bound to the runtime → `registry.get(step.kind)` →
|
|
* `paramSchema.parse(step)` → `contribution.execute(validated, args, ctx)` —
|
|
* NOT by calling the dispatch service directly. The full `WorkflowsDomain`
|
|
* class is not importable here (its repository pulls built dists of
|
|
* plugin-browser/finances/inbox), so this file drives the registry seam the
|
|
* dispatcher consults; the loop mechanics around that seam are covered by
|
|
* plugin-personal-assistant/test/workflow-step-registry.test.ts.
|
|
*
|
|
* Cases:
|
|
* (a) dispatch_workflow step → real execution row; the parent run's
|
|
* request + accumulated outputs are threaded into the nested
|
|
* execution's triggerData (the contribution's contract)
|
|
* (b) idempotent double-fire: same payload `__idempotencyKey` collapses two
|
|
* step executions onto one embedded execution row
|
|
* (c) WORKFLOW_DISPATCH not registered → structured failure, no execution
|
|
* (d) unknown workflowId → structured failure from the real dispatch, no row
|
|
* (e) malformed step (no workflowId) fails schema validation before dispatch
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
|
import { registerDefaultWorkflowStepPack } from '../../../plugin-personal-assistant/src/lifeops/registries/workflow-step-default-pack.ts';
|
|
import {
|
|
createWorkflowStepRegistry,
|
|
getWorkflowStepRegistry,
|
|
registerWorkflowStepRegistry,
|
|
type WorkflowStepExecuteArgs,
|
|
type WorkflowStepExecuteContext,
|
|
} from '../../../plugin-personal-assistant/src/lifeops/registries/workflow-step-registry.ts';
|
|
import type { EmbeddedWorkflowService } from '../../src/services/embedded-workflow-service';
|
|
import {
|
|
registerWorkflowDispatchService,
|
|
WORKFLOW_DISPATCH_SERVICE_TYPE,
|
|
type WorkflowDispatchResult,
|
|
} from '../../src/services/workflow-dispatch';
|
|
import { type EmbeddedHarness, makeEmbeddedHarness } from './embedded-harness';
|
|
|
|
setDefaultTimeout(60_000);
|
|
|
|
/** A runnable two-node workflow the nested dispatch executes for real. */
|
|
async function createDispatchableWorkflow(
|
|
workflow: EmbeddedWorkflowService,
|
|
name: string
|
|
): Promise<string> {
|
|
const created = await workflow.createWorkflow({
|
|
name,
|
|
nodes: [
|
|
{
|
|
id: 'sched',
|
|
name: 'Schedule Trigger',
|
|
type: 'workflows-nodes-base.scheduleTrigger',
|
|
typeVersion: 1.2,
|
|
position: [0, 0],
|
|
parameters: { intervalMs: 60_000 },
|
|
},
|
|
{
|
|
id: 'set',
|
|
name: 'Set',
|
|
type: 'workflows-nodes-base.set',
|
|
typeVersion: 3.4,
|
|
position: [200, 0],
|
|
parameters: { assignments: { assignments: [] } },
|
|
},
|
|
],
|
|
connections: {
|
|
'Schedule Trigger': { main: [[{ node: 'Set', type: 'main', index: 0 }]] },
|
|
},
|
|
});
|
|
return created.id;
|
|
}
|
|
|
|
/** The minimal owner-workflow definition the step args carry; only ownership
|
|
* defaults are read from it by contributions, none by dispatch_workflow. */
|
|
function makeLifeOpsDefinition(): WorkflowStepExecuteArgs['definition'] {
|
|
return {
|
|
id: 'lifeops-wf-1',
|
|
agentId: 'agent',
|
|
domain: 'personal',
|
|
subjectType: 'agent',
|
|
subjectId: 'agent',
|
|
visibilityScope: 'owner_only',
|
|
contextPolicy: 'default',
|
|
title: 'Nested dispatch',
|
|
triggerType: 'manual',
|
|
schedule: { kind: 'manual' },
|
|
actionPlan: { steps: [] },
|
|
permissionPolicy: {
|
|
allowBrowserActions: false,
|
|
trustedBrowserActions: false,
|
|
},
|
|
status: 'active',
|
|
createdBy: 'user',
|
|
metadata: {},
|
|
createdAt: '2026-07-04T00:00:00.000Z',
|
|
updatedAt: '2026-07-04T00:00:00.000Z',
|
|
} as unknown as WorkflowStepExecuteArgs['definition'];
|
|
}
|
|
|
|
function makeStepArgs(overrides: Partial<WorkflowStepExecuteArgs> = {}): WorkflowStepExecuteArgs {
|
|
return {
|
|
definition: makeLifeOpsDefinition(),
|
|
startedAt: new Date().toISOString(),
|
|
confirmBrowserActions: false,
|
|
request: {},
|
|
outputs: {},
|
|
previousStepValue: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Execute one step the way `WorkflowsDomain.executeWorkflowDefinition` does:
|
|
* consult the runtime-bound registry, validate against the contribution's
|
|
* schema, then execute with the run args + service context.
|
|
*/
|
|
async function executeStepViaRegistry(
|
|
h: EmbeddedHarness,
|
|
step: Record<string, unknown>,
|
|
args: WorkflowStepExecuteArgs
|
|
): Promise<unknown> {
|
|
const registry = getWorkflowStepRegistry(h.runtime);
|
|
if (!registry) throw new Error('registry not bound to runtime');
|
|
const contribution = registry.get(String(step.kind));
|
|
if (!contribution) throw new Error(`no contribution for kind ${step.kind}`);
|
|
const validated = contribution.paramSchema.parse(step);
|
|
const ctx = { runtime: h.runtime } as unknown as WorkflowStepExecuteContext;
|
|
return contribution.execute(validated, args, ctx);
|
|
}
|
|
|
|
describe('dispatch_workflow step e2e (LifeOps registry -> WORKFLOW_DISPATCH -> embedded execution)', () => {
|
|
let h: EmbeddedHarness;
|
|
|
|
beforeEach(async () => {
|
|
h = await makeEmbeddedHarness('dispatch-step-e2e-agent');
|
|
registerWorkflowDispatchService(h.runtime);
|
|
// The same wiring plugin-personal-assistant's init performs: default pack
|
|
// registered into a registry bound to this runtime.
|
|
const registry = createWorkflowStepRegistry();
|
|
registerDefaultWorkflowStepPack(registry);
|
|
registerWorkflowStepRegistry(h.runtime, registry);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await h.close();
|
|
});
|
|
|
|
test("(a) the step lands a real execution row and threads request + outputs into the nested run's triggerData", async () => {
|
|
const workflowId = await createDispatchableWorkflow(h.workflow, 'Nested digest');
|
|
|
|
const args = makeStepArgs({
|
|
request: { reason: 'parent-run' },
|
|
outputs: { calendar: { eventCount: 3 } },
|
|
});
|
|
const result = (await executeStepViaRegistry(
|
|
h,
|
|
{
|
|
kind: 'dispatch_workflow',
|
|
workflowId,
|
|
payload: { note: 'from-step' },
|
|
},
|
|
args
|
|
)) as WorkflowDispatchResult;
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.executionId).toBeDefined();
|
|
|
|
// Domain artifact: a real row in workflow.embedded_executions.
|
|
const { data: executions } = await h.workflow.listExecutions({
|
|
workflowId,
|
|
});
|
|
expect(executions).toHaveLength(1);
|
|
const execution = executions[0];
|
|
expect(execution.id).toBe(result.executionId as string);
|
|
expect(execution.status).toBe('success');
|
|
expect(execution.finished).toBe(true);
|
|
expect(execution.mode).toBe('trigger');
|
|
|
|
// The contribution's contract: step payload + parent request + accumulated
|
|
// outputs all arrive as the nested execution's trigger data.
|
|
const triggerData = execution.customData?.triggerData as Record<string, unknown>;
|
|
expect(triggerData).toMatchObject({
|
|
note: 'from-step',
|
|
request: { reason: 'parent-run' },
|
|
outputs: { calendar: { eventCount: 3 } },
|
|
});
|
|
});
|
|
|
|
test('(b) two step fires sharing an idempotency key collapse onto one execution row', async () => {
|
|
const workflowId = await createDispatchableWorkflow(h.workflow, 'Nested dedup');
|
|
|
|
const step = {
|
|
kind: 'dispatch_workflow',
|
|
workflowId,
|
|
// The dispatch layer's legacy payload contract: __idempotencyKey rides
|
|
// inside the payload when the caller cannot pass a second argument —
|
|
// exactly how a LifeOps step would express at-most-once semantics.
|
|
payload: { __idempotencyKey: 'step-fire-1' },
|
|
};
|
|
|
|
const first = (await executeStepViaRegistry(h, step, makeStepArgs())) as WorkflowDispatchResult;
|
|
const second = (await executeStepViaRegistry(
|
|
h,
|
|
step,
|
|
makeStepArgs()
|
|
)) as WorkflowDispatchResult;
|
|
|
|
expect(first.ok).toBe(true);
|
|
expect(second.ok).toBe(true);
|
|
expect(second.dedup).toBe(true);
|
|
expect(second.executionId).toBe(first.executionId as string);
|
|
|
|
const { data: executions } = await h.workflow.listExecutions({
|
|
workflowId,
|
|
});
|
|
expect(executions).toHaveLength(1);
|
|
});
|
|
|
|
test('(c) missing WORKFLOW_DISPATCH service yields a structured failure and no execution', async () => {
|
|
const workflowId = await createDispatchableWorkflow(h.workflow, 'Nested orphan');
|
|
h.services.delete(WORKFLOW_DISPATCH_SERVICE_TYPE);
|
|
|
|
const result = (await executeStepViaRegistry(
|
|
h,
|
|
{ kind: 'dispatch_workflow', workflowId },
|
|
makeStepArgs()
|
|
)) as { ok: boolean; error?: string };
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toBe('WORKFLOW_DISPATCH service not registered');
|
|
const { data: executions } = await h.workflow.listExecutions({
|
|
workflowId,
|
|
});
|
|
expect(executions).toHaveLength(0);
|
|
});
|
|
|
|
test('(d) unknown workflowId is a structured failure from the real dispatch, not a throw', async () => {
|
|
const result = (await executeStepViaRegistry(
|
|
h,
|
|
{ kind: 'dispatch_workflow', workflowId: 'no-such-workflow' },
|
|
makeStepArgs()
|
|
)) as WorkflowDispatchResult;
|
|
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toMatch(/not found/i);
|
|
const { data: executions } = await h.workflow.listExecutions({
|
|
workflowId: 'no-such-workflow',
|
|
});
|
|
expect(executions).toHaveLength(0);
|
|
});
|
|
|
|
test('(e) a step without workflowId fails schema validation before any dispatch', async () => {
|
|
// The dispatcher parses against the contribution's schema before execute;
|
|
// a malformed step must be rejected there, never reaching the service.
|
|
await expect(
|
|
executeStepViaRegistry(h, { kind: 'dispatch_workflow' }, makeStepArgs())
|
|
).rejects.toThrow();
|
|
});
|
|
});
|