diff --git a/contracts/events.ts b/contracts/events.ts new file mode 100644 index 0000000..e6b3130 --- /dev/null +++ b/contracts/events.ts @@ -0,0 +1,115 @@ +/** + * 协议 v1 的事件模型(JSON Schema 为准,这里是 TS 镜像)。 + * + * 界面只依赖这一套事件:引擎换 Pi 还是 Codex,事件不变(架构设计 5.2)。 + * 改事件必须同时改:contracts/schemas/events.schema.json、本文件、docs/spec/protocol.md。 + */ +import type { ExecutionReceipt } from './protocol.js'; + +export const PROTOCOL_VERSION = 1; + +/** 所有事件共有的信封字段 */ +export interface EventEnvelope { + /** 协议版本;改协议就加版本,不改这一版 */ + v: typeof PROTOCOL_VERSION; + /** 会话内单调递增:断点续传与去重靠它 */ + seq: number; + /** 整数毫秒(ADR-0007) */ + ts: number; + sessionId: string; + /** 一次运行(回合)的标识 */ + runId?: string; + type: TType; + data: TData; +} + +export type ExecutionStatus = ExecutionReceipt['status']; + +export interface MessageDeltaData { + messageId: string; + text: string; +} + +export interface MessageCompletedData { + messageId: string; + finishReason?: 'stop' | 'cancelled' | 'error' | 'length'; +} + +export interface ToolCallData { + callId: string; + capabilityId: string; + /** 参数摘要,不是参数明文 */ + argsDigest: string; +} + +export interface ToolResultData { + callId: string; + executionId: string; + status: ExecutionStatus; +} + +export interface ExecutionUpdatedData { + executionId: string; + status: ExecutionStatus; + errorKind?: string; +} + +export interface ApprovalRequestedData { + approvalId: string; + capabilityId: string; + paramsDigest: string; + /** 确认卡正文:影响范围与关键参数摘要 */ + impactSummary: string; + /** 目标数据版本;版本变了确认就失效 */ + revision: number; + expiresAt: number; +} + +export interface ApprovalResolvedData { + approvalId: string; + decision: 'approved' | 'rejected' | 'expired'; +} + +export interface RunStateData { + state: 'idle' | 'running' | 'cancelling' | 'cancelled'; +} + +export interface SessionUpdatedData { + title?: string; + status?: 'active' | 'archived'; +} + +export interface ErrorData { + code: string; + message: string; + retryable: boolean; + errorKind?: string; +} + +export type MessageDeltaEvent = EventEnvelope<'message.delta', MessageDeltaData>; +export type MessageCompletedEvent = EventEnvelope<'message.completed', MessageCompletedData>; +export type ToolCallEvent = EventEnvelope<'tool.call', ToolCallData>; +export type ToolResultEvent = EventEnvelope<'tool.result', ToolResultData>; +export type ExecutionUpdatedEvent = EventEnvelope<'execution.updated', ExecutionUpdatedData>; +export type ApprovalRequestedEvent = EventEnvelope<'approval.requested', ApprovalRequestedData>; +export type ApprovalResolvedEvent = EventEnvelope<'approval.resolved', ApprovalResolvedData>; +export type RunStateEvent = EventEnvelope<'run.state', RunStateData>; +export type SessionUpdatedEvent = EventEnvelope<'session.updated', SessionUpdatedData>; +export type ErrorEvent = EventEnvelope<'error', ErrorData>; +export type HeartbeatEvent = EventEnvelope<'heartbeat', Record>; + +/** 平台事件:客户端唯一认识的事件形态 */ +export type PlatformEvent = + | MessageDeltaEvent + | MessageCompletedEvent + | ToolCallEvent + | ToolResultEvent + | ExecutionUpdatedEvent + | ApprovalRequestedEvent + | ApprovalResolvedEvent + | RunStateEvent + | SessionUpdatedEvent + | ErrorEvent + | HeartbeatEvent; + +export type PlatformEventType = PlatformEvent['type']; \ No newline at end of file diff --git a/contracts/fixtures/events.jsonl b/contracts/fixtures/events.jsonl new file mode 100644 index 0000000..fe23e09 --- /dev/null +++ b/contracts/fixtures/events.jsonl @@ -0,0 +1,11 @@ +{"v":1,"seq":1,"ts":1757404800000,"sessionId":"s-1","runId":"r-1","type":"message.delta","data":{"messageId":"m-1","text":"正在核算产能"}} +{"v":1,"seq":2,"ts":1757404800100,"sessionId":"s-1","runId":"r-1","type":"message.completed","data":{"messageId":"m-1","finishReason":"stop"}} +{"v":1,"seq":3,"ts":1757404800200,"sessionId":"s-1","runId":"r-1","type":"tool.call","data":{"callId":"c-1","capabilityId":"aps.schedule.preview","argsDigest":"sha256:9f2a"}} +{"v":1,"seq":4,"ts":1757404800300,"sessionId":"s-1","runId":"r-1","type":"tool.result","data":{"callId":"c-1","executionId":"exec-1","status":"accepted"}} +{"v":1,"seq":5,"ts":1757404800400,"sessionId":"s-1","runId":"r-1","type":"execution.updated","data":{"executionId":"exec-1","status":"succeeded"}} +{"v":1,"seq":6,"ts":1757404800500,"sessionId":"s-1","runId":"r-1","type":"approval.requested","data":{"approvalId":"apr-1","capabilityId":"aps.schedule.publish","paramsDigest":"sha256:1c4d","impactSummary":"发布 2026-10 排产方案,影响 12 个订单","revision":37,"expiresAt":1757408400000}} +{"v":1,"seq":7,"ts":1757404800600,"sessionId":"s-1","runId":"r-1","type":"approval.resolved","data":{"approvalId":"apr-1","decision":"approved"}} +{"v":1,"seq":8,"ts":1757404800700,"sessionId":"s-1","runId":"r-1","type":"run.state","data":{"state":"idle"}} +{"v":1,"seq":9,"ts":1757404800800,"sessionId":"s-1","type":"session.updated","data":{"title":"十月排产核对","status":"active"}} +{"v":1,"seq":10,"ts":1757404800900,"sessionId":"s-1","type":"error","data":{"code":"provider.timeout","message":"APS 求解超时","retryable":true,"errorKind":"timeout"}} +{"v":1,"seq":11,"ts":1757404801000,"sessionId":"s-1","type":"heartbeat","data":{}} \ No newline at end of file diff --git a/contracts/fixtures/http.json b/contracts/fixtures/http.json new file mode 100644 index 0000000..f06f2ca --- /dev/null +++ b/contracts/fixtures/http.json @@ -0,0 +1,29 @@ +{ + "session": { + "schema": "session.schema.json", + "value": { "sessionId": "s-1", "projectId": "p-1", "scope": "project", "status": "active", "title": "十月排产核对", "createdAt": 1757404800000 } + }, + "capability": { + "schema": "capability.schema.json", + "value": { + "id": "aps.schedule.preview", + "providerId": "aps", + "title": "试排一版", + "purpose": "按当前项目数据试排一版方案,只出结果不发布", + "effect": "read", + "synonyms": ["试排", "跑一版", "预排"] + } + }, + "receipt": { + "schema": "receipt.schema.json", + "value": { "executionId": "exec-1", "status": "pending-confirmation", "approvalId": "apr-1" } + }, + "approvalDecision": { + "schema": "approval.schema.json", + "value": { "decision": "approve", "revision": 37, "idempotencyKey": "idem-2f8a1c9d", "note": "按会议结论发布" } + }, + "error": { + "schema": "error.schema.json", + "value": { "error": { "code": "auth.expired", "message": "登录态已过期", "retryable": false } } + } +} \ No newline at end of file diff --git a/contracts/index.ts b/contracts/index.ts index 335a9e6..cce576e 100644 --- a/contracts/index.ts +++ b/contracts/index.ts @@ -2,22 +2,10 @@ * 客户端与服务端共享的契约(架构设计第 2 节)。 * * 只放类型与模式定义,不引入运行时依赖:这一层被所有包依赖,任何依赖都会变成全局耦合。 - * 引擎差异不在这里体现——引擎事件经适配层转成下面的平台事件,界面对引擎无感。 + * JSON Schema 在 contracts/schemas/ 是规范本体,本包里的 TS 是镜像;两者由 + * scripts/check-contracts.test.mjs 卡漂移。 */ +export * from './events.js'; +export * from './protocol.js'; -/** 能力标识,形如 `aps.schedule.preview`。命名与描述规范见 docs/spec/capability-description.md */ -export type CapabilityId = string; - -/** 执行回执:所有能力调用的统一返回(架构设计 4.2) */ -export interface ExecutionReceipt { - executionId: string; - status: 'accepted' | 'succeeded' | 'failed' | 'pending-confirmation'; - result?: unknown; - errorKind?: string; -} - -/** 平台事件:唯一面向客户端的事件形态(实施设计 1.7 定义事件模型) */ -export type PlatformEvent = - | { type: 'message.delta'; sessionId: string; text: string } - | { type: 'tool.call'; sessionId: string; capabilityId: CapabilityId } - | { type: 'receipt'; sessionId: string; receipt: ExecutionReceipt }; \ No newline at end of file +export const CONTRACTS_PACKAGE = '@aps/contracts'; \ No newline at end of file diff --git a/contracts/protocol.ts b/contracts/protocol.ts new file mode 100644 index 0000000..442736d --- /dev/null +++ b/contracts/protocol.ts @@ -0,0 +1,142 @@ +/** + * 协议 v1 的 HTTP 表面(JSON Schema 为准,这里是 TS 镜像)。 + * + * 服务端与客户端之间只认这一套接口;引擎协议(Pi 的 JSONL、Codex 的 app-server)不越过适配层。 + * 端点与规则见 docs/spec/protocol.md。 + */ + +/** 路径前缀:协议改版就换前缀,不原地改语义 */ +export const PROTOCOL_PREFIX = '/v1' as const; + +export const ENDPOINTS = { + health: '/v1/health', + me: '/v1/me', + projects: '/v1/projects', + sessions: '/v1/sessions', + session: (sessionId: string) => `/v1/sessions/${sessionId}`, + /** SSE:回放 + 实时事件流 */ + sessionEvents: (sessionId: string) => `/v1/sessions/${sessionId}/events`, + sessionMessages: (sessionId: string) => `/v1/sessions/${sessionId}/messages`, + sessionCancel: (sessionId: string) => `/v1/sessions/${sessionId}/cancel`, + capabilities: '/v1/capabilities', + execution: (executionId: string) => `/v1/executions/${executionId}`, + approval: (approvalId: string) => `/v1/approvals/${approvalId}`, +} as const; + +/** 能力标识,形如 `aps.schedule.preview` */ +export type CapabilityId = string; + +export interface HealthResponse { + status: 'ok'; + version: string; + protocolVersion: number; +} + +export interface MeResponse { + userId: string; + tenantId: string; + displayName: string; + /** 桌面端与浏览器共用同一身份模型,登录方式不同(见协议文档第 2 节) */ + clientKind: 'desktop' | 'browser'; +} + +/** 能力条目:按身份过滤后返回;描述七要素见 docs/spec/capability-description.md */ +export interface CapabilitySummary { + id: CapabilityId; + providerId: string; + title: string; + purpose: string; + effect: 'read' | 'draft' | 'write'; + inputSchema?: Record; + synonyms?: string[]; +} + +export interface CapabilitiesResponse { + capabilities: CapabilitySummary[]; +} + +/** 会话概要(ADR-0003:个人会话只读,写操作必须在项目会话内) */ +export interface SessionSummary { + sessionId: string; + projectId: string | null; + scope: 'personal' | 'project'; + status: 'active' | 'archived'; + title: string; + createdAt: number; +} + +export interface CreateSessionRequest { + projectId?: string; + scope: 'personal' | 'project'; + title?: string; +} + +export interface CreateSessionResponse { + session: SessionSummary; +} + +export interface SessionListResponse { + sessions: SessionSummary[]; +} + +export interface SessionDetailResponse { + session: SessionSummary; + /** 最近一段消息;完整历史走事件流回放 */ + messages: ChatMessage[]; +} + +export interface ChatMessage { + messageId: string; + role: 'user' | 'assistant' | 'system'; + ts: number; + text: string; +} + +export interface SendMessageRequest { + text: string; + /** 同一写请求重试不重复生效 */ + idempotencyKey: string; +} + +export interface SendMessageResponse { + accepted: true; + messageId: string; + runId: string; +} + +export interface CancelResponse { + state: 'cancelling' | 'idle'; +} + +/** 能力调用的统一回执(架构设计 4.2) */ +export interface ExecutionReceipt { + executionId: string; + status: 'accepted' | 'pending-confirmation' | 'succeeded' | 'failed'; + result?: unknown; + errorKind?: string; + /** status = pending-confirmation 时给出待确认动作标识 */ + approvalId?: string; +} + +export interface ApprovalDecisionRequest { + decision: 'approve' | 'reject'; + /** 目标数据版本;版本变了确认就失效 */ + revision: number; + idempotencyKey: string; + note?: string; +} + +export interface ApprovalDecisionResponse { + approvalId: string; + decision: 'approved' | 'rejected' | 'expired'; + receipt?: ExecutionReceipt; +} + +export interface ErrorEnvelope { + error: { + code: string; + message: string; + retryable: boolean; + detail?: unknown; + }; +} \ No newline at end of file diff --git a/contracts/schemas/approval.schema.json b/contracts/schemas/approval.schema.json new file mode 100644 index 0000000..5f44512 --- /dev/null +++ b/contracts/schemas/approval.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aps.local/contracts/approval.schema.json", + "title": "ApprovalDecisionRequest", + "description": "客户端对确认卡的响应(架构设计 4.2:确认卡防的是误解意图,不是防越权)。", + "type": "object", + "required": ["decision", "revision", "idempotencyKey"], + "properties": { + "decision": { "type": "string", "enum": ["approve", "reject"] }, + "revision": { "type": "integer", "minimum": 0 }, + "idempotencyKey": { "type": "string", "minLength": 8 }, + "note": { "type": "string" } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/contracts/schemas/capability.schema.json b/contracts/schemas/capability.schema.json new file mode 100644 index 0000000..acab73e --- /dev/null +++ b/contracts/schemas/capability.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aps.local/contracts/capability.schema.json", + "title": "CapabilitySummary", + "description": "按身份过滤后交给客户端与引擎的能力条目。描述七要素见 docs/spec/capability-description.md。", + "type": "object", + "required": ["id", "providerId", "title", "purpose", "effect"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$" }, + "providerId": { "type": "string" }, + "title": { "type": "string" }, + "purpose": { "type": "string", "description": "一句话用途,40 字以内" }, + "effect": { "type": "string", "enum": ["read", "draft", "write"] }, + "inputSchema": { "type": "object" }, + "synonyms": { "type": "array", "items": { "type": "string" }, "description": "业务黑话与同义词" } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/contracts/schemas/envelope.schema.json b/contracts/schemas/envelope.schema.json new file mode 100644 index 0000000..858a820 --- /dev/null +++ b/contracts/schemas/envelope.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aps.local/contracts/envelope.schema.json", + "title": "EventEnvelope", + "description": "服务端推给客户端的事件信封(协议 v1)。界面只依赖这一层,不感知引擎差异。", + "$defs": { + "status": { + "type": "string", + "enum": ["accepted", "pending-confirmation", "succeeded", "failed"] + }, + "envelope": { + "type": "object", + "required": ["v", "seq", "ts", "sessionId", "type", "data"], + "properties": { + "v": { "const": 1, "description": "协议版本;改协议就加版本,不改这一版" }, + "seq": { "type": "integer", "minimum": 1, "description": "会话内单调递增;断点续传与去重靠它" }, + "ts": { "type": "integer", "description": "发生时间,整数毫秒(ADR-0007)" }, + "sessionId": { "type": "string", "minLength": 1 }, + "runId": { "type": "string", "description": "一次运行(回合)的标识;同会话可先后多个 run" }, + "type": { "type": "string" }, + "data": { "type": "object" } + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/contracts/schemas/error.schema.json b/contracts/schemas/error.schema.json new file mode 100644 index 0000000..212af85 --- /dev/null +++ b/contracts/schemas/error.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aps.local/contracts/error.schema.json", + "title": "ErrorEnvelope", + "description": "HTTP 错误统一形状;SSE 里的错误见 events.schema.json 的 error 事件。", + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message", "retryable"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" }, + "retryable": { "type": "boolean" }, + "detail": {} + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/contracts/schemas/events.schema.json b/contracts/schemas/events.schema.json new file mode 100644 index 0000000..c4ebfbf --- /dev/null +++ b/contracts/schemas/events.schema.json @@ -0,0 +1,191 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aps.local/contracts/events.schema.json", + "title": "PlatformEvent", + "description": "协议 v1 的全部事件类型。新增事件类型必须同时改这里、contracts/events.ts 与 docs/spec/protocol.md。", + "$ref": "#/$defs/event", + "$defs": { + "envelope": { + "allOf": [{ "$ref": "envelope.schema.json#/$defs/envelope" }] + }, + "messageDelta": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "message.delta" }, + "data": { + "type": "object", + "required": ["messageId", "text"], + "properties": { + "messageId": { "type": "string" }, + "text": { "type": "string" } + }, + "additionalProperties": false + } + } + }, + "messageCompleted": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "message.completed" }, + "data": { + "type": "object", + "required": ["messageId"], + "properties": { + "messageId": { "type": "string" }, + "finishReason": { "type": "string", "enum": ["stop", "cancelled", "error", "length"] } + }, + "additionalProperties": false + } + } + }, + "toolCall": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "tool.call" }, + "data": { + "type": "object", + "required": ["callId", "capabilityId", "argsDigest"], + "properties": { + "callId": { "type": "string" }, + "capabilityId": { "type": "string" }, + "argsDigest": { "type": "string", "description": "参数摘要,不是参数明文" } + }, + "additionalProperties": false + } + } + }, + "toolResult": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "tool.result" }, + "data": { + "type": "object", + "required": ["callId", "executionId", "status"], + "properties": { + "callId": { "type": "string" }, + "executionId": { "type": "string" }, + "status": { "$ref": "envelope.schema.json#/$defs/status" } + }, + "additionalProperties": false + } + } + }, + "executionUpdated": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "execution.updated" }, + "data": { + "type": "object", + "required": ["executionId", "status"], + "properties": { + "executionId": { "type": "string" }, + "status": { "$ref": "envelope.schema.json#/$defs/status" }, + "errorKind": { "type": "string" } + }, + "additionalProperties": false + } + } + }, + "approvalRequested": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "approval.requested" }, + "data": { + "type": "object", + "required": ["approvalId", "capabilityId", "paramsDigest", "impactSummary", "revision", "expiresAt"], + "properties": { + "approvalId": { "type": "string" }, + "capabilityId": { "type": "string" }, + "paramsDigest": { "type": "string" }, + "impactSummary": { "type": "string", "description": "确认卡正文:影响范围与关键参数摘要" }, + "revision": { "type": "integer", "minimum": 0, "description": "目标数据版本;版本变了确认就失效" }, + "expiresAt": { "type": "integer" } + }, + "additionalProperties": false + } + } + }, + "approvalResolved": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "approval.resolved" }, + "data": { + "type": "object", + "required": ["approvalId", "decision"], + "properties": { + "approvalId": { "type": "string" }, + "decision": { "type": "string", "enum": ["approved", "rejected", "expired"] } + }, + "additionalProperties": false + } + } + }, + "runState": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "run.state" }, + "data": { + "type": "object", + "required": ["state"], + "properties": { + "state": { "type": "string", "enum": ["idle", "running", "cancelling", "cancelled"] } + }, + "additionalProperties": false + } + } + }, + "sessionUpdated": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "session.updated" }, + "data": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "status": { "type": "string", "enum": ["active", "archived"] } + }, + "additionalProperties": false + } + } + }, + "error": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "error" }, + "data": { + "type": "object", + "required": ["code", "message", "retryable"], + "properties": { + "code": { "type": "string" }, + "message": { "type": "string" }, + "retryable": { "type": "boolean" }, + "errorKind": { "type": "string" } + }, + "additionalProperties": false + } + } + }, + "heartbeat": { + "allOf": [{ "$ref": "#/$defs/envelope" }], + "properties": { + "type": { "const": "heartbeat" }, + "data": { "type": "object", "additionalProperties": false } + } + }, + "event": { + "oneOf": [ + { "$ref": "#/$defs/messageDelta" }, + { "$ref": "#/$defs/messageCompleted" }, + { "$ref": "#/$defs/toolCall" }, + { "$ref": "#/$defs/toolResult" }, + { "$ref": "#/$defs/executionUpdated" }, + { "$ref": "#/$defs/approvalRequested" }, + { "$ref": "#/$defs/approvalResolved" }, + { "$ref": "#/$defs/runState" }, + { "$ref": "#/$defs/sessionUpdated" }, + { "$ref": "#/$defs/error" }, + { "$ref": "#/$defs/heartbeat" } + ] + } + } +} \ No newline at end of file diff --git a/contracts/schemas/receipt.schema.json b/contracts/schemas/receipt.schema.json new file mode 100644 index 0000000..3f4494d --- /dev/null +++ b/contracts/schemas/receipt.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aps.local/contracts/receipt.schema.json", + "title": "ExecutionReceipt", + "description": "能力调用的统一回执(架构设计 4.2)。", + "type": "object", + "required": ["executionId", "status"], + "properties": { + "executionId": { "type": "string" }, + "status": { "$ref": "envelope.schema.json#/$defs/status" }, + "result": {}, + "errorKind": { "type": "string" }, + "approvalId": { "type": "string", "description": "status = pending-confirmation 时给出待确认动作标识" } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/contracts/schemas/session.schema.json b/contracts/schemas/session.schema.json new file mode 100644 index 0000000..721653f --- /dev/null +++ b/contracts/schemas/session.schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aps.local/contracts/session.schema.json", + "title": "SessionSummary", + "description": "会话概要(ADR-0003:个人会话只读,写操作必须在项目会话内)。", + "type": "object", + "required": ["sessionId", "scope", "status", "title", "createdAt"], + "properties": { + "sessionId": { "type": "string" }, + "projectId": { "type": ["string", "null"] }, + "scope": { "type": "string", "enum": ["personal", "project"] }, + "status": { "type": "string", "enum": ["active", "archived"] }, + "title": { "type": "string" }, + "createdAt": { "type": "integer" } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/contracts/tsconfig.json b/contracts/tsconfig.json index d1e92f8..97d1338 100644 --- a/contracts/tsconfig.json +++ b/contracts/tsconfig.json @@ -5,5 +5,5 @@ "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" }, - "include": ["index.ts"] + "include": ["*.ts"] } \ No newline at end of file diff --git a/docs/spec/protocol.md b/docs/spec/protocol.md new file mode 100644 index 0000000..3928541 --- /dev/null +++ b/docs/spec/protocol.md @@ -0,0 +1,107 @@ +# 客户端与服务端协议(v1) + +> 契约本体在 `contracts/schemas/`(JSON Schema),TS 镜像在 `contracts/`,漂移由 `scripts/check-contracts.test.mjs` 卡。本文是给人看的同一份东西。 + +## 1 范围与所有权 + +服务端与客户端之间只认这一套接口。引擎协议(Pi 的 JSONL、Codex 的 app-server)只活在 `server/agent` 内部,客户端与 Core 都不感知——协议的所有权比协议的兼容性重要(架构设计 5.2)。 + +界面只依赖本文的事件模型:换引擎、换模型,事件不变。 + +## 2 版本与兼容规则 + +| 规则 | 说明 | +| --- | --- | +| 路径版本 | 前缀 `{PROTOCOL_PREFIX}` = `/v1`;改语义就换前缀,不原地改 | +| 事件版本 | 每个事件带 `v`,当前 `1`;字段只加不改不删 | +| 删除字段 | 先废弃(保留但不再填充)→ 下一个大版本再删 | +| 未知事件 | 客户端必须忽略不认识的 `type`,不能崩 | +| 未知字段 | 客户端必须忽略,不能因多字段报错 | + +## 3 认证与身份 + +| 客户端 | 方式 | 说明 | +| --- | --- | --- | +| 浏览器 | `Cookie: ap_session=` | 沿用现有 JMS 登录;服务端不把业务系统 token 下发给客户端 | +| 桌面端 | `Authorization: Bearer ` | 授权码 + 设备绑定换来的短期令牌(沿用现有授权方式) | + +两者都由 `server/entry` 解析成同一个 Subject(用户 + 租户),再交给应用服务与 Core。首次调用的返回值就是 `MeResponse`。 + +## 4 端点 + +| 方法 | 路径 | 请求 | 响应 | 说明 | +| --- | --- | --- | --- | --- | +| GET | `/v1/health` | — | `HealthResponse` | 健康检查 | +| GET | `/v1/me` | — | `MeResponse` | 当前身份与客户端形态 | +| GET | `/v1/projects` | — | `ProjectSummary[]` | 可见项目 | +| GET | `/v1/sessions` | `?projectId=` | `SessionListResponse` | 会话列表 | +| POST | `/v1/sessions` | `CreateSessionRequest` | `CreateSessionResponse` | 建会话;写操作必须建在项目会话(ADR-0003) | +| GET | `/v1/sessions/{id}` | `?limit=` | `SessionDetailResponse` | 会话详情与最近消息 | +| GET | `/v1/sessions/{id}/events` | `Last-Event-ID` 或 `?since=` | SSE 事件流 | 回放 + 实时,见第 5 节 | +| POST | `/v1/sessions/{id}/messages` | `SendMessageRequest` | `SendMessageResponse` | 发消息;返回受理,结果走事件流 | +| POST | `/v1/sessions/{id}/cancel` | — | `CancelResponse` | 取消当前运行 | +| GET | `/v1/capabilities` | `?sessionId=` | `CapabilitiesResponse` | 按身份过滤的能力清单 | +| GET | `/v1/executions/{executionId}` | — | `ExecutionReceipt` | 执行状态与回执 | +| POST | `/v1/approvals/{approvalId}` | `ApprovalDecisionRequest` | `ApprovalDecisionResponse` | 确认卡响应 | + +## 5 事件流(SSE) + +| 规则 | 说明 | +| --- | --- | +| 传输 | `text/event-stream`;`event:` 填事件 `type`,`data:` 填整条事件 JSON | +| 序号 | `seq` 在会话内单调递增,从 1 开始;客户端按它去重与排序 | +| 断点续传 | 客户端带 `Last-Event-ID`(或 `?since=`)重连,服务端从该序号之后回放 | +| 回放 | 回放与实时用同一格式,客户端不需要两套解析 | +| 心跳 | 无事件时按固定间隔发 `heartbeat`,用于探活与代理保活 | +| 顺序 | 同一会话的事件严格有序;不同会话之间不保证 | +| 敏感 | 事件里只有参数摘要(`argsDigest`、`paramsDigest`),没有凭据与参数明文 | + +## 6 事件清单 + +| 事件 | 含义 | `data` 关键字段 | +| --- | --- | --- | +| `message.delta` | 助手增量文本 | `messageId`、`text` | +| `message.completed` | 一条助手消息结束 | `messageId`、`finishReason` | +| `tool.call` | 引擎请求调用能力 | `callId`、`capabilityId`、`argsDigest` | +| `tool.result` | 能力调用的回执 | `callId`、`executionId`、`status` | +| `execution.updated` | 执行状态变化 | `executionId`、`status`、`errorKind` | +| `approval.requested` | 需要用户确认 | `approvalId`、`capabilityId`、`paramsDigest`、`impactSummary`、`revision`、`expiresAt` | +| `approval.resolved` | 确认结果 | `approvalId`、`decision` | +| `run.state` | 当前运行状态 | `state`(`idle`/`running`/`cancelling`/`cancelled`) | +| `session.updated` | 会话标题或状态变化 | `title`、`status` | +| `error` | 错误 | `code`、`message`、`retryable` | +| `heartbeat` | 保活 | 空对象 | + +`status` 取值统一为 `accepted` / `pending-confirmation` / `succeeded` / `failed`。 + +## 7 审批时序 + +1. 引擎请求写能力 → Core 判定需确认 → 落一条待确认动作(ADR-0003、架构设计 4.2) +2. 服务端推 `approval.requested`,客户端渲染确认卡(影响范围、参数摘要、目标版本、有效期) +3. 用户点批准 → `POST /v1/approvals/{id}`,带 `revision` 与 `idempotencyKey` +4. 服务端校验版本与有效期 → 推 `approval.resolved` → 执行 → 推 `execution.updated` +5. 版本过期或已执行 → 返回 `expired`,客户端提示重新确认;确认人是会话发起人本人 + +## 8 错误模型 + +| 位置 | 形状 | 说明 | +| --- | --- | --- | +| HTTP | `ErrorEnvelope` | 请求本身失败(鉴权、参数、找不到) | +| SSE | `error` 事件 | 运行中失败;`retryable` 表示可重试,客户端据此给"重试"按钮 | + +## 9 幂等与取消 + +| 场景 | 规则 | +| --- | --- | +| 发消息 | 带 `idempotencyKey`;同一 key 重试不产生第二个 run | +| 审批 | 带 `idempotencyKey`;同一 key 只生效一次 | +| 取消 | 幂等:重复取消不报错;已结束的运行返回 `idle` | +| 结果未知 | 执行超时返回 `failed` 且 `errorKind = unknown`,由客户端提示查执行记录,不自动重试写操作 | + +## 10 变更规则 + +改协议必须同时改三处,否则 `pnpm test` 会失败: + +1. `contracts/schemas/*.json`(规范本体) +2. `contracts/events.ts` / `contracts/protocol.ts`(TS 镜像) +3. 本文档(第 4、6 节的表) \ No newline at end of file diff --git a/package.json b/package.json index 85285ab..487e315 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "node --test \"scripts/**/*.test.mjs\"" }, "devDependencies": { + "ajv": "^8.20.0", "typescript": "^7.0.2" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9328308..e28d4a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + ajv: + specifier: ^8.20.0 + version: 8.20.0 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -168,6 +171,22 @@ packages: cpu: [x64] os: [win32] + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -235,6 +254,21 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + json-schema-traverse@1.0.0: {} + + require-from-string@2.0.2: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/scripts/check-contracts.test.mjs b/scripts/check-contracts.test.mjs new file mode 100644 index 0000000..6308348 --- /dev/null +++ b/scripts/check-contracts.test.mjs @@ -0,0 +1,87 @@ +/** + * 契约漂移检查(任务 A1.7)。 + * + * 三件事: + * 1. 事件与 HTTP 样例必须符合 contracts/schemas 下的 JSON Schema + * 2. 事件类型在 schema、TS 镜像、协议文档三处必须完全一致 + * 3. 每种事件类型都要有样例,避免新增类型忘了配样例 + */ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import Ajv2020 from 'ajv/dist/2020.js'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const SCHEMA_DIR = join(ROOT, 'contracts', 'schemas'); +const EVENTS_SCHEMA_ID = 'https://aps.local/contracts/events.schema.json'; + +const readText = (rel) => readFile(join(ROOT, rel), 'utf8'); + +async function buildAjv() { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + for (const name of await readdir(SCHEMA_DIR)) { + if (!name.endsWith('.json')) continue; + ajv.addSchema(JSON.parse(await readFile(join(SCHEMA_DIR, name), 'utf8'))); + } + return ajv; +} + +const formatErrors = (errors) => (errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).join('; '); + +test('事件样例符合 events.schema.json', async () => { + const ajv = await buildAjv(); + const validate = ajv.getSchema(EVENTS_SCHEMA_ID); + assert.ok(validate, '未注册 events.schema.json'); + const lines = (await readText('contracts/fixtures/events.jsonl')).split(/\r?\n/).filter((l) => l.trim()); + assert.ok(lines.length >= 11, `样例太少:${lines.length}`); + for (const line of lines) { + const event = JSON.parse(line); + assert.ok(validate(event), `${event.type} 样例不符合 schema:${formatErrors(validate.errors)}`); + } +}); + +test('HTTP 样例符合各自 schema', async () => { + const ajv = await buildAjv(); + const cases = JSON.parse(await readText('contracts/fixtures/http.json')); + for (const [name, testCase] of Object.entries(cases)) { + const schemaId = `https://aps.local/contracts/${testCase.schema}`; + const validate = ajv.getSchema(schemaId); + assert.ok(validate, `${name}:未注册 ${testCase.schema}`); + assert.ok(validate(testCase.value), `${name} 样例不符合 ${testCase.schema}:${formatErrors(validate.errors)}`); + } +}); + +test('事件类型在 schema、TS 与协议文档三处一致', async () => { + const schemaTypes = new Set( + [...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]), + ); + const tsTypes = new Set([...(await readText('contracts/events.ts')).matchAll(/EventEnvelope<'([^']+)'/g)].map((m) => m[1])); + const docTypes = new Set([...(await readText('docs/spec/protocol.md')).matchAll(/^\| `([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)` \|/gm)].map((m) => m[1])); + + assert.ok(schemaTypes.size >= 11, `schema 里事件类型太少:${schemaTypes.size}`); + const diff = (a, b, label) => [...a].filter((t) => !b.has(t)).map((t) => `${label}:${t}`); + const problems = [ + ...diff(schemaTypes, tsTypes, '只在 schema'), + ...diff(tsTypes, schemaTypes, '只在 TS'), + ...diff(schemaTypes, docTypes, '只在 schema(文档缺)'), + ...diff(docTypes, schemaTypes, '只在文档'), + ]; + assert.deepEqual(problems, [], `事件类型不一致:\n${problems.join('\n')}`); +}); + +test('每种事件类型都有样例', async () => { + const fixtureTypes = new Set( + (await readText('contracts/fixtures/events.jsonl')) + .split(/\r?\n/) + .filter((l) => l.trim()) + .map((l) => JSON.parse(l).type), + ); + const schemaTypes = new Set( + [...(await readText('contracts/schemas/events.schema.json')).matchAll(/"const":\s*"([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*)"/g)].map((m) => m[1]), + ); + const missing = [...schemaTypes].filter((t) => !fixtureTypes.has(t)); + assert.deepEqual(missing, [], `以下事件类型缺样例:${missing.join('、')}`); +}); \ No newline at end of file