Repository navigation
feat(events): add canonical Event contracts #265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<object, ValidateFunction>(); | ||
|
|
||
| /** 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', '~'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { getEventContract } from './registry.js'; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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<string>(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<Record<string, string>>)[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' }]); | ||
|
miyaontherelay marked this conversation as resolved.
|
||
|
|
||
| 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)); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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<string, unknown> = {}; | ||
| 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<string, unknown>, key: string, value: unknown): void { | ||
| Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true }); | ||
| } | ||
|
miyaontherelay marked this conversation as resolved.
|
||
|
|
||
| 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' }]); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>(value: T, options: RedactOptions = {}): T { | ||
| return visit(value, options, new WeakMap<object, unknown>()) as T; | ||
| } | ||
|
|
||
| function visit(value: unknown, options: RedactOptions, seen: WeakMap<object, unknown>): 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<string, unknown> = {}; | ||
| 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; | ||
| } | ||
|
miyaontherelay marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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<string, unknown> { | ||
| const prototype = Object.getPrototypeOf(value); | ||
| return prototype === Object.prototype || prototype === null; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.