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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/verify-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ on:
default: '@agentworkforce/cli'
type: choice
options:
- '@agentworkforce/events'
- '@agentworkforce/cli'
- 'agentworkforce'
- '@agentworkforce/workload-router'
Expand Down
15 changes: 15 additions & 0 deletions packages/events/README.md
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`.
43 changes: 43 additions & 0 deletions packages/events/package.json
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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
"dependencies": {
"ajv": "8.20.0",
"ajv-formats": "3.0.1"
}
}
37 changes: 37 additions & 0 deletions packages/events/src/index.ts
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';
54 changes: 54 additions & 0 deletions packages/events/src/json-schema.ts
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', '~');
}
146 changes: 146 additions & 0 deletions packages/events/src/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { getEventContract } from './registry.js';
Comment thread
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' }]);
Comment thread
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));
Comment thread
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 });
}
Comment thread
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' }]);
}
53 changes: 53 additions & 0 deletions packages/events/src/redact.ts
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;
}
Comment thread
miyaontherelay marked this conversation as resolved.
Comment thread
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;
}
Loading
Loading