d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
import { getErrorMessage } from '@sim/utils/errors'
|
||
import { isRecordLike } from '@sim/utils/object'
|
||
import type {
|
||
MothershipStreamV1EventEnvelope,
|
||
MothershipStreamV1StreamRef,
|
||
MothershipStreamV1StreamScope,
|
||
MothershipStreamV1Trace,
|
||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||
import {
|
||
MothershipStreamV1EventType,
|
||
MothershipStreamV1ResourceOp,
|
||
MothershipStreamV1RunKind,
|
||
MothershipStreamV1SessionKind,
|
||
MothershipStreamV1SpanPayloadKind,
|
||
MothershipStreamV1TextChannel,
|
||
MothershipStreamV1ToolPhase,
|
||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||
import type { FilePreviewTargetKind } from './file-preview-session-contract'
|
||
|
||
type JsonRecord = Record<string, unknown>
|
||
|
||
const FILE_PREVIEW_PHASE = {
|
||
start: 'file_preview_start',
|
||
target: 'file_preview_target',
|
||
editMeta: 'file_preview_edit_meta',
|
||
content: 'file_preview_content',
|
||
complete: 'file_preview_complete',
|
||
} as const
|
||
|
||
type EnvelopeToStreamEvent<T> = T extends {
|
||
type: infer TType
|
||
payload: infer TPayload
|
||
scope?: infer TScope
|
||
}
|
||
? { type: TType; payload: TPayload; scope?: Exclude<TScope, undefined> }
|
||
: never
|
||
|
||
export type SyntheticFilePreviewPhase = (typeof FILE_PREVIEW_PHASE)[keyof typeof FILE_PREVIEW_PHASE]
|
||
|
||
export interface SyntheticFilePreviewTarget {
|
||
kind: FilePreviewTargetKind
|
||
fileId?: string
|
||
fileName?: string
|
||
}
|
||
|
||
export interface SyntheticFilePreviewStartPayload {
|
||
previewPhase: typeof FILE_PREVIEW_PHASE.start
|
||
toolCallId: string
|
||
toolName: 'workspace_file'
|
||
}
|
||
|
||
export interface SyntheticFilePreviewTargetPayload {
|
||
operation?: string
|
||
previewPhase: typeof FILE_PREVIEW_PHASE.target
|
||
target: SyntheticFilePreviewTarget
|
||
title?: string
|
||
toolCallId: string
|
||
toolName: 'workspace_file'
|
||
}
|
||
|
||
export interface SyntheticFilePreviewEditMetaPayload {
|
||
edit: JsonRecord
|
||
previewPhase: typeof FILE_PREVIEW_PHASE.editMeta
|
||
toolCallId: string
|
||
toolName: 'workspace_file'
|
||
}
|
||
|
||
export interface SyntheticFilePreviewContentPayload {
|
||
content: string
|
||
contentMode: 'delta' | 'snapshot'
|
||
edit?: JsonRecord
|
||
fileId?: string
|
||
fileName: string
|
||
operation?: string
|
||
previewPhase: typeof FILE_PREVIEW_PHASE.content
|
||
previewVersion: number
|
||
targetKind?: string
|
||
toolCallId: string
|
||
toolName: 'workspace_file'
|
||
}
|
||
|
||
export interface SyntheticFilePreviewCompletePayload {
|
||
fileId?: string
|
||
output?: unknown
|
||
previewPhase: typeof FILE_PREVIEW_PHASE.complete
|
||
previewVersion?: number
|
||
toolCallId: string
|
||
toolName: 'workspace_file'
|
||
}
|
||
|
||
export type SyntheticFilePreviewPayload =
|
||
| SyntheticFilePreviewStartPayload
|
||
| SyntheticFilePreviewTargetPayload
|
||
| SyntheticFilePreviewEditMetaPayload
|
||
| SyntheticFilePreviewContentPayload
|
||
| SyntheticFilePreviewCompletePayload
|
||
|
||
export interface SyntheticFilePreviewEventEnvelope {
|
||
payload: SyntheticFilePreviewPayload
|
||
scope?: MothershipStreamV1StreamScope
|
||
seq: number
|
||
stream: MothershipStreamV1StreamRef
|
||
trace?: MothershipStreamV1Trace
|
||
ts: string
|
||
type: 'tool'
|
||
v: 1
|
||
}
|
||
|
||
export type PersistedStreamEventEnvelope =
|
||
| MothershipStreamV1EventEnvelope
|
||
| SyntheticFilePreviewEventEnvelope
|
||
|
||
export type ContractStreamEvent = EnvelopeToStreamEvent<MothershipStreamV1EventEnvelope>
|
||
export type SyntheticStreamEvent = EnvelopeToStreamEvent<SyntheticFilePreviewEventEnvelope>
|
||
export type SessionStreamEvent = ContractStreamEvent | SyntheticStreamEvent
|
||
export type StreamEvent = SessionStreamEvent
|
||
export type ToolCallStreamEvent = Extract<
|
||
ContractStreamEvent,
|
||
{ type: 'tool'; payload: { phase: 'call' } }
|
||
>
|
||
export type ToolArgsDeltaStreamEvent = Extract<
|
||
ContractStreamEvent,
|
||
{ type: 'tool'; payload: { phase: 'args_delta' } }
|
||
>
|
||
export type ToolResultStreamEvent = Extract<
|
||
ContractStreamEvent,
|
||
{ type: 'tool'; payload: { phase: 'result' } }
|
||
>
|
||
export type SubagentSpanStreamEvent = Extract<
|
||
ContractStreamEvent,
|
||
{ type: 'span'; payload: { kind: 'subagent' } }
|
||
>
|
||
|
||
export interface ParseStreamEventEnvelopeSuccess {
|
||
ok: true
|
||
event: PersistedStreamEventEnvelope
|
||
}
|
||
|
||
export interface ParseStreamEventEnvelopeFailure {
|
||
errors?: string[]
|
||
message: string
|
||
ok: false
|
||
reason: 'invalid_json' | 'invalid_stream_event'
|
||
}
|
||
|
||
export type ParseStreamEventEnvelopeResult =
|
||
| ParseStreamEventEnvelopeSuccess
|
||
| ParseStreamEventEnvelopeFailure
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Structural helpers (CSP-safe – no codegen / eval / new Function)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function isOptionalString(value: unknown): value is string | undefined {
|
||
return value === undefined || typeof value === 'string'
|
||
}
|
||
|
||
function isOptionalFiniteNumber(value: unknown): value is number | undefined {
|
||
return value === undefined || (typeof value === 'number' && Number.isFinite(value))
|
||
}
|
||
|
||
function isStreamRef(value: unknown): value is MothershipStreamV1StreamRef {
|
||
return (
|
||
isRecordLike(value) &&
|
||
typeof value.streamId === 'string' &&
|
||
value.streamId.length > 0 &&
|
||
isOptionalString(value.chatId) &&
|
||
isOptionalString(value.cursor)
|
||
)
|
||
}
|
||
|
||
function isTrace(value: unknown): value is MothershipStreamV1Trace {
|
||
return (
|
||
isRecordLike(value) &&
|
||
typeof value.requestId === 'string' &&
|
||
isOptionalString(value.goTraceId) &&
|
||
isOptionalString(value.spanId)
|
||
)
|
||
}
|
||
|
||
function isStreamScope(value: unknown): value is MothershipStreamV1StreamScope {
|
||
return (
|
||
isRecordLike(value) &&
|
||
value.lane === 'subagent' &&
|
||
isOptionalString(value.agentId) &&
|
||
isOptionalString(value.parentToolCallId)
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Contract envelope validator (replaces Ajv runtime compilation)
|
||
//
|
||
// Validates the envelope shell (v, seq, ts, stream, trace?, scope?) and that
|
||
// `type` is one of the known event types with a non-null payload object.
|
||
// Per-payload-variant validation is intentionally lightweight: the server
|
||
// already performs strict schema validation; the client only needs enough
|
||
// structural checking to safely dispatch inside the switch statement.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const KNOWN_EVENT_TYPES: ReadonlySet<string> = new Set(Object.values(MothershipStreamV1EventType))
|
||
|
||
function isValidEnvelopeShell(value: unknown): value is JsonRecord & {
|
||
v: 1
|
||
seq: number
|
||
ts: string
|
||
stream: MothershipStreamV1StreamRef
|
||
type: string
|
||
payload: JsonRecord
|
||
} {
|
||
if (!isRecordLike(value)) return false
|
||
if (value.v !== 1) return false
|
||
if (typeof value.seq !== 'number' || !Number.isFinite(value.seq)) return false
|
||
if (typeof value.ts !== 'string') return false
|
||
if (!isStreamRef(value.stream)) return false
|
||
if (value.trace !== undefined && !isTrace(value.trace)) return false
|
||
if (value.scope !== undefined && !isStreamScope(value.scope)) return false
|
||
if (typeof value.type !== 'string' || !KNOWN_EVENT_TYPES.has(value.type)) return false
|
||
if (!isRecordLike(value.payload)) return false
|
||
return true
|
||
}
|
||
|
||
function isValidSessionPayload(payload: JsonRecord): boolean {
|
||
const kind = payload.kind
|
||
if (typeof kind !== 'string') return false
|
||
switch (kind) {
|
||
case MothershipStreamV1SessionKind.start:
|
||
return true
|
||
case MothershipStreamV1SessionKind.chat:
|
||
return typeof payload.chatId === 'string'
|
||
case MothershipStreamV1SessionKind.title:
|
||
return typeof payload.title === 'string'
|
||
case MothershipStreamV1SessionKind.trace:
|
||
return typeof payload.requestId === 'string'
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
function isValidTextPayload(payload: JsonRecord): boolean {
|
||
return (
|
||
(payload.channel === MothershipStreamV1TextChannel.assistant ||
|
||
payload.channel === MothershipStreamV1TextChannel.thinking) &&
|
||
typeof payload.text === 'string'
|
||
)
|
||
}
|
||
|
||
function isValidToolPayload(payload: JsonRecord): boolean {
|
||
if (typeof payload.toolCallId !== 'string') return false
|
||
if (typeof payload.toolName !== 'string') return false
|
||
const phase = payload.phase
|
||
return (
|
||
phase === MothershipStreamV1ToolPhase.call ||
|
||
phase === MothershipStreamV1ToolPhase.args_delta ||
|
||
phase === MothershipStreamV1ToolPhase.result
|
||
)
|
||
}
|
||
|
||
function isValidSpanPayload(payload: JsonRecord): boolean {
|
||
const kind = payload.kind
|
||
return (
|
||
kind === MothershipStreamV1SpanPayloadKind.subagent ||
|
||
kind === MothershipStreamV1SpanPayloadKind.structured_result ||
|
||
kind === MothershipStreamV1SpanPayloadKind.subagent_result
|
||
)
|
||
}
|
||
|
||
function isValidResourcePayload(payload: JsonRecord): boolean {
|
||
return (
|
||
(payload.op === MothershipStreamV1ResourceOp.upsert ||
|
||
payload.op === MothershipStreamV1ResourceOp.remove) &&
|
||
isRecordLike(payload.resource) &&
|
||
typeof (payload.resource as JsonRecord).id === 'string' &&
|
||
typeof (payload.resource as JsonRecord).type === 'string'
|
||
)
|
||
}
|
||
|
||
function isValidRunPayload(payload: JsonRecord): boolean {
|
||
const kind = payload.kind
|
||
return (
|
||
kind === MothershipStreamV1RunKind.checkpoint_pause ||
|
||
kind === MothershipStreamV1RunKind.resumed ||
|
||
kind === MothershipStreamV1RunKind.compaction_start ||
|
||
kind === MothershipStreamV1RunKind.compaction_done
|
||
)
|
||
}
|
||
|
||
function isValidErrorPayload(payload: JsonRecord): boolean {
|
||
return typeof payload.message === 'string' || typeof payload.error === 'string'
|
||
}
|
||
|
||
function isValidCompletePayload(payload: JsonRecord): boolean {
|
||
return typeof payload.status === 'string'
|
||
}
|
||
|
||
function isContractEnvelope(value: unknown): value is MothershipStreamV1EventEnvelope {
|
||
if (!isValidEnvelopeShell(value)) return false
|
||
const payload = value.payload as JsonRecord
|
||
switch (value.type) {
|
||
case MothershipStreamV1EventType.session:
|
||
return isValidSessionPayload(payload)
|
||
case MothershipStreamV1EventType.text:
|
||
return isValidTextPayload(payload)
|
||
case MothershipStreamV1EventType.tool:
|
||
return isValidToolPayload(payload)
|
||
case MothershipStreamV1EventType.span:
|
||
return isValidSpanPayload(payload)
|
||
case MothershipStreamV1EventType.resource:
|
||
return isValidResourcePayload(payload)
|
||
case MothershipStreamV1EventType.run:
|
||
return isValidRunPayload(payload)
|
||
case MothershipStreamV1EventType.error:
|
||
return isValidErrorPayload(payload)
|
||
case MothershipStreamV1EventType.complete:
|
||
return isValidCompletePayload(payload)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Synthetic file-preview envelope validators
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function isSyntheticEnvelopeBase(value: unknown): value is Omit<
|
||
SyntheticFilePreviewEventEnvelope,
|
||
'payload'
|
||
> & {
|
||
payload?: unknown
|
||
} {
|
||
return (
|
||
isRecordLike(value) &&
|
||
value.v === 1 &&
|
||
value.type === 'tool' &&
|
||
typeof value.seq === 'number' &&
|
||
Number.isFinite(value.seq) &&
|
||
typeof value.ts === 'string' &&
|
||
isStreamRef(value.stream) &&
|
||
(value.trace === undefined || isTrace(value.trace)) &&
|
||
(value.scope === undefined || isStreamScope(value.scope))
|
||
)
|
||
}
|
||
|
||
function isSyntheticFilePreviewTarget(value: unknown): value is SyntheticFilePreviewTarget {
|
||
return (
|
||
isRecordLike(value) &&
|
||
(value.kind === 'new_file' || value.kind === 'file_id') &&
|
||
isOptionalString(value.fileId) &&
|
||
isOptionalString(value.fileName)
|
||
)
|
||
}
|
||
|
||
function isSyntheticFilePreviewPayload(value: unknown): value is SyntheticFilePreviewPayload {
|
||
if (!isRecordLike(value)) {
|
||
return false
|
||
}
|
||
|
||
if (typeof value.toolCallId !== 'string' || value.toolName !== 'workspace_file') {
|
||
return false
|
||
}
|
||
|
||
switch (value.previewPhase) {
|
||
case FILE_PREVIEW_PHASE.start:
|
||
return true
|
||
case FILE_PREVIEW_PHASE.target:
|
||
return (
|
||
isSyntheticFilePreviewTarget(value.target) &&
|
||
isOptionalString(value.operation) &&
|
||
isOptionalString(value.title)
|
||
)
|
||
case FILE_PREVIEW_PHASE.editMeta:
|
||
return isRecordLike(value.edit)
|
||
case FILE_PREVIEW_PHASE.content:
|
||
return (
|
||
typeof value.content === 'string' &&
|
||
(value.contentMode === 'delta' || value.contentMode === 'snapshot') &&
|
||
typeof value.previewVersion === 'number' &&
|
||
Number.isFinite(value.previewVersion) &&
|
||
typeof value.fileName === 'string' &&
|
||
isOptionalString(value.fileId) &&
|
||
isOptionalString(value.targetKind) &&
|
||
isOptionalString(value.operation) &&
|
||
(value.edit === undefined || isRecordLike(value.edit))
|
||
)
|
||
case FILE_PREVIEW_PHASE.complete:
|
||
return isOptionalString(value.fileId) && isOptionalFiniteNumber(value.previewVersion)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
export function isSyntheticFilePreviewEventEnvelope(
|
||
value: unknown
|
||
): value is SyntheticFilePreviewEventEnvelope {
|
||
return isSyntheticEnvelopeBase(value) && isSyntheticFilePreviewPayload(value.payload)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stream event type guards
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function isToolCallStreamEvent(event: SessionStreamEvent): event is ToolCallStreamEvent {
|
||
return event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'call'
|
||
}
|
||
|
||
export function isToolArgsDeltaStreamEvent(
|
||
event: SessionStreamEvent
|
||
): event is ToolArgsDeltaStreamEvent {
|
||
return (
|
||
event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'args_delta'
|
||
)
|
||
}
|
||
|
||
export function isToolResultStreamEvent(event: SessionStreamEvent): event is ToolResultStreamEvent {
|
||
return event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'result'
|
||
}
|
||
|
||
export function isSubagentSpanStreamEvent(
|
||
event: SessionStreamEvent
|
||
): event is SubagentSpanStreamEvent {
|
||
return event.type === 'span' && isRecordLike(event.payload) && event.payload.kind === 'subagent'
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Public contract validators & parsers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function isContractStreamEventEnvelope(
|
||
value: unknown
|
||
): value is MothershipStreamV1EventEnvelope {
|
||
return isContractEnvelope(value)
|
||
}
|
||
|
||
export function parsePersistedStreamEventEnvelope(value: unknown): ParseStreamEventEnvelopeResult {
|
||
if (isContractEnvelope(value)) {
|
||
return { ok: true, event: value }
|
||
}
|
||
|
||
if (isSyntheticFilePreviewEventEnvelope(value)) {
|
||
return { ok: true, event: value }
|
||
}
|
||
|
||
const hints: string[] = []
|
||
if (!isRecordLike(value)) {
|
||
hints.push('value is not an object')
|
||
} else {
|
||
if (value.v !== 1) hints.push(`unexpected v=${JSON.stringify(value.v)}`)
|
||
if (typeof value.type !== 'string') hints.push('missing type')
|
||
else if (!KNOWN_EVENT_TYPES.has(value.type)) hints.push(`unknown type="${value.type}"`)
|
||
if (!isRecordLike(value.payload)) hints.push('missing or invalid payload')
|
||
}
|
||
|
||
return {
|
||
ok: false,
|
||
reason: 'invalid_stream_event',
|
||
message: 'A stream event failed validation.',
|
||
...(hints.length > 0 ? { errors: hints } : {}),
|
||
}
|
||
}
|
||
|
||
export function parsePersistedStreamEventEnvelopeJson(raw: string): ParseStreamEventEnvelopeResult {
|
||
let parsed: unknown
|
||
try {
|
||
parsed = JSON.parse(raw)
|
||
} catch (error) {
|
||
const rawMessage = getErrorMessage(error, 'Invalid JSON')
|
||
return {
|
||
ok: false,
|
||
reason: 'invalid_json',
|
||
message: 'Received invalid JSON while parsing a stream event.',
|
||
...(rawMessage ? { errors: [rawMessage] } : {}),
|
||
}
|
||
}
|
||
|
||
return parsePersistedStreamEventEnvelope(parsed)
|
||
}
|