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/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..26df94eb --- /dev/null +++ b/packages/events/package.json @@ -0,0 +1,43 @@ +{ + "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", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + } +} 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/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 new file mode 100644 index 00000000..2d054bb8 --- /dev/null +++ b/packages/events/src/parse.ts @@ -0,0 +1,146 @@ +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, + ...(Object.hasOwn(input, 'compatibility') ? { originalCompatibility: input.compatibility } : {}) + }; + const legacyExtensions: Record = {}; + for (const key of preserved) defineDataProperty(legacyExtensions, key, input[key]); + for (const key of ['expand', 'resumeContext', 'harnessSession', 'eventType'] as const) { + if (legacy[key] !== undefined) defineDataProperty(legacyExtensions, key, legacy[key]); + } + defineDataProperty(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'] { + 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)}`; + 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 { + 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..e98ab307 --- /dev/null +++ b/packages/events/src/redact.ts @@ -0,0 +1,53 @@ +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; + } + if (!isPlainObject(value)) return value; + const out: Record = {}; + seen.set(value, out); + for (const [key, child] of Object.entries(value)) { + 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) => { + 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 new file mode 100644 index 00000000..a284d3c2 --- /dev/null +++ b/packages/events/src/registry.test.ts @@ -0,0 +1,131 @@ +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, + 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), [ + 'startup', + '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('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); + 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('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 new file mode 100644 index 00000000..525a4266 --- /dev/null +++ b/packages/events/src/registry.ts @@ -0,0 +1,195 @@ +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 = Object.freeze({}) satisfies JsonSchema; + +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' }); + 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', + 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: [ + withoutSchemaIdentifiers(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..8029949e --- /dev/null +++ b/packages/events/src/types.ts @@ -0,0 +1,107 @@ +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[]; + /** A colliding legacy `compatibility` field, retained without hiding the decoder trace. */ + originalCompatibility?: unknown; +} + +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..afc7797c --- /dev/null +++ b/packages/events/src/validate.ts @@ -0,0 +1,26 @@ +import { validateJsonSchema } from './json-schema.js'; +import { EVENT_FRAME_V1_SCHEMA } from './schemas.js'; +import type { EventFrameV1, ValidationIssue, ValidationResult } from './types.js'; + +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 { + return validateJsonSchema(EVENT_FRAME_V1_SCHEMA, value); +} + +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..439cc12c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,15 @@ importers: specifier: ^0.25.0 version: 0.25.12 + 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: '@agent-relay/fleet': @@ -166,6 +175,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