Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/capabilities-health-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@workflow/core': minor
'@workflow/world': minor
'workflow': minor
---

Export a Zod-backed World capabilities schema and include World capabilities in health-check responses.
65 changes: 53 additions & 12 deletions packages/core/src/runtime/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,37 @@ describe('healthCheck response parsing', () => {
expect(result.workflowCoreVersion).toBe('5.0.0-beta.7');
});

it('surfaces valid World capabilities', async () => {
const world = makeWorldWithResponse(
JSON.stringify({
healthy: true,
capabilities: {
hookRetention: { active: true },
futureCapability: true,
},
})
);

await expect(healthCheck(world, { timeout: 1000 })).resolves.toMatchObject({
healthy: true,
capabilities: { hookRetention: { active: true } },
});
});

it('rejects invalid World capabilities', async () => {
const world = makeWorldWithResponse(
JSON.stringify({
healthy: true,
capabilities: { hookRetention: { active: 'yes' } },
})
);

const result = await healthCheck(world, { timeout: 20 });

expect(result.healthy).toBe(false);
expect(result.error).toMatch(/timed out/);
});

it('omits workflowCoreVersion when the response does not include the field', async () => {
// Independent of specVersion — the field is omitted by any responder
// running an older `@workflow/core` that predates the addition of
Expand All @@ -259,9 +290,7 @@ describe('healthCheck response parsing', () => {
expect(result.workflowCoreVersion).toBeUndefined();
});

it('omits workflowCoreVersion when the field is the wrong type', async () => {
// Defensive: the parser only accepts strings. Anything else is dropped
// rather than surfaced as garbage.
it('rejects workflowCoreVersion with the wrong type', async () => {
const world = makeWorldWithResponse(
JSON.stringify({
healthy: true,
Expand All @@ -272,10 +301,10 @@ describe('healthCheck response parsing', () => {
})
);

const result = await healthCheck(world, { timeout: 1000 });
const result = await healthCheck(world, { timeout: 20 });

expect(result.healthy).toBe(true);
expect(result.workflowCoreVersion).toBeUndefined();
expect(result.healthy).toBe(false);
expect(result.error).toMatch(/timed out/);
});

it('surfaces hookResumeInputVersion from the target so the caller stamps the consumer value', async () => {
Expand Down Expand Up @@ -319,9 +348,7 @@ describe('healthCheck response parsing', () => {
expect(result.hookResumeInputVersion).toBeUndefined();
});

it('omits hookResumeInputVersion when the field is the wrong type', async () => {
// Defensive: only a number is accepted; anything else is dropped rather
// than surfaced as a bogus capability.
it('rejects hookResumeInputVersion with the wrong type', async () => {
const world = makeWorldWithResponse(
JSON.stringify({
healthy: true,
Expand All @@ -332,10 +359,10 @@ describe('healthCheck response parsing', () => {
})
);

const result = await healthCheck(world, { timeout: 1000 });
const result = await healthCheck(world, { timeout: 20 });

expect(result.healthy).toBe(true);
expect(result.hookResumeInputVersion).toBeUndefined();
expect(result.healthy).toBe(false);
expect(result.error).toMatch(/timed out/);
});

it('returns healthy with no fields for non-JSON plain-text responses', async () => {
Expand Down Expand Up @@ -1214,6 +1241,20 @@ describe('health check run public key', () => {
expect(writtenResponse(write).healthy).toBe(true);
});

it('returns the target World capabilities', async () => {
const { getWorldLazy } = await import('./get-world-lazy.js');
const { world, write } = responderWorld(undefined);
world.capabilities = { hookRetention: { active: true } };
vi.mocked(getWorldLazy).mockReturnValue(world as any);

await handleHealthCheckMessage({
__healthCheck: true,
correlationId: 'corr_capabilities',
});

expect(writtenResponse(write).capabilities).toEqual(world.capabilities);
});

it('omits the key when encryption is not configured', async () => {
const { getWorldLazy } = await import('./get-world-lazy.js');
const { world, write } = responderWorld(undefined);
Expand Down
102 changes: 27 additions & 75 deletions packages/core/src/runtime/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import {
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
ulidToDate,
WorldCapabilitiesSchema,
} from '@workflow/world';
import { monotonicFactory } from 'ulid';
import { z } from 'zod';
import { runtimeLogger } from '../logger.js';
import { bytesToBase64, deriveRunKeyPair } from '../sealed-box.js';
import {
Expand Down Expand Up @@ -76,47 +78,29 @@ function getHealthCheckStreamName(correlationId: string): string {
return `__health_check__${correlationId}`;
}

/**
* Result of a health check operation.
*/
export interface HealthCheckResult {
healthy: boolean;
export const HealthCheckResponseSchema = z.object({
healthy: z.boolean(),
/** Spec version of the responding deployment. */
specVersion: z.number().optional(),
/** `@workflow/core` version of the responding deployment. */
workflowCoreVersion: z.string().optional(),
/** The target run's X25519 public key, encoded as base64. */
encryptionPublicKey: z.string().optional(),
/** The responding deployment's hook-resume input protocol version. */
hookResumeInputVersion: z.number().optional(),
/** Optional features supported by the responding deployment's World. */
capabilities: WorldCapabilitiesSchema.optional(),
});

type HealthCheckResponse = z.infer<typeof HealthCheckResponseSchema>;

/** Result of a health check operation. */
export type HealthCheckResult = HealthCheckResponse & {
/** Error message if health check failed */
error?: string;
/** Latency if the health check was successful */
latencyMs?: number;
/** Spec version of the responding deployment */
specVersion?: number;
/**
* `@workflow/core` version of the responding deployment, used for
* capability detection (see `getRunCapabilities`). Omitted when the
* responding deployment did not provide the field as a string —
* for example, an older `@workflow/core` that predates this field,
* or a non-JSON plain-text health response.
*/
workflowCoreVersion?: string;
/**
* The target run's X25519 public key (base64), returned only when the probe
* carried a `runId` and the responding deployment has encryption enabled.
*
* Lets a cross-deployment `start()` seal the workflow arguments using a
* response it was already waiting on, instead of making a separate
* key-lookup request.
*/
encryptionPublicKey?: string;
/**
* The responding deployment's `HOOK_RESUME_INPUT_VERSION` — the protocol
* version at which the *consumer* (queue-message target) re-ensures the
* `hook_received` event from `hookInput` on replay. A cross-deployment
* `start()` stamps the *target's* value (not the caller's) into the new
* run's `executionContext.hookResumeInputVersion` so that `resumeHook()`
* only takes the parallel path when the deployment that will actually
* consume the queue message is known to honor `hookInput`. Omitted when the
* responding deployment predates this field (an older consumer that ignores
* `hookInput`), which fails the gate closed.
*/
hookResumeInputVersion?: number;
}
};

/**
* Checks if the given message is a health check payload.
Expand Down Expand Up @@ -194,6 +178,7 @@ export async function handleHealthCheckMessage(
// the *consumer's* hook-resume protocol version — exactly what a
// cross-deployment caller needs to gate its parallel resume path on.
hookResumeInputVersion: HOOK_RESUME_INPUT_VERSION,
capabilities: world.capabilities,
...(encryptionPublicKey ? { encryptionPublicKey } : {}),
timestamp: Date.now(),
});
Expand Down Expand Up @@ -301,12 +286,9 @@ async function readStreamWithTimeout(
* Parse and validate a health check response from stream chunks.
* Returns the parsed response or null if invalid.
*/
function parseHealthCheckResponse(chunks: Uint8Array[]): {
healthy: boolean;
specVersion?: number;
workflowCoreVersion?: string;
encryptionPublicKey?: string;
} | null {
function parseHealthCheckResponse(
chunks: Uint8Array[]
): HealthCheckResponse | null {
if (chunks.length === 0) return null;

const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
Expand All @@ -331,38 +313,8 @@ function parseHealthCheckResponse(chunks: Uint8Array[]): {
return null;
}

if (
typeof response !== 'object' ||
response === null ||
!('healthy' in response) ||
typeof (response as { healthy: unknown }).healthy !== 'boolean'
) {
return null;
}

const r = response as Record<string, unknown>;
const parsed: {
healthy: boolean;
specVersion?: number;
workflowCoreVersion?: string;
encryptionPublicKey?: string;
hookResumeInputVersion?: number;
} = {
healthy: r.healthy as boolean,
};
if (typeof r.specVersion === 'number') {
parsed.specVersion = r.specVersion;
}
if (typeof r.workflowCoreVersion === 'string') {
parsed.workflowCoreVersion = r.workflowCoreVersion;
}
if (typeof r.encryptionPublicKey === 'string') {
parsed.encryptionPublicKey = r.encryptionPublicKey;
}
if (typeof r.hookResumeInputVersion === 'number') {
parsed.hookResumeInputVersion = r.hookResumeInputVersion;
}
return parsed;
const result = HealthCheckResponseSchema.safeParse(response);
return result.success ? result.data : null;
}

export async function healthCheck(
Expand Down
43 changes: 43 additions & 0 deletions packages/world/src/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { z } from 'zod';

/**
* Optional features a World implementation supports. Missing capabilities are
* unsupported so runtime behavior always fails closed.
*/
export const WorldCapabilitiesSchema = z.object({
/**
* Supports `experimental_minRetention` for Hooks. Missing or inactive means
* the runtime rejects retained Hooks before registration.
*/
hookRetention: z.object({ active: z.boolean() }).optional(),

/**
* Enforces the event count and update-time preconditions on event creation.
* Worlds that accept but ignore either field must leave this unset.
*/
preconditionGuard: z.boolean().optional(),

/** Supports `maxConcurrency`-limited queue consumption. */
maxConcurrency: z.boolean().optional(),

/**
* Deduplicates concurrent `hook_received` writes sharing a
* `(runId, resumeId)` and returns the canonical event to every caller.
*/
hookResumeDedup: z.boolean().optional(),

/**
* Uses atomic, immutable deployments with strict run affinity. Worlds with
* synthetic or version-tagged deployment ids must leave this unset.
*/
deploymentAffinity: z.boolean().optional(),

/**
* Allocates dense, slot-numbered event IDs for new runs. Event creation
* advances past occupied slots and returns the skipped events to the writer.
* Existing runs keep their original event ID scheme.
*/
slotEventIds: z.boolean().optional(),
});

export type WorldCapabilities = z.infer<typeof WorldCapabilitiesSchema>;
2 changes: 2 additions & 0 deletions packages/world/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export {
validateAttributeKey,
validateAttributeValue,
} from './attributes.js';
export type * from './capabilities.js';
export { WorldCapabilitiesSchema } from './capabilities.js';
export {
_resetEnvWarnCacheForTests,
type EnvNumberOptions,
Expand Down
Loading
Loading