From 24e87bb03401c6def3c764a53743dff9de368c9c Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 15 Jul 2026 13:32:46 +0200 Subject: [PATCH 1/3] feat(events): add canonical event contracts --- packages/events/README.md | 15 +++ packages/events/package.json | 39 ++++++ packages/events/src/index.ts | 37 ++++++ packages/events/src/parse.ts | 136 ++++++++++++++++++++ packages/events/src/redact.ts | 38 ++++++ packages/events/src/registry.test.ts | 77 ++++++++++++ packages/events/src/registry.ts | 177 +++++++++++++++++++++++++++ packages/events/src/schemas.ts | 90 ++++++++++++++ packages/events/src/types.ts | 105 ++++++++++++++++ packages/events/src/validate.ts | 132 ++++++++++++++++++++ packages/events/tsconfig.json | 8 ++ packages/runtime/package.json | 1 + packages/runtime/src/shim.ts | 90 ++------------ pnpm-lock.yaml | 5 + 14 files changed, 868 insertions(+), 82 deletions(-) create mode 100644 packages/events/README.md create mode 100644 packages/events/package.json create mode 100644 packages/events/src/index.ts create mode 100644 packages/events/src/parse.ts create mode 100644 packages/events/src/redact.ts create mode 100644 packages/events/src/registry.test.ts create mode 100644 packages/events/src/registry.ts create mode 100644 packages/events/src/schemas.ts create mode 100644 packages/events/src/types.ts create mode 100644 packages/events/src/validate.ts create mode 100644 packages/events/tsconfig.json diff --git a/packages/events/README.md b/packages/events/README.md new file mode 100644 index 00000000..c3a23baf --- /dev/null +++ b/packages/events/README.md @@ -0,0 +1,15 @@ +# @agentworkforce/events + +Canonical, transport-independent Event wire contracts for AgentWorkforce. + +```ts +import { decodeEventFrame, getEventContract } from '@agentworkforce/events'; + +const decoded = decodeEventFrame(input); +const contract = getEventContract(decoded.frame.type, decoded.frame.contractVersion); +``` + +`decodeEventFrame` accepts the canonical `EventFrameV1` and the legacy +Workforce gateway envelope. Legacy decoding is explicit in the returned +`compatibility` metadata. Known versioned frames reject unknown top-level +fields; forward-compatible data belongs in `extensions`. diff --git a/packages/events/package.json b/packages/events/package.json new file mode 100644 index 00000000..f0735456 --- /dev/null +++ b/packages/events/package.json @@ -0,0 +1,39 @@ +{ + "name": "@agentworkforce/events", + "version": "0.1.0", + "private": false, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schemas": { + "types": "./dist/schemas.d.ts", + "default": "./dist/schemas.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/workforce", + "directory": "packages/events" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsc -p tsconfig.json && node --test dist/**/*.test.js dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts new file mode 100644 index 00000000..77257511 --- /dev/null +++ b/packages/events/src/index.ts @@ -0,0 +1,37 @@ +export type { + DecodedEventFrame, + EventCompatibilityInfo, + EventContract, + EventFrameV1, + EventSummary, + JsonSchema, + LegacyRawGatewayEnvelope, + ValidationIssue, + ValidationResult +} from './types.js'; +export { + EVENT_FRAME_V1_FIELDS, + EVENT_FRAME_V1_SCHEMA, + EVENT_SUMMARY_SCHEMA +} from './schemas.js'; +export { + EVENT_CONTRACTS, + EVENT_CONTRACT_JSON_SCHEMAS, + getEventContract, + requireEventContract +} from './registry.js'; +export { + decodeEventFrame, + decodeLegacyRawGatewayEnvelope, + LEGACY_EVENT_TYPE_ALIASES, + LEGACY_RAW_GATEWAY_ENVELOPE_FIELDS, + parseEventFrame, + safeParseEventFrame, + validateRegisteredEventFrame +} from './parse.js'; +export { redactEventValue, type RedactOptions } from './redact.js'; +export { + assertEventFrameV1, + EventValidationError, + validateEventFrameV1 +} from './validate.js'; diff --git a/packages/events/src/parse.ts b/packages/events/src/parse.ts new file mode 100644 index 00000000..bcfb59de --- /dev/null +++ b/packages/events/src/parse.ts @@ -0,0 +1,136 @@ +import { getEventContract } from './registry.js'; +import type { DecodedEventFrame, EventCompatibilityInfo, EventFrameV1, LegacyRawGatewayEnvelope, ValidationResult } from './types.js'; +import { EventValidationError, assertEventFrameV1, isRecord, validateEventFrameV1 } from './validate.js'; + +export const LEGACY_RAW_GATEWAY_ENVELOPE_FIELDS = Object.freeze([ + 'id', 'workspace', 'type', 'occurredAt', 'attempt', 'resource', 'summary', 'expand', + 'digest', 'name', 'cron', 'provider', 'eventType', 'deliveryId', 'paths', + 'resumeContext', 'harnessSession', 'channel', 'messageId', 'threadId' +] as const); +const LEGACY_FIELDS = new Set(LEGACY_RAW_GATEWAY_ENVELOPE_FIELDS); + +export const LEGACY_EVENT_TYPE_ALIASES = Object.freeze({ + 'github.issue.labeled': 'github.issues.labeled', + 'github.pull_request.open': 'github.pull_request.opened', + 'slack.message': 'slack.message.created', + 'linear.issue.create': 'linear.issue.created', + user_reply: 'relaycast.message' +} as const); + +export function parseEventFrame(input: unknown): EventFrameV1 { + assertEventFrameV1(input); + const contract = getEventContract(input.type, input.contractVersion); + if (!contract) throw new EventValidationError([{ path: '$.type', message: `unknown Event contract ${input.type}@${input.contractVersion}`, keyword: 'contract' }]); + const result = contract.validate(input); + if (!result.valid) throw new EventValidationError(result.errors); + return input; +} + +export function safeParseEventFrame(input: unknown): { success: true; data: EventFrameV1 } | { success: false; error: EventValidationError } { + try { + return { success: true, data: parseEventFrame(input) }; + } catch (error) { + return { success: false, error: error instanceof EventValidationError ? error : new EventValidationError([{ path: '$', message: error instanceof Error ? error.message : String(error) }]) }; + } +} + +export function validateRegisteredEventFrame(input: unknown): ValidationResult { + const base = validateEventFrameV1(input); + if (!base.valid || !isRecord(input)) return base; + const contract = getEventContract(String(input.type), Number(input.contractVersion)); + if (!contract) return { valid: false, errors: [{ path: '$.type', message: `unknown Event contract ${String(input.type)}@${String(input.contractVersion)}`, keyword: 'contract' }] }; + return contract.validate(input as unknown as EventFrameV1); +} + +export function decodeEventFrame(input: unknown): DecodedEventFrame { + if (isRecord(input) && 'schemaVersion' in input) return { frame: parseEventFrame(input) }; + return decodeLegacyRawGatewayEnvelope(input); +} + +export function decodeLegacyRawGatewayEnvelope(input: unknown): DecodedEventFrame { + if (!isRecord(input)) throw new EventValidationError([{ path: '$', message: 'legacy gateway envelope must be an object', keyword: 'type' }]); + const legacy = input as LegacyRawGatewayEnvelope; + requireString(legacy.id, '$.id'); + requireString(legacy.workspace, '$.workspace'); + requireString(legacy.type, '$.type'); + requireString(legacy.occurredAt, '$.occurredAt'); + + const originalType = legacy.type; + const aliased = (LEGACY_EVENT_TYPE_ALIASES as Readonly>)[originalType]; + const type = aliased ?? originalType; + const contract = getEventContract(type, 1); + if (!contract) throw new EventValidationError([{ path: '$.type', message: `unknown legacy Event contract ${type}@1`, keyword: 'contract' }]); + + const provider = nonEmpty(legacy.provider) ?? contract.provider; + const canonicalResource = isCanonicalResource(legacy.resource) ? legacy.resource : undefined; + const messageId = nonEmpty(legacy.messageId) ?? nestedString(legacy.resource, 'messageId') ?? nestedString(legacy.resource, 'message', 'id'); + const channel = nonEmpty(legacy.channel) ?? nestedString(legacy.resource, 'channel'); + const threadId = nonEmpty(legacy.threadId) ?? nestedString(legacy.resource, 'threadId') ?? nestedString(legacy.resumeContext, 'threadId'); + const resourceId = canonicalResource?.id ?? messageId ?? nonEmpty(legacy.deliveryId) ?? (type === 'cron.tick' ? nonEmpty(legacy.name) : undefined) ?? legacy.id; + const path = canonicalResource?.path ?? firstPath(legacy.paths) ?? deriveLegacyPath(type, provider, contract.resourceKind, resourceId, channel); + const preserved = Object.keys(input).filter((key) => !LEGACY_FIELDS.has(key)); + const aliasesApplied = aliased ? [`${originalType} -> ${type}`] : []; + const compatibility: EventCompatibilityInfo = { + source: 'legacy-raw-gateway-envelope', + originalType, + aliasesApplied, + preservedFields: preserved + }; + const legacyExtensions: Record = {}; + for (const key of preserved) legacyExtensions[key] = input[key]; + for (const key of ['expand', 'resumeContext', 'harnessSession', 'eventType'] as const) { + if (legacy[key] !== undefined) legacyExtensions[key] = legacy[key]; + } + legacyExtensions.compatibility = compatibility; + + const frame: EventFrameV1 = { + schemaVersion: 1, + id: legacy.id, + workspace: legacy.workspace, + type, + contractVersion: 1, + occurredAt: legacy.occurredAt, + attempt: Number.isInteger(legacy.attempt) && (legacy.attempt as number) > 0 ? legacy.attempt as number : 1, + resource: canonicalResource ?? { path, kind: contract.resourceKind, id: resourceId, provider }, + summary: isRecord(legacy.summary) ? legacy.summary : {}, + ...(legacy.deliveryId ? { delivery: { id: legacy.deliveryId } } : {}), + ...(!canonicalResource && legacy.resource !== undefined ? { payload: legacy.resource } : {}), + ...(Array.isArray(legacy.paths) ? { paths: legacy.paths.filter((item): item is string => typeof item === 'string') } : {}), + ...(typeof legacy.digest === 'string' ? { digest: legacy.digest } : {}), + ...(type === 'cron.tick' ? { schedule: { name: nonEmpty(legacy.name) ?? resourceId, ...(nonEmpty(legacy.cron) ? { cron: legacy.cron } : {}) } } : {}), + ...((channel || messageId || threadId) ? { message: { ...(channel ? { channel } : {}), ...(messageId ? { messageId } : {}), ...(threadId ? { threadId } : {}) } } : {}), + extensions: legacyExtensions + }; + return { frame: parseEventFrame(frame), compatibility }; +} + +function isCanonicalResource(value: unknown): value is EventFrameV1['resource'] { + return isRecord(value) && typeof value.path === 'string' && typeof value.kind === 'string' && typeof value.id === 'string' && typeof value.provider === 'string'; +} + +function deriveLegacyPath(type: string, provider: string, kind: string, id: string, channel?: string): string { + if (type === 'cron.tick') return `/cron/schedules/${encodeURIComponent(id)}`; + if (type === 'relaycast.message') return `/relaycast/${encodeURIComponent(channel ?? 'dm')}/messages/${encodeURIComponent(id)}`; + return `/${encodeURIComponent(provider)}/${kind.split('.').slice(1).map(encodeURIComponent).join('/')}/${encodeURIComponent(id)}`; +} + +function firstPath(value: unknown): string | undefined { + return Array.isArray(value) ? value.find((item): item is string => typeof item === 'string') : undefined; +} + +function nestedString(value: unknown, ...path: string[]): string | undefined { + let cursor: unknown = value; + for (const part of path) { + if (!isRecord(cursor)) return undefined; + cursor = cursor[part]; + } + return nonEmpty(cursor); +} + +function nonEmpty(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function requireString(value: unknown, path: string): asserts value is string { + if (!nonEmpty(value)) throw new EventValidationError([{ path, message: 'must be a non-empty string', keyword: 'minLength' }]); +} diff --git a/packages/events/src/redact.ts b/packages/events/src/redact.ts new file mode 100644 index 00000000..54628f6e --- /dev/null +++ b/packages/events/src/redact.ts @@ -0,0 +1,38 @@ +const REDACTED = '[REDACTED]'; +const SENSITIVE_KEY = /(?:authorization|cookie|password|passwd|secret|token|api[-_]?key|private[-_]?key)/i; +const BEARER = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*/gi; + +export interface RedactOptions { + additionalSensitiveKeys?: readonly (string | RegExp)[]; +} + +/** Clone and redact a JSON-like value without mutating the provider payload. */ +export function redactEventValue(value: T, options: RedactOptions = {}): T { + return visit(value, options, new WeakMap()) as T; +} + +function visit(value: unknown, options: RedactOptions, seen: WeakMap): unknown { + if (typeof value === 'string') return value.replace(BEARER, REDACTED); + if (value === null || typeof value !== 'object') return value; + const cached = seen.get(value); + if (cached !== undefined) return cached; + if (Array.isArray(value)) { + const out: unknown[] = []; + seen.set(value, out); + for (const item of value) out.push(visit(item, options, seen)); + return out; + } + const out: Record = {}; + seen.set(value, out); + for (const [key, child] of Object.entries(value)) { + out[key] = isSensitiveKey(key, options.additionalSensitiveKeys) ? REDACTED : visit(child, options, seen); + } + return out; +} + +function isSensitiveKey(key: string, additional: RedactOptions['additionalSensitiveKeys']): boolean { + if (SENSITIVE_KEY.test(key)) return true; + return additional?.some((matcher) => + typeof matcher === 'string' ? matcher.toLowerCase() === key.toLowerCase() : matcher.test(key) + ) ?? false; +} diff --git a/packages/events/src/registry.test.ts b/packages/events/src/registry.test.ts new file mode 100644 index 00000000..1074323f --- /dev/null +++ b/packages/events/src/registry.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + decodeEventFrame, + EVENT_CONTRACTS, + EVENT_CONTRACT_JSON_SCHEMAS, + parseEventFrame, + redactEventValue, + safeParseEventFrame +} from './index.js'; + +test('every registry entry exports schema and validating example fixtures', () => { + assert.deepEqual(EVENT_CONTRACTS.map((entry) => entry.id), [ + 'cron.tick', + 'github.issues.labeled', + 'github.pull_request.opened', + 'slack.message.created', + 'linear.issue.created', + 'relaycast.message' + ]); + for (const contract of EVENT_CONTRACTS) { + assert.ok(EVENT_CONTRACT_JSON_SCHEMAS[`${contract.id}@${contract.version}`]); + assert.ok(contract.fixtureExamples.length > 0); + for (const fixture of contract.fixtureExamples) { + assert.equal(parseEventFrame(fixture), fixture); + assert.deepEqual(contract.validate(fixture), { valid: true, errors: [] }); + } + } +}); + +test('known schema versions reject unknown top-level fields and preserve extensions', () => { + const fixture = EVENT_CONTRACTS[1].fixtureExamples[0]; + assert.equal(safeParseEventFrame({ ...fixture, surprise: true }).success, false); + const extended = { ...fixture, extensions: { futureCapability: { enabled: true } } }; + assert.deepEqual(parseEventFrame(extended).extensions, { futureCapability: { enabled: true } }); +}); + +test('legacy gateway envelopes decode with visible compatibility metadata', () => { + const decoded = decodeEventFrame({ + id: 'evt_legacy', + workspace: 'ws_1', + type: 'github.issue.labeled', + occurredAt: '2026-07-15T09:00:00.000Z', + attempt: 2, + provider: 'github', + deliveryId: 'delivery_1', + paths: ['/github/repos/acme/app/issues/42.json'], + resource: { action: 'labeled', issue: { number: 42 } }, + summary: { title: 'Issue labeled' }, + resumeContext: { phase: 2 }, + vendorFutureField: { retained: true } + }); + assert.equal(decoded.frame.type, 'github.issues.labeled'); + assert.equal(decoded.frame.contractVersion, 1); + assert.deepEqual(decoded.frame.payload, { action: 'labeled', issue: { number: 42 } }); + assert.equal(decoded.compatibility?.source, 'legacy-raw-gateway-envelope'); + assert.deepEqual(decoded.compatibility?.aliasesApplied, ['github.issue.labeled -> github.issues.labeled']); + assert.deepEqual(decoded.frame.extensions?.vendorFutureField, { retained: true }); + assert.deepEqual(decoded.frame.extensions?.resumeContext, { phase: 2 }); +}); + +test('legacy cron and relaycast envelopes retain their specialized coordinates', () => { + const cron = decodeEventFrame({ id: 'cron_1', workspace: 'ws', type: 'cron.tick', occurredAt: '2026-07-15T09:00:00Z', name: 'scan', cron: '0 9 * * *' }); + assert.deepEqual(cron.frame.schedule, { name: 'scan', cron: '0 9 * * *' }); + assert.equal(cron.frame.resource.id, 'scan'); + + const relay = decodeEventFrame({ id: 'msg_evt', workspace: 'ws', type: 'relaycast.message', occurredAt: '2026-07-15T09:00:00Z', channel: 'general', messageId: 'm1', threadId: 't1', resource: { text: 'hello' } }); + assert.deepEqual(relay.frame.message, { channel: 'general', messageId: 'm1', threadId: 't1' }); + assert.equal(relay.frame.resource.kind, 'relaycast.message'); +}); + +test('redaction is recursive, immutable, and handles bearer strings', () => { + const input = { token: 'secret', nested: [{ authorization: 'Bearer abc.def', text: 'Bearer abc.def' }] }; + const output = redactEventValue(input); + assert.deepEqual(output, { token: '[REDACTED]', nested: [{ authorization: '[REDACTED]', text: '[REDACTED]' }] }); + assert.equal(input.token, 'secret'); +}); diff --git a/packages/events/src/registry.ts b/packages/events/src/registry.ts new file mode 100644 index 00000000..7ca21fb4 --- /dev/null +++ b/packages/events/src/registry.ts @@ -0,0 +1,177 @@ +import { redactEventValue } from './redact.js'; +import { EVENT_FRAME_V1_SCHEMA, EVENT_SUMMARY_SCHEMA } from './schemas.js'; +import type { EventContract, EventFrameV1, JsonSchema, ValidationIssue, ValidationResult } from './types.js'; +import { validateEventFrameV1 } from './validate.js'; + +const OPEN_PAYLOAD_SCHEMA = { type: 'object', additionalProperties: true } as const; + +function example( + id: string, + provider: string, + trigger: string, + resourceKind: string, + overrides: Partial = {} +): EventFrameV1 { + return { + schemaVersion: 1, + id: `evt_${id.replaceAll('.', '_')}`, + workspace: 'ws_example', + type: id, + contractVersion: 1, + occurredAt: '2026-07-15T09:00:00.000Z', + attempt: 1, + resource: { + path: `/${provider}/${resourceKind.slice(provider.length + 1).replaceAll('.', 's/')}/example`, + kind: resourceKind, + id: 'example', + provider + }, + summary: { title: `${id} example` }, + payload: { action: trigger.split('.').at(-1) }, + ...overrides + }; +} + +function contract(config: { + id: string; + provider: string; + trigger: string; + resourceKind: string; + fixture: EventFrameV1; + payloadSchema?: JsonSchema; +}): EventContract { + return Object.freeze({ + id: config.id, + version: 1, + provider: config.provider, + trigger: config.trigger, + resourceKind: config.resourceKind, + summarySchema: EVENT_SUMMARY_SCHEMA, + payloadSchema: config.payloadSchema ?? OPEN_PAYLOAD_SCHEMA, + fixtureExamples: Object.freeze([config.fixture]), + redact: (payload: TPayload) => redactEventValue(payload), + validate(frame: EventFrameV1): ValidationResult { + const base = validateEventFrameV1(frame); + if (!base.valid) return base; + const errors: ValidationIssue[] = []; + if (frame.type !== config.id) errors.push({ path: '$.type', message: `must equal ${config.id}`, keyword: 'const' }); + if (frame.contractVersion !== 1) errors.push({ path: '$.contractVersion', message: 'must equal 1', keyword: 'const' }); + if (frame.resource.provider !== config.provider) errors.push({ path: '$.resource.provider', message: `must equal ${config.provider}`, keyword: 'const' }); + if (frame.resource.kind !== config.resourceKind) errors.push({ path: '$.resource.kind', message: `must equal ${config.resourceKind}`, keyword: 'const' }); + return errors.length === 0 ? { valid: true, errors: [] } : { valid: false, errors }; + } + }); +} + +export const EVENT_CONTRACTS = Object.freeze([ + contract({ + id: 'cron.tick', + provider: 'cron', + trigger: 'tick', + resourceKind: 'cron.schedule', + fixture: example('cron.tick', 'cron', 'tick', 'cron.schedule', { + resource: { path: '/cron/schedules/scan', kind: 'cron.schedule', id: 'scan', provider: 'cron' }, + summary: { title: 'Scheduled scan' }, + schedule: { name: 'scan', cron: '0 9 * * *', timezone: 'UTC', scheduledFor: '2026-07-15T09:00:00.000Z' }, + payload: undefined + }) + }), + contract({ + id: 'github.issues.labeled', + provider: 'github', + trigger: 'issues.labeled', + resourceKind: 'github.issue', + fixture: example('github.issues.labeled', 'github', 'issues.labeled', 'github.issue', { + resource: { path: '/github/repos/acme/app/issues/42.json', kind: 'github.issue', id: '42', provider: 'github' }, + summary: { title: 'Issue #42 labeled bug', actor: 'octocat' }, + payload: { action: 'labeled', issue: { number: 42 }, label: { name: 'bug' } } + }) + }), + contract({ + id: 'github.pull_request.opened', + provider: 'github', + trigger: 'pull_request.opened', + resourceKind: 'github.pull_request', + fixture: example('github.pull_request.opened', 'github', 'pull_request.opened', 'github.pull_request', { + resource: { path: '/github/repos/acme/app/pulls/7.json', kind: 'github.pull_request', id: '7', provider: 'github' }, + summary: { title: 'PR #7 opened', actor: 'octocat' }, + payload: { action: 'opened', pull_request: { number: 7 } } + }) + }), + contract({ + id: 'slack.message.created', + provider: 'slack', + trigger: 'message.created', + resourceKind: 'slack.message', + fixture: example('slack.message.created', 'slack', 'message.created', 'slack.message', { + resource: { path: '/slack/channels/C123/messages/171.json', kind: 'slack.message', id: '171', provider: 'slack' }, + summary: { title: 'New Slack message', actor: 'U123' }, + message: { channel: 'C123', messageId: '171' }, + payload: { channel: 'C123', user: 'U123', text: 'hello' } + }) + }), + contract({ + id: 'linear.issue.created', + provider: 'linear', + trigger: 'issue.created', + resourceKind: 'linear.issue', + fixture: example('linear.issue.created', 'linear', 'issue.created', 'linear.issue', { + resource: { path: '/linear/issues/ENG-1.json', kind: 'linear.issue', id: 'ENG-1', provider: 'linear' }, + summary: { title: 'ENG-1 created', actor: 'user@example.com' }, + payload: { action: 'create', issue: { identifier: 'ENG-1', title: 'Ship it' } } + }) + }), + contract({ + id: 'relaycast.message', + provider: 'relaycast', + trigger: 'message', + resourceKind: 'relaycast.message', + fixture: example('relaycast.message', 'relaycast', 'message', 'relaycast.message', { + resource: { path: '/relaycast/general/messages/msg_1', kind: 'relaycast.message', id: 'msg_1', provider: 'relaycast' }, + summary: { title: 'Can you take a look?' }, + message: { channel: 'general', messageId: 'msg_1', threadId: 'thread_1' }, + payload: { text: 'Can you take a look?' } + }) + }) +] as const satisfies readonly EventContract[]); + +const BY_KEY = new Map(EVENT_CONTRACTS.map((entry) => [`${entry.id}@${entry.version}`, entry])); + +export function getEventContract(id: string, version = 1): EventContract | undefined { + return BY_KEY.get(`${id}@${version}`); +} + +export function requireEventContract(id: string, version = 1): EventContract { + const entry = getEventContract(id, version); + if (!entry) throw new Error(`Unknown Event contract: ${id}@${version}`); + return entry; +} + +export const EVENT_CONTRACT_JSON_SCHEMAS = Object.freeze(Object.fromEntries( + EVENT_CONTRACTS.map((entry) => [ + `${entry.id}@${entry.version}`, + { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: `https://agentworkforce.dev/schemas/events/${entry.id}/v${entry.version}.json`, + allOf: [ + EVENT_FRAME_V1_SCHEMA, + { + type: 'object', + properties: { + type: { const: entry.id }, + contractVersion: { const: entry.version }, + resource: { + type: 'object', + properties: { + provider: { const: entry.provider }, + kind: { const: entry.resourceKind } + } + }, + summary: entry.summarySchema, + ...(entry.payloadSchema ? { payload: entry.payloadSchema } : {}) + } + } + ] + } satisfies JsonSchema + ]) +)); diff --git a/packages/events/src/schemas.ts b/packages/events/src/schemas.ts new file mode 100644 index 00000000..d9c9776a --- /dev/null +++ b/packages/events/src/schemas.ts @@ -0,0 +1,90 @@ +import type { JsonSchema } from './types.js'; + +const stringSchema = { type: 'string' } as const; +const nonEmptyStringSchema = { type: 'string', minLength: 1 } as const; + +export const EVENT_SUMMARY_SCHEMA = { + $id: 'https://agentworkforce.dev/schemas/events/event-summary.json', + type: 'object', + properties: { + title: stringSchema, + description: stringSchema, + actor: stringSchema + }, + additionalProperties: true +} as const satisfies JsonSchema; + +/** Canonical JSON Schema export used by Cloud, fixtures, and replay tooling. */ +export const EVENT_FRAME_V1_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://agentworkforce.dev/schemas/events/event-frame-v1.json', + title: 'EventFrameV1', + type: 'object', + additionalProperties: false, + required: [ + 'schemaVersion', + 'id', + 'workspace', + 'type', + 'contractVersion', + 'occurredAt', + 'attempt', + 'resource', + 'summary' + ], + properties: { + schemaVersion: { const: 1 }, + id: nonEmptyStringSchema, + workspace: nonEmptyStringSchema, + type: nonEmptyStringSchema, + contractVersion: { type: 'integer', minimum: 1 }, + occurredAt: { type: 'string', format: 'date-time' }, + attempt: { type: 'integer', minimum: 1 }, + resource: { + type: 'object', + additionalProperties: false, + required: ['path', 'kind', 'id', 'provider'], + properties: { + path: stringSchema, + kind: nonEmptyStringSchema, + id: nonEmptyStringSchema, + provider: nonEmptyStringSchema + } + }, + summary: EVENT_SUMMARY_SCHEMA, + delivery: { + type: 'object', + additionalProperties: false, + properties: { id: stringSchema, dedupeKey: stringSchema } + }, + payload: {}, + paths: { type: 'array', items: stringSchema }, + digest: stringSchema, + schedule: { + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: nonEmptyStringSchema, + cron: stringSchema, + timezone: stringSchema, + scheduledFor: { type: 'string', format: 'date-time' } + } + }, + message: { + type: 'object', + additionalProperties: false, + properties: { + channel: stringSchema, + messageId: stringSchema, + threadId: stringSchema + } + }, + extensions: { type: 'object', additionalProperties: true } + } +} as const satisfies JsonSchema; + +/** Derived from the schema so parsers and exported schema cannot drift. */ +export const EVENT_FRAME_V1_FIELDS = Object.freeze( + Object.keys(EVENT_FRAME_V1_SCHEMA.properties) +) as readonly (keyof typeof EVENT_FRAME_V1_SCHEMA.properties)[]; diff --git a/packages/events/src/types.ts b/packages/events/src/types.ts new file mode 100644 index 00000000..938315a7 --- /dev/null +++ b/packages/events/src/types.ts @@ -0,0 +1,105 @@ +export type JsonSchema = Readonly>; + +/** Small, display-safe description of an Event. Provider-specific detail lives in payload. */ +export interface EventSummary { + title?: string; + description?: string; + actor?: string; + [key: string]: unknown; +} + +export interface EventFrameV1 { + schemaVersion: 1; + id: string; + workspace: string; + type: string; + contractVersion: number; + occurredAt: string; + attempt: number; + resource: { + path: string; + kind: string; + id: string; + provider: string; + }; + summary: EventSummary; + delivery?: { + id?: string; + dedupeKey?: string; + }; + payload?: unknown; + paths?: string[]; + digest?: string; + schedule?: { + name: string; + cron?: string; + timezone?: string; + scheduledFor?: string; + }; + message?: { + channel?: string; + messageId?: string; + threadId?: string; + }; + extensions?: Record; +} + +export interface ValidationIssue { + path: string; + message: string; + keyword?: string; +} + +export type ValidationResult = + | { valid: true; errors: readonly [] } + | { valid: false; errors: readonly ValidationIssue[] }; + +export interface EventContract { + id: string; + version: number; + provider: string; + trigger: string; + resourceKind: string; + summarySchema: JsonSchema; + payloadSchema?: JsonSchema; + fixtureExamples: readonly EventFrameV1[]; + redact(payload: TPayload): TPayload; + validate(frame: EventFrameV1): ValidationResult; +} + +/** The pre-EventFrame gateway shape retained only as an input compatibility contract. */ +export interface LegacyRawGatewayEnvelope { + id: string; + workspace: string; + type: string; + occurredAt: string; + attempt?: number; + resource?: unknown; + summary?: Record; + expand?: unknown; + digest?: string; + name?: string; + cron?: string; + provider?: string; + eventType?: string; + deliveryId?: string; + paths?: string[]; + resumeContext?: unknown; + harnessSession?: unknown; + channel?: string; + messageId?: string; + threadId?: string; + [key: string]: unknown; +} + +export interface EventCompatibilityInfo { + source: 'legacy-raw-gateway-envelope'; + originalType: string; + aliasesApplied: readonly string[]; + preservedFields: readonly string[]; +} + +export interface DecodedEventFrame { + frame: EventFrameV1; + compatibility?: EventCompatibilityInfo; +} diff --git a/packages/events/src/validate.ts b/packages/events/src/validate.ts new file mode 100644 index 00000000..85694752 --- /dev/null +++ b/packages/events/src/validate.ts @@ -0,0 +1,132 @@ +import { EVENT_FRAME_V1_FIELDS } from './schemas.js'; +import type { EventFrameV1, ValidationIssue, ValidationResult } from './types.js'; + +const TOP_LEVEL_FIELDS = new Set(EVENT_FRAME_V1_FIELDS); + +export class EventValidationError extends Error { + readonly issues: readonly ValidationIssue[]; + + constructor(issues: readonly ValidationIssue[]) { + super(issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')); + this.name = 'EventValidationError'; + this.issues = issues; + } +} + +export function validateEventFrameV1(value: unknown): ValidationResult { + const errors: ValidationIssue[] = []; + if (!isRecord(value)) { + return invalid([{ path: '$', message: 'must be an object', keyword: 'type' }]); + } + + for (const key of Object.keys(value)) { + if (!TOP_LEVEL_FIELDS.has(key)) { + errors.push({ path: `$.${key}`, message: 'is not a known EventFrameV1 field; place extension data under $.extensions', keyword: 'additionalProperties' }); + } + } + + if (value.schemaVersion !== 1) error(errors, '$.schemaVersion', 'must equal 1', 'const'); + requiredString(value.id, '$.id', errors); + requiredString(value.workspace, '$.workspace', errors); + requiredString(value.type, '$.type', errors); + positiveInteger(value.contractVersion, '$.contractVersion', errors); + requiredString(value.occurredAt, '$.occurredAt', errors); + if (typeof value.occurredAt === 'string' && Number.isNaN(Date.parse(value.occurredAt))) { + error(errors, '$.occurredAt', 'must be an ISO-8601 date-time', 'format'); + } + positiveInteger(value.attempt, '$.attempt', errors); + + if (!isRecord(value.resource)) { + error(errors, '$.resource', 'must be an object', 'type'); + } else { + exactKeys(value.resource, new Set(['path', 'kind', 'id', 'provider']), '$.resource', errors); + string(value.resource.path, '$.resource.path', errors); + requiredString(value.resource.kind, '$.resource.kind', errors); + requiredString(value.resource.id, '$.resource.id', errors); + requiredString(value.resource.provider, '$.resource.provider', errors); + } + + if (!isRecord(value.summary)) error(errors, '$.summary', 'must be an object', 'type'); + optionalRecord(value.delivery, '$.delivery', errors, new Set(['id', 'dedupeKey'])); + if (isRecord(value.delivery)) { + optionalString(value.delivery.id, '$.delivery.id', errors); + optionalString(value.delivery.dedupeKey, '$.delivery.dedupeKey', errors); + } + optionalStringArray(value.paths, '$.paths', errors); + optionalString(value.digest, '$.digest', errors); + optionalRecord(value.schedule, '$.schedule', errors, new Set(['name', 'cron', 'timezone', 'scheduledFor'])); + if (isRecord(value.schedule)) { + requiredString(value.schedule.name, '$.schedule.name', errors); + optionalString(value.schedule.cron, '$.schedule.cron', errors); + optionalString(value.schedule.timezone, '$.schedule.timezone', errors); + optionalString(value.schedule.scheduledFor, '$.schedule.scheduledFor', errors); + if (typeof value.schedule.scheduledFor === 'string' && Number.isNaN(Date.parse(value.schedule.scheduledFor))) { + error(errors, '$.schedule.scheduledFor', 'must be an ISO-8601 date-time', 'format'); + } + } + optionalRecord(value.message, '$.message', errors, new Set(['channel', 'messageId', 'threadId'])); + if (isRecord(value.message)) { + optionalString(value.message.channel, '$.message.channel', errors); + optionalString(value.message.messageId, '$.message.messageId', errors); + optionalString(value.message.threadId, '$.message.threadId', errors); + } + if (value.extensions !== undefined && !isRecord(value.extensions)) { + error(errors, '$.extensions', 'must be an object', 'type'); + } + + return errors.length === 0 ? { valid: true, errors: [] } : invalid(errors); +} + +function optionalRecord(value: unknown, path: string, errors: ValidationIssue[], keys: Set): void { + if (value === undefined) return; + if (!isRecord(value)) { + error(errors, path, 'must be an object', 'type'); + return; + } + exactKeys(value, keys, path, errors); +} + +function exactKeys(value: Record, keys: Set, path: string, errors: ValidationIssue[]): void { + for (const key of Object.keys(value)) { + if (!keys.has(key)) error(errors, `${path}.${key}`, 'is not a known field', 'additionalProperties'); + } +} + +function optionalStringArray(value: unknown, path: string, errors: ValidationIssue[]): void { + if (value === undefined) return; + if (!Array.isArray(value)) return error(errors, path, 'must be an array', 'type'); + value.forEach((item, index) => string(item, `${path}[${index}]`, errors)); +} + +function positiveInteger(value: unknown, path: string, errors: ValidationIssue[]): void { + if (!Number.isInteger(value) || (value as number) < 1) error(errors, path, 'must be a positive integer', 'minimum'); +} + +function optionalString(value: unknown, path: string, errors: ValidationIssue[]): void { + if (value !== undefined) string(value, path, errors); +} + +function requiredString(value: unknown, path: string, errors: ValidationIssue[]): void { + if (typeof value !== 'string' || value.length === 0) error(errors, path, 'must be a non-empty string', 'minLength'); +} + +function string(value: unknown, path: string, errors: ValidationIssue[]): void { + if (typeof value !== 'string') error(errors, path, 'must be a string', 'type'); +} + +function error(errors: ValidationIssue[], path: string, message: string, keyword: string): void { + errors.push({ path, message, keyword }); +} + +function invalid(errors: ValidationIssue[]): ValidationResult { + return { valid: false, errors }; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function assertEventFrameV1(value: unknown): asserts value is EventFrameV1 { + const result = validateEventFrameV1(value); + if (!result.valid) throw new EventValidationError(result.errors); +} diff --git a/packages/events/tsconfig.json b/packages/events/tsconfig.json new file mode 100644 index 00000000..df59da57 --- /dev/null +++ b/packages/events/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 23cb812b..c70ff839 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -55,6 +55,7 @@ "dependencies": { "@agent-assistant/proactive": "^0.4.32", "@agent-relay/events": "^6.3.3", + "@agentworkforce/events": "workspace:*", "@agentworkforce/persona-kit": "workspace:*", "@relayfile/adapter-core": "^0.5.1", "agent-trajectories": "^0.5.3" diff --git a/packages/runtime/src/shim.ts b/packages/runtime/src/shim.ts index 9f1a5e62..abab2a10 100644 --- a/packages/runtime/src/shim.ts +++ b/packages/runtime/src/shim.ts @@ -1,86 +1,12 @@ /** - * Raw envelope shape the cloud proactive-runtime gateway delivers per M1 - * spec (`cloud-proactive-runtime-spec/docs/proactive-runtime/spec.md`). + * Compatibility input for the pre-EventFrame gateway wire format. * - * This is workforce's own type, not an import from `@agent-relay/agent` — - * and intentionally so. The relay agent SDK exports *decoded* events - * (`CronTickEvent`, `RelayfileChangeEvent`, …) but no raw-envelope type; - * the wire format below is cloud's gateway contract, mirrored here and - * pinned to `envelope-fields.cloud.ts` (cloud#1841) via - * `shim.contract.test.ts`. There is nothing in the SDK to "swap to" — the - * runtime owns this decode boundary so it can track the cloud gateway - * contract independently of the SDK's version. + * @deprecated New producers and fixtures should use `EventFrameV1` from + * `@agentworkforce/events`. This alias remains additive while Cloud migrates. */ -export interface RawGatewayEnvelope { - id: string; - workspace: string; - /** Dotted type like `cron.tick`, `github.pull_request.opened`. */ - type: string; - occurredAt: string; - attempt?: number; - resource?: unknown; - summary?: Record; - expand?: unknown; - digest?: string; - /** Cron-only: the schedule name. */ - name?: string; - /** Cron-only: the schedule's cron expression. */ - cron?: string; - /** Provider id cloud derived for the event (e.g. "github"). */ - provider?: string; - /** Provider-qualified event name as cloud received it. */ - eventType?: string; - /** Upstream webhook delivery id, when the provider supplied one. */ - deliveryId?: string; - /** Relayfile paths the event touched (drives event-scoped mount sync). */ - paths?: string[]; - /** Opaque resume token for multi-phase deliveries (pr-reviewer resume). */ - resumeContext?: unknown; - /** Harness resume session cloud attaches for multi-phase deliveries. */ - harnessSession?: unknown; - /** Relaycast channel (or DM id) for `relaycast.message` envelopes. */ - channel?: string; - /** Relaycast message id for `relaycast.message` envelopes. */ - messageId?: string; - /** Relaycast thread id for `relaycast.message` envelopes, when threaded. */ - threadId?: string; -} +export type { LegacyRawGatewayEnvelope as RawGatewayEnvelope } from '@agentworkforce/events'; -/** - * Every field cloud's `buildEnvelope` can emit on a delivered envelope. - * MUST stay in lockstep with the checked-in contract copy in - * `envelope-fields.cloud.ts` (source: cloud - * `packages/web/lib/proactive-runtime/deployment-trigger-delivery.ts` - * `ENVELOPE_FIELDS`, cloud#1841) — `shim.contract.test.ts` fails on drift, - * and a `satisfies` check below fails compilation if a listed field is not - * actually declared on `RawGatewayEnvelope`. - */ -export const RAW_GATEWAY_ENVELOPE_FIELDS = [ - 'id', - 'workspace', - 'type', - 'occurredAt', - 'attempt', - 'name', - 'cron', - 'resource', - 'provider', - 'eventType', - 'deliveryId', - 'paths', - 'summary', - 'resumeContext', - 'harnessSession', - 'channel', - 'messageId', - 'threadId', - // Declared on the frame but never emitted by cloud's buildEnvelope — - // kept for older gateway shapes; not part of the cloud contract. - 'expand', - 'digest', -] as const satisfies readonly (keyof RawGatewayEnvelope)[]; - -// v4: the envelope → event decoder lives in `to-agent-event.ts` -// (`envelopeToAgentEvent`), which produces the relay SDK's `AgentEvent`. The -// pre-v4 `shimEnvelope` (which produced the removed `WorkforceEvent` shape) is -// gone; this module now only owns the `RawGatewayEnvelope` wire contract. +/** @deprecated Derived from the canonical compatibility decoder package. */ +export { + LEGACY_RAW_GATEWAY_ENVELOPE_FIELDS as RAW_GATEWAY_ENVELOPE_FIELDS +} from '@agentworkforce/events'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33a312ef..be572644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,8 @@ importers: specifier: ^0.25.0 version: 0.25.12 + packages/events: {} + packages/local-surface: dependencies: '@agent-relay/fleet': @@ -166,6 +168,9 @@ importers: '@agent-relay/events': specifier: ^6.3.3 version: 6.3.6 + '@agentworkforce/events': + specifier: workspace:* + version: link:../events '@agentworkforce/persona-kit': specifier: workspace:* version: link:../persona-kit From e52dfaaacb03e2f3078ab19072be76b921d31a10 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 15 Jul 2026 13:58:51 +0200 Subject: [PATCH 2/3] fix(events): harden compatibility and publishing --- .github/workflows/publish.yml | 3 +- .github/workflows/verify-publish.yml | 1 + packages/events/package.json | 6 +- packages/events/src/json-schema.ts | 54 +++++++++++++ packages/events/src/parse.ts | 22 ++++-- packages/events/src/redact.ts | 23 +++++- packages/events/src/registry.test.ts | 54 +++++++++++++ packages/events/src/registry.ts | 22 +++++- packages/events/src/types.ts | 2 + packages/events/src/validate.ts | 112 +-------------------------- pnpm-lock.yaml | 9 ++- 11 files changed, 184 insertions(+), 124 deletions(-) create mode 100644 packages/events/src/json-schema.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 294bb1d9..d5c68519 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -86,6 +86,7 @@ jobs: id: targets run: | # Dependency order (topological): + # events (leaf — consumed by runtime and Cloud) # persona-kit (leaf — consumed by everyone) # runtime (→ persona-kit; consumed by deploy + mcp-workforce) # delivery (→ runtime; consumed by external agents) @@ -96,7 +97,7 @@ jobs: # cli (→ persona-kit + workload-router + deploy) # agentworkforce (→ cli — umbrella wrapper, must publish last) # personas-core publishes via the separate publish-personas.yml workflow. - echo "packages=persona-kit runtime delivery workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT" + echo "packages=events persona-kit runtime delivery workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT" # Lockstep baseline heal. The workspace publishes every package at the # same version, so if any package's local version lags either its own diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index 6b6e03c6..314113f8 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -14,6 +14,7 @@ on: default: '@agentworkforce/cli' type: choice options: + - '@agentworkforce/events' - '@agentworkforce/cli' - 'agentworkforce' - '@agentworkforce/workload-router' diff --git a/packages/events/package.json b/packages/events/package.json index f0735456..4f494260 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -33,7 +33,11 @@ "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "tsc -p tsconfig.json && node --test dist/**/*.test.js dist/*.test.js", + "test": "tsc -p tsconfig.json && node --test dist/*.test.js", "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" } } diff --git a/packages/events/src/json-schema.ts b/packages/events/src/json-schema.ts new file mode 100644 index 00000000..0d4b2609 --- /dev/null +++ b/packages/events/src/json-schema.ts @@ -0,0 +1,54 @@ +import addFormatsModule from 'ajv-formats'; +import { Ajv2020, type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js'; +import type { JsonSchema, ValidationIssue, ValidationResult } from './types.js'; + +const ajv = new Ajv2020({ allErrors: true, strict: true }); +(addFormatsModule as unknown as (instance: Ajv2020) => Ajv2020)(ajv); + +const validators = new WeakMap(); + +/** Validate against the same JSON Schema objects exported to consumers. */ +export function validateJsonSchema(schema: JsonSchema, value: unknown, path = '$'): ValidationResult { + const cached = validators.get(schema); + const validator = cached ?? ajv.compile(withoutSchemaIdentifiers(schema)); + if (!cached) validators.set(schema, validator); + if (validator(value)) return { valid: true, errors: [] }; + return { + valid: false, + errors: (validator.errors ?? []).map((issue) => toValidationIssue(issue, path)) + }; +} + +/** Remove registration identifiers when a schema is embedded or compiled in a shared catalog. */ +export function withoutSchemaIdentifiers(schema: JsonSchema): JsonSchema { + return stripIdentifiers(schema) as JsonSchema; +} + +function stripIdentifiers(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripIdentifiers); + if (typeof value !== 'object' || value === null) return value; + return Object.fromEntries(Object.entries(value) + .filter(([key]) => key !== '$id' && key !== '$schema') + .map(([key, child]) => [key, stripIdentifiers(child)])); +} + +function toValidationIssue(issue: ErrorObject, root: string): ValidationIssue { + const pointer = issue.instancePath + .split('/') + .slice(1) + .map(unescapePointer) + .map((part) => /^\d+$/.test(part) ? `[${part}]` : `.${part}`) + .join(''); + const additional = issue.keyword === 'additionalProperties' + ? `.${String((issue.params as { additionalProperty?: unknown }).additionalProperty ?? '')}` + : ''; + return { + path: `${root}${pointer}${additional}`, + message: issue.message ?? 'is invalid', + keyword: issue.keyword + }; +} + +function unescapePointer(value: string): string { + return value.replaceAll('~1', '/').replaceAll('~0', '~'); +} diff --git a/packages/events/src/parse.ts b/packages/events/src/parse.ts index bcfb59de..2d054bb8 100644 --- a/packages/events/src/parse.ts +++ b/packages/events/src/parse.ts @@ -74,14 +74,15 @@ export function decodeLegacyRawGatewayEnvelope(input: unknown): DecodedEventFram source: 'legacy-raw-gateway-envelope', originalType, aliasesApplied, - preservedFields: preserved + preservedFields: preserved, + ...(Object.hasOwn(input, 'compatibility') ? { originalCompatibility: input.compatibility } : {}) }; const legacyExtensions: Record = {}; - for (const key of preserved) legacyExtensions[key] = input[key]; + for (const key of preserved) defineDataProperty(legacyExtensions, key, input[key]); for (const key of ['expand', 'resumeContext', 'harnessSession', 'eventType'] as const) { - if (legacy[key] !== undefined) legacyExtensions[key] = legacy[key]; + if (legacy[key] !== undefined) defineDataProperty(legacyExtensions, key, legacy[key]); } - legacyExtensions.compatibility = compatibility; + defineDataProperty(legacyExtensions, 'compatibility', compatibility); const frame: EventFrameV1 = { schemaVersion: 1, @@ -105,13 +106,22 @@ export function decodeLegacyRawGatewayEnvelope(input: unknown): DecodedEventFram } function isCanonicalResource(value: unknown): value is EventFrameV1['resource'] { - return isRecord(value) && typeof value.path === 'string' && typeof value.kind === 'string' && typeof value.id === 'string' && typeof value.provider === 'string'; + if (!isRecord(value)) return false; + const keys = ['path', 'kind', 'id', 'provider'] as const; + return Object.keys(value).length === keys.length && keys.every((key) => typeof value[key] === 'string'); } function deriveLegacyPath(type: string, provider: string, kind: string, id: string, channel?: string): string { if (type === 'cron.tick') return `/cron/schedules/${encodeURIComponent(id)}`; if (type === 'relaycast.message') return `/relaycast/${encodeURIComponent(channel ?? 'dm')}/messages/${encodeURIComponent(id)}`; - return `/${encodeURIComponent(provider)}/${kind.split('.').slice(1).map(encodeURIComponent).join('/')}/${encodeURIComponent(id)}`; + const kindSegments = kind.split('.').filter(Boolean); + if (kindSegments[0] === provider) kindSegments.shift(); + const resourceSegments = kindSegments.length > 0 ? kindSegments : ['resources']; + return `/${encodeURIComponent(provider)}/${resourceSegments.map(encodeURIComponent).join('/')}/${encodeURIComponent(id)}`; +} + +function defineDataProperty(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true }); } function firstPath(value: unknown): string | undefined { diff --git a/packages/events/src/redact.ts b/packages/events/src/redact.ts index 54628f6e..e98ab307 100644 --- a/packages/events/src/redact.ts +++ b/packages/events/src/redact.ts @@ -22,17 +22,32 @@ function visit(value: unknown, options: RedactOptions, seen: WeakMap = {}; seen.set(value, out); for (const [key, child] of Object.entries(value)) { - out[key] = isSensitiveKey(key, options.additionalSensitiveKeys) ? REDACTED : visit(child, options, seen); + Object.defineProperty(out, key, { + value: isSensitiveKey(key, options.additionalSensitiveKeys) ? REDACTED : visit(child, options, seen), + enumerable: true, + configurable: true, + writable: true + }); } return out; } function isSensitiveKey(key: string, additional: RedactOptions['additionalSensitiveKeys']): boolean { if (SENSITIVE_KEY.test(key)) return true; - return additional?.some((matcher) => - typeof matcher === 'string' ? matcher.toLowerCase() === key.toLowerCase() : matcher.test(key) - ) ?? false; + return additional?.some((matcher) => { + if (typeof matcher === 'string') return matcher.toLowerCase() === key.toLowerCase(); + matcher.lastIndex = 0; + const matches = matcher.test(key); + matcher.lastIndex = 0; + return matches; + }) ?? false; +} + +function isPlainObject(value: object): value is Record { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } diff --git a/packages/events/src/registry.test.ts b/packages/events/src/registry.test.ts index 1074323f..a284d3c2 100644 --- a/packages/events/src/registry.test.ts +++ b/packages/events/src/registry.test.ts @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import addFormatsModule from 'ajv-formats'; +import { Ajv2020 } from 'ajv/dist/2020.js'; import { decodeEventFrame, EVENT_CONTRACTS, @@ -11,6 +13,7 @@ import { test('every registry entry exports schema and validating example fixtures', () => { assert.deepEqual(EVENT_CONTRACTS.map((entry) => entry.id), [ + 'startup', 'cron.tick', 'github.issues.labeled', 'github.pull_request.opened', @@ -28,6 +31,12 @@ test('every registry entry exports schema and validating example fixtures', () = } }); +test('all exported contract schemas register together without identifier collisions', () => { + const ajv = new Ajv2020({ strict: true }); + (addFormatsModule as unknown as (instance: Ajv2020) => Ajv2020)(ajv); + for (const schema of Object.values(EVENT_CONTRACT_JSON_SCHEMAS)) ajv.addSchema(schema); +}); + test('known schema versions reject unknown top-level fields and preserve extensions', () => { const fixture = EVENT_CONTRACTS[1].fixtureExamples[0]; assert.equal(safeParseEventFrame({ ...fixture, surprise: true }).success, false); @@ -69,9 +78,54 @@ test('legacy cron and relaycast envelopes retain their specialized coordinates', assert.equal(relay.frame.resource.kind, 'relaycast.message'); }); +test('legacy startup remains compatible and resource lookalikes retain provider data', () => { + const startup = decodeEventFrame({ id: 'evt_boot', workspace: 'ws', type: 'startup', occurredAt: '2026-07-15T09:00:00Z' }); + assert.equal(startup.frame.type, 'startup'); + assert.equal(startup.frame.resource.kind, 'runtime.startup'); + + const github = decodeEventFrame({ + id: 'evt_resource', + workspace: 'ws', + type: 'github.issues.labeled', + occurredAt: '2026-07-15T09:00:00Z', + resource: { path: '/provider/path', kind: 'github.issue', id: '42', provider: 'github', action: 'labeled' } + }); + assert.deepEqual(github.frame.payload, { path: '/provider/path', kind: 'github.issue', id: '42', provider: 'github', action: 'labeled' }); + assert.equal(github.frame.resource.path.includes('//'), false); +}); + +test('legacy extension copying is prototype-safe and preserves compatibility collisions', () => { + const legacy = JSON.parse('{"id":"evt_proto","workspace":"ws","type":"startup","occurredAt":"2026-07-15T09:00:00Z","__proto__":{"polluted":true},"compatibility":{"vendor":"original"}}'); + const decoded = decodeEventFrame(legacy); + assert.equal(Object.prototype.hasOwnProperty.call(decoded.frame.extensions, '__proto__'), true); + assert.deepEqual(decoded.frame.extensions?.__proto__, { polluted: true }); + assert.deepEqual(decoded.compatibility?.originalCompatibility, { vendor: 'original' }); + assert.equal(({} as { polluted?: boolean }).polluted, undefined); +}); + +test('schema-driven validation rejects malformed timestamps and summary fields', () => { + const fixture = EVENT_CONTRACTS[0].fixtureExamples[0]; + assert.equal(safeParseEventFrame({ ...fixture, occurredAt: 'July 15, 2026' }).success, false); + assert.equal(safeParseEventFrame({ ...fixture, summary: { title: 42 } }).success, false); + assert.equal(safeParseEventFrame({ ...fixture, schedule: { name: 'scan', scheduledFor: '2026-02-30T00:00:00Z' } }).success, false); +}); + test('redaction is recursive, immutable, and handles bearer strings', () => { const input = { token: 'secret', nested: [{ authorization: 'Bearer abc.def', text: 'Bearer abc.def' }] }; const output = redactEventValue(input); assert.deepEqual(output, { token: '[REDACTED]', nested: [{ authorization: '[REDACTED]', text: '[REDACTED]' }] }); assert.equal(input.token, 'secret'); }); + +test('redaction preserves prototype-looking data safely and treats global matchers statelessly', () => { + const input = JSON.parse('{"__proto__":{"safe":true},"first":"one","second":"two"}'); + const output = redactEventValue(input, { additionalSensitiveKeys: [/first|second/g] }); + assert.equal(Object.prototype.hasOwnProperty.call(output, '__proto__'), true); + assert.deepEqual(output.__proto__, { safe: true }); + assert.equal(output.first, '[REDACTED]'); + assert.equal(output.second, '[REDACTED]'); + assert.equal(({} as { safe?: boolean }).safe, undefined); + + const date = new Date('2026-07-15T09:00:00Z'); + assert.equal(redactEventValue(date), date); +}); diff --git a/packages/events/src/registry.ts b/packages/events/src/registry.ts index 7ca21fb4..525a4266 100644 --- a/packages/events/src/registry.ts +++ b/packages/events/src/registry.ts @@ -1,9 +1,10 @@ import { redactEventValue } from './redact.js'; +import { validateJsonSchema, withoutSchemaIdentifiers } from './json-schema.js'; import { EVENT_FRAME_V1_SCHEMA, EVENT_SUMMARY_SCHEMA } from './schemas.js'; import type { EventContract, EventFrameV1, JsonSchema, ValidationIssue, ValidationResult } from './types.js'; import { validateEventFrameV1 } from './validate.js'; -const OPEN_PAYLOAD_SCHEMA = { type: 'object', additionalProperties: true } as const; +const OPEN_PAYLOAD_SCHEMA = Object.freeze({}) satisfies JsonSchema; function example( id: string, @@ -58,12 +59,29 @@ function contract(config: { if (frame.contractVersion !== 1) errors.push({ path: '$.contractVersion', message: 'must equal 1', keyword: 'const' }); if (frame.resource.provider !== config.provider) errors.push({ path: '$.resource.provider', message: `must equal ${config.provider}`, keyword: 'const' }); if (frame.resource.kind !== config.resourceKind) errors.push({ path: '$.resource.kind', message: `must equal ${config.resourceKind}`, keyword: 'const' }); + const summary = validateJsonSchema(EVENT_SUMMARY_SCHEMA, frame.summary, '$.summary'); + if (!summary.valid) errors.push(...summary.errors); + if (frame.payload !== undefined) { + const payload = validateJsonSchema(config.payloadSchema ?? OPEN_PAYLOAD_SCHEMA, frame.payload, '$.payload'); + if (!payload.valid) errors.push(...payload.errors); + } return errors.length === 0 ? { valid: true, errors: [] } : { valid: false, errors }; } }); } export const EVENT_CONTRACTS = Object.freeze([ + contract({ + id: 'startup', + provider: 'runtime', + trigger: 'startup', + resourceKind: 'runtime.startup', + fixture: example('startup', 'runtime', 'startup', 'runtime.startup', { + resource: { path: '/runtime/startups/evt_startup', kind: 'runtime.startup', id: 'evt_startup', provider: 'runtime' }, + summary: { title: 'Agent runtime started' }, + payload: undefined + }) + }), contract({ id: 'cron.tick', provider: 'cron', @@ -154,7 +172,7 @@ export const EVENT_CONTRACT_JSON_SCHEMAS = Object.freeze(Object.fromEntries( $schema: 'https://json-schema.org/draft/2020-12/schema', $id: `https://agentworkforce.dev/schemas/events/${entry.id}/v${entry.version}.json`, allOf: [ - EVENT_FRAME_V1_SCHEMA, + withoutSchemaIdentifiers(EVENT_FRAME_V1_SCHEMA), { type: 'object', properties: { diff --git a/packages/events/src/types.ts b/packages/events/src/types.ts index 938315a7..8029949e 100644 --- a/packages/events/src/types.ts +++ b/packages/events/src/types.ts @@ -97,6 +97,8 @@ export interface EventCompatibilityInfo { originalType: string; aliasesApplied: readonly string[]; preservedFields: readonly string[]; + /** A colliding legacy `compatibility` field, retained without hiding the decoder trace. */ + originalCompatibility?: unknown; } export interface DecodedEventFrame { diff --git a/packages/events/src/validate.ts b/packages/events/src/validate.ts index 85694752..afc7797c 100644 --- a/packages/events/src/validate.ts +++ b/packages/events/src/validate.ts @@ -1,8 +1,7 @@ -import { EVENT_FRAME_V1_FIELDS } from './schemas.js'; +import { validateJsonSchema } from './json-schema.js'; +import { EVENT_FRAME_V1_SCHEMA } from './schemas.js'; import type { EventFrameV1, ValidationIssue, ValidationResult } from './types.js'; -const TOP_LEVEL_FIELDS = new Set(EVENT_FRAME_V1_FIELDS); - export class EventValidationError extends Error { readonly issues: readonly ValidationIssue[]; @@ -14,112 +13,7 @@ export class EventValidationError extends Error { } export function validateEventFrameV1(value: unknown): ValidationResult { - const errors: ValidationIssue[] = []; - if (!isRecord(value)) { - return invalid([{ path: '$', message: 'must be an object', keyword: 'type' }]); - } - - for (const key of Object.keys(value)) { - if (!TOP_LEVEL_FIELDS.has(key)) { - errors.push({ path: `$.${key}`, message: 'is not a known EventFrameV1 field; place extension data under $.extensions', keyword: 'additionalProperties' }); - } - } - - if (value.schemaVersion !== 1) error(errors, '$.schemaVersion', 'must equal 1', 'const'); - requiredString(value.id, '$.id', errors); - requiredString(value.workspace, '$.workspace', errors); - requiredString(value.type, '$.type', errors); - positiveInteger(value.contractVersion, '$.contractVersion', errors); - requiredString(value.occurredAt, '$.occurredAt', errors); - if (typeof value.occurredAt === 'string' && Number.isNaN(Date.parse(value.occurredAt))) { - error(errors, '$.occurredAt', 'must be an ISO-8601 date-time', 'format'); - } - positiveInteger(value.attempt, '$.attempt', errors); - - if (!isRecord(value.resource)) { - error(errors, '$.resource', 'must be an object', 'type'); - } else { - exactKeys(value.resource, new Set(['path', 'kind', 'id', 'provider']), '$.resource', errors); - string(value.resource.path, '$.resource.path', errors); - requiredString(value.resource.kind, '$.resource.kind', errors); - requiredString(value.resource.id, '$.resource.id', errors); - requiredString(value.resource.provider, '$.resource.provider', errors); - } - - if (!isRecord(value.summary)) error(errors, '$.summary', 'must be an object', 'type'); - optionalRecord(value.delivery, '$.delivery', errors, new Set(['id', 'dedupeKey'])); - if (isRecord(value.delivery)) { - optionalString(value.delivery.id, '$.delivery.id', errors); - optionalString(value.delivery.dedupeKey, '$.delivery.dedupeKey', errors); - } - optionalStringArray(value.paths, '$.paths', errors); - optionalString(value.digest, '$.digest', errors); - optionalRecord(value.schedule, '$.schedule', errors, new Set(['name', 'cron', 'timezone', 'scheduledFor'])); - if (isRecord(value.schedule)) { - requiredString(value.schedule.name, '$.schedule.name', errors); - optionalString(value.schedule.cron, '$.schedule.cron', errors); - optionalString(value.schedule.timezone, '$.schedule.timezone', errors); - optionalString(value.schedule.scheduledFor, '$.schedule.scheduledFor', errors); - if (typeof value.schedule.scheduledFor === 'string' && Number.isNaN(Date.parse(value.schedule.scheduledFor))) { - error(errors, '$.schedule.scheduledFor', 'must be an ISO-8601 date-time', 'format'); - } - } - optionalRecord(value.message, '$.message', errors, new Set(['channel', 'messageId', 'threadId'])); - if (isRecord(value.message)) { - optionalString(value.message.channel, '$.message.channel', errors); - optionalString(value.message.messageId, '$.message.messageId', errors); - optionalString(value.message.threadId, '$.message.threadId', errors); - } - if (value.extensions !== undefined && !isRecord(value.extensions)) { - error(errors, '$.extensions', 'must be an object', 'type'); - } - - return errors.length === 0 ? { valid: true, errors: [] } : invalid(errors); -} - -function optionalRecord(value: unknown, path: string, errors: ValidationIssue[], keys: Set): void { - if (value === undefined) return; - if (!isRecord(value)) { - error(errors, path, 'must be an object', 'type'); - return; - } - exactKeys(value, keys, path, errors); -} - -function exactKeys(value: Record, keys: Set, path: string, errors: ValidationIssue[]): void { - for (const key of Object.keys(value)) { - if (!keys.has(key)) error(errors, `${path}.${key}`, 'is not a known field', 'additionalProperties'); - } -} - -function optionalStringArray(value: unknown, path: string, errors: ValidationIssue[]): void { - if (value === undefined) return; - if (!Array.isArray(value)) return error(errors, path, 'must be an array', 'type'); - value.forEach((item, index) => string(item, `${path}[${index}]`, errors)); -} - -function positiveInteger(value: unknown, path: string, errors: ValidationIssue[]): void { - if (!Number.isInteger(value) || (value as number) < 1) error(errors, path, 'must be a positive integer', 'minimum'); -} - -function optionalString(value: unknown, path: string, errors: ValidationIssue[]): void { - if (value !== undefined) string(value, path, errors); -} - -function requiredString(value: unknown, path: string, errors: ValidationIssue[]): void { - if (typeof value !== 'string' || value.length === 0) error(errors, path, 'must be a non-empty string', 'minLength'); -} - -function string(value: unknown, path: string, errors: ValidationIssue[]): void { - if (typeof value !== 'string') error(errors, path, 'must be a string', 'type'); -} - -function error(errors: ValidationIssue[], path: string, message: string, keyword: string): void { - errors.push({ path, message, keyword }); -} - -function invalid(errors: ValidationIssue[]): ValidationResult { - return { valid: false, errors }; + return validateJsonSchema(EVENT_FRAME_V1_SCHEMA, value); } export function isRecord(value: unknown): value is Record { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be572644..744562a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,7 +98,14 @@ importers: specifier: ^0.25.0 version: 0.25.12 - packages/events: {} + packages/events: + dependencies: + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) packages/local-surface: dependencies: From 3190104dbe79c66e9c7483eaf4f45ae1566b0aa9 Mon Sep 17 00:00:00 2001 From: Miya Date: Wed, 15 Jul 2026 14:17:29 +0200 Subject: [PATCH 3/3] fix(events): pin validator dependencies --- packages/events/package.json | 4 ++-- pnpm-lock.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/events/package.json b/packages/events/package.json index 4f494260..26df94eb 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -37,7 +37,7 @@ "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1" + "ajv": "8.20.0", + "ajv-formats": "3.0.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 744562a0..439cc12c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,10 +101,10 @@ importers: packages/events: dependencies: ajv: - specifier: ^8.20.0 + specifier: 8.20.0 version: 8.20.0 ajv-formats: - specifier: ^3.0.1 + specifier: 3.0.1 version: 3.0.1(ajv@8.20.0) packages/local-surface: