diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 8263d535..bdd76bab 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -80,6 +80,25 @@ export { export { type RawGatewayEnvelope } from './shim.js'; export { envelopeToAgentEvent } from './to-agent-event.js'; +// Versioned Agent compiler and Run engine contracts. These are additive to +// the existing simulation record while hosted/local producers migrate. +export { + LOCAL_EFFECT_POLICY_DEFAULTS, + resolveLocalEffectPolicy, + type CompiledAgentV1, + type Diagnostic, + type EffectPolicyV1, + type PreviewAction, + type RunArtifactEntry, + type RunArtifactManifest, + type RunMode, + type RunRecordV2, + type RunRequestV1, + type RunTraceEventV1, + type StateDiff, + type StateSourceV1 +} from './run-contracts.js'; + export type { LinearAgentActivity, LinearAgentActivityType, diff --git a/packages/runtime/src/run-contracts.test.ts b/packages/runtime/src/run-contracts.test.ts new file mode 100644 index 00000000..83f68aef --- /dev/null +++ b/packages/runtime/src/run-contracts.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + LOCAL_EFFECT_POLICY_DEFAULTS, + resolveLocalEffectPolicy, + type PreviewAction, + type RunRecordV2 +} from './run-contracts.js'; + +test('local EffectPolicyV1 defaults are fixture/stub/preview safe', () => { + assert.deepEqual(LOCAL_EFFECT_POLICY_DEFAULTS, { + reads: 'fixtures', + writes: 'preview', + model: 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: [] + }); + assert.throws( + () => LOCAL_EFFECT_POLICY_DEFAULTS.allowedHttp.push({ method: 'GET', urlGlob: '*' }), + TypeError + ); +}); + +test('local policy resolver copies HTTP defaults instead of sharing mutable state', () => { + const first = resolveLocalEffectPolicy(); + const second = resolveLocalEffectPolicy(); + assert.notEqual(first.allowedHttp, LOCAL_EFFECT_POLICY_DEFAULTS.allowedHttp); + assert.notEqual(first.allowedHttp, second.allowedHttp); +}); + +test('local effect policy cannot be escalated to live writes, shell, or compose', () => { + assert.deepEqual(resolveLocalEffectPolicy({ writes: 'live', shell: 'live', compose: 'live' }), { + reads: 'fixtures', + writes: 'preview', + model: 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: [] + }); + assert.equal(resolveLocalEffectPolicy({ writes: 'deny' }).writes, 'deny'); +}); + +test('RunRecordV2 retains additive existing and extension fields', () => { + const record: RunRecordV2 = { + runId: 'run_1', + status: 'succeeded', + origin: 'local_dry_run', + mode: 'preview', + policy: resolveLocalEffectPolicy(), + eventId: 'evt_1', + eventContract: 'cron.tick@1', + trace: [], + actions: [], + artifacts: { artifacts: [] }, + stateDiff: {}, + legacyCloudField: { retained: true } + }; + assert.deepEqual(record.legacyCloudField, { retained: true }); +}); + +test('RunRecordV2 JSON round-trip preserves richer transport preview fields', () => { + interface TransportPreviewAction extends PreviewAction { + method: 'write'; + path: string; + /** Mirrors the narrower WS-D transport query-parameter contract. */ + parameters: Record; + body: Record; + simulatedReceipt: { id: string; timestamp: string }; + } + + const transportAction: TransportPreviewAction = { + kind: 'provider.write', + status: 'previewed', + provider: 'slack', + resource: 'messages', + method: 'write', + path: '/slack/channels/C123/messages/drafts/draft-1.json', + parameters: { channel: 'C123' }, + body: { text: 'Preview only' }, + simulatedReceipt: { id: 'sim_1', timestamp: '2026-07-15T09:00:00.000Z' }, + data: { + operation: 'write', + simulatedReceipt: { id: 'sim_1', timestamp: '2026-07-15T09:00:00.000Z' } + } + }; + const record: RunRecordV2 = { + runId: 'run_transport', + status: 'succeeded', + origin: 'local_dry_run', + mode: 'preview', + policy: resolveLocalEffectPolicy(), + eventId: 'evt_transport', + eventContract: 'slack.message.created@1', + trace: [], + actions: [transportAction], + artifacts: { artifacts: [] }, + stateDiff: {} + }; + + const roundTripped = JSON.parse(JSON.stringify(record)) as RunRecordV2; + const action = roundTripped.actions[0] as PreviewAction & Partial; + assert.equal(action.method, 'write'); + assert.equal(action.path, '/slack/channels/C123/messages/drafts/draft-1.json'); + assert.deepEqual(action.simulatedReceipt, { + id: 'sim_1', + timestamp: '2026-07-15T09:00:00.000Z' + }); +}); diff --git a/packages/runtime/src/run-contracts.ts b/packages/runtime/src/run-contracts.ts new file mode 100644 index 00000000..11311dd3 --- /dev/null +++ b/packages/runtime/src/run-contracts.ts @@ -0,0 +1,147 @@ +import type { EventFrameV1 } from '@agentworkforce/events'; +import type { AgentSpec, PersonaSpec } from '@agentworkforce/persona-kit'; + +export interface Diagnostic { + severity: 'warning' | 'error'; + code: string; + message: string; + path?: string; + extensions?: Record; +} + +/** Stable compiler output shared by deploy, invoke, and Run construction. */ +export interface CompiledAgentV1 { + schemaVersion: 1; + sourceKind: 'single-file' | 'split'; + sourcePath: string; + persona: PersonaSpec; + agent: AgentSpec; + handlerEntry: string; + sourceDigest: string; + compileWarnings: Diagnostic[]; + extensions?: Record; +} + +export type RunMode = 'simulate' | 'preview' | 'sandbox' | 'hosted' | 'replay'; + +export interface EffectPolicyV1 { + reads: 'deny' | 'fixtures' | 'live'; + writes: 'deny' | 'preview' | 'sandbox' | 'live'; + model: 'stub' | 'fixture' | 'live'; + shell: 'deny' | 'simulate' | 'sandbox' | 'live'; + compose: 'deny' | 'preview' | 'sandbox' | 'live'; + allowedHttp: Array<{ method: string; urlGlob: string }>; + allowedProviders?: string[]; +} + +/** Safe policy floor used by local invoke before case-specific narrowing. */ +const EMPTY_ALLOWED_HTTP = Object.freeze([]) as unknown as EffectPolicyV1['allowedHttp']; + +export const LOCAL_EFFECT_POLICY_DEFAULTS: Readonly = Object.freeze({ + reads: 'fixtures', + writes: 'preview', + model: 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: EMPTY_ALLOWED_HTTP +}); + +export interface StateSourceV1 { + schemaVersion: 1; + kind: 'empty' | 'fixtures' | 'workspace' | 'replay'; + ref?: string; + fidelity?: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + extensions?: Record; +} + +export interface RunRequestV1 { + schemaVersion: 1; + agent: CompiledAgentV1; + event: EventFrameV1; + mode: RunMode; + inputs: Record; + policy: EffectPolicyV1; + state: StateSourceV1; + clock?: { now: string }; + parentRunId?: string; + composeId?: string; +} + +export interface RunTraceEventV1 { + schemaVersion: 1; + seq: number; + at: string; + runId: string; + parentSpanId?: string; + spanId: string; + kind: string; + phase: 'route' | 'read' | 'decide' | 'model' | 'write' | 'compose' | 'result'; + status: 'started' | 'succeeded' | 'failed' | 'denied' | 'previewed'; + summary: string; + data?: Record; + artifactRefs?: string[]; +} + +export interface PreviewAction { + id?: string; + kind: string; + provider?: string; + resource?: string; + status?: 'denied' | 'previewed' | 'sandboxed' | 'executed'; + data?: Record; + extensions?: Record; +} + +export interface RunArtifactEntry { + id: string; + kind: string; + path?: string; + mediaType?: string; + redacted: boolean; + extensions?: Record; +} + +export interface RunArtifactManifest { + artifacts: RunArtifactEntry[]; + extensions?: Record; +} + +export interface StateDiff { + files?: Array<{ path: string; before?: string; after?: string }>; + memory?: Array>; + providers?: Array>; + extensions?: Record; +} + +/** Additive common record; hosted/local implementations may retain existing fields. */ +export interface RunRecordV2 { + runId: string; + status: 'succeeded' | 'failed' | 'cancelled'; + origin: 'local_dry_run' | 'hosted'; + mode: RunRequestV1['mode']; + policy: EffectPolicyV1; + eventId: string; + eventContract: string; + trace: RunTraceEventV1[]; + actions: PreviewAction[]; + artifacts: RunArtifactManifest; + stateDiff: StateDiff; + /** Existing Run fields remain serializable while producers migrate additively. */ + [field: string]: unknown; +} + +/** Apply local defaults while preventing callers from escalating local effects. */ +export function resolveLocalEffectPolicy( + requested: Partial = {} +): EffectPolicyV1 { + return { + reads: requested.reads ?? LOCAL_EFFECT_POLICY_DEFAULTS.reads, + writes: requested.writes === 'deny' ? 'deny' : 'preview', + model: requested.model ?? LOCAL_EFFECT_POLICY_DEFAULTS.model, + shell: requested.shell === 'deny' ? 'deny' : 'simulate', + compose: requested.compose === 'deny' ? 'deny' : 'preview', + allowedHttp: (requested.allowedHttp ?? LOCAL_EFFECT_POLICY_DEFAULTS.allowedHttp) + .map((rule) => ({ ...rule })), + ...(requested.allowedProviders ? { allowedProviders: [...requested.allowedProviders] } : {}) + }; +}