diff --git a/README.md b/README.md index c3df496..9eabd88 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Gaza 是一个协议优先、跨平台的多 Teamserver 桌面工作台。当前仓库包含可运行的首个项目骨架,重点验证 Teamserver 工作区和标签页隔离模型。 +## 标准协议模型 + +`src/protocol` 定义 GUI 与兼容 Teamserver 之间的首版强类型模型,包括 Teamserver、Project、Session、Capability、Task、Event、Artifact 和 Audit。该模块不包含网络请求、认证或私有 C2 适配。 + +`ServerId`、`ProjectId`、`SessionId` 等采用品牌类型,避免在编译期误用不同层级 ID;枚举允许未来服务端新增值,解析器保留未知枚举和扩展字段。迁移现有界面数据时可从 `src/protocol/fixtures` 引入最小演示 fixture。 + ## 技术栈 - Tauri 2(桌面容器) diff --git a/src/protocol/fixtures/index.ts b/src/protocol/fixtures/index.ts new file mode 100644 index 0000000..d29ac73 --- /dev/null +++ b/src/protocol/fixtures/index.ts @@ -0,0 +1,18 @@ +import type { Session } from "../model"; + +/** UI 迁移期可直接使用的最小 Session fixture。 */ +export const demoSession = { + server_id: "demo-server", + project_id: "demo-project", + session_id: "demo-session", + name: "演示会话", + state: "ONLINE", + hostname: "demo-host", + username: "operator", + os: "linux", + architecture: "amd64", + first_seen_at: "2026-08-03T00:00:00Z", + last_active_at: "2026-08-03T00:00:05Z", + tags: ["fixture"], + capabilities: ["session.overview"], +} as unknown as Session; diff --git a/src/protocol/model.ts b/src/protocol/model.ts new file mode 100644 index 0000000..82e2cae --- /dev/null +++ b/src/protocol/model.ts @@ -0,0 +1,166 @@ +/** 协议对象允许保留服务端新增字段,GUI 不应因未知字段而拒绝整个对象。 */ +export interface Extensible { + readonly [extension: string]: unknown; +} + +declare const brand: unique symbol; +export type Brand = Value & { readonly [brand]: Name }; +export type ServerId = Brand; +export type ProjectId = Brand; +export type SessionId = Brand; +export type TaskId = Brand; +export type EventId = Brand; +export type ArtifactId = Brand; +export type AuditId = Brand; +export type CapabilityId = Brand; + +export type Timestamp = Brand; +export type Cursor = Brand; + +/** 已知值保留自动补全,同时允许服务端返回未来新增值。 */ +export type ForwardCompatible = Known | (string & {}); + +export interface ProtocolVersion extends Extensible { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +export interface ProtocolEnvelope extends Extensible { + readonly protocol_version: ProtocolVersion; + readonly request_id?: string; +} + +export interface ServerContext extends Extensible { + readonly server_id: ServerId; +} + +export interface ProjectContext extends ServerContext { + readonly project_id: ProjectId; +} + +export interface SessionContext extends ProjectContext { + readonly session_id: SessionId; +} + +export interface PageRequest extends Extensible { + readonly cursor?: Cursor; + readonly page_size?: number; +} + +export interface PageInfo extends Extensible { + readonly next_cursor?: Cursor; + readonly has_more: boolean; +} + +export interface Page extends Extensible { + readonly items: readonly T[]; + readonly page_info: PageInfo; +} + +export type ConnectionState = ForwardCompatible< + | "DISCONNECTED" | "CONNECTING" | "TLS_HANDSHAKE" | "AUTHENTICATING" + | "NEGOTIATING" | "SYNCHRONIZING" | "CONNECTED" | "DEGRADED" + | "RECONNECTING" | "AUTH_EXPIRED" | "FAILED" +>; + +export interface Teamserver extends ServerContext, Extensible { + readonly name: string; + readonly protocol_version: ProtocolVersion; + readonly connection_state: ConnectionState; + readonly capabilities: readonly CapabilityId[]; +} + +export interface Project extends ProjectContext, Extensible { + readonly name: string; + readonly description?: string; + readonly capabilities: readonly CapabilityId[]; +} + +export type SessionState = ForwardCompatible<"ONLINE" | "OFFLINE" | "DORMANT" | "LOST">; + +export interface Session extends SessionContext, Extensible { + readonly name: string; + readonly state: SessionState; + readonly hostname?: string; + readonly username?: string; + readonly os?: string; + readonly architecture?: string; + readonly first_seen_at: Timestamp; + readonly last_active_at: Timestamp; + readonly tags: readonly string[]; + readonly capabilities: readonly CapabilityId[]; +} + +export type JsonSchema = Readonly>; + +export interface Capability extends ProjectContext, Extensible { + readonly capability_id: CapabilityId; + readonly name: string; + readonly description?: string; + readonly input_schema: JsonSchema; + readonly output_schema?: JsonSchema; + readonly supports_batch: boolean; +} + +export type TaskState = ForwardCompatible< + "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "CANCELLED" +>; + +export interface Task extends SessionContext, Extensible { + readonly task_id: TaskId; + readonly capability_id: CapabilityId; + readonly state: TaskState; + readonly cancellable: boolean; + readonly created_at: Timestamp; + readonly updated_at: Timestamp; + readonly result?: unknown; + readonly error?: ProtocolError; +} + +export interface EventContext extends ServerContext, Extensible { + readonly project_id?: ProjectId; + readonly session_id?: SessionId; + readonly task_id?: TaskId; +} + +export interface Event extends Extensible { + readonly event_id: EventId; + readonly type: ForwardCompatible<"SESSION_UPDATED" | "TASK_UPDATED" | "TASK_OUTPUT" | "ARTIFACT_CREATED">; + readonly timestamp: Timestamp; + readonly cursor: Cursor; + readonly sequence?: number; + readonly context: EventContext; + readonly payload: unknown; +} + +export interface Artifact extends SessionContext, Extensible { + readonly artifact_id: ArtifactId; + readonly task_id?: TaskId; + readonly name: string; + readonly media_type: string; + readonly size_bytes: number; + readonly sha256?: string; + readonly created_at: Timestamp; +} + +export interface AuditRecord extends ProjectContext, Extensible { + readonly audit_id: AuditId; + readonly actor: string; + readonly action: string; + readonly target: Readonly>; + readonly parameter_summary?: Readonly>; + readonly outcome: ForwardCompatible<"SUCCEEDED" | "FAILED" | "DENIED">; + readonly request_id: string; + readonly timestamp: Timestamp; +} + +export interface ProtocolError extends Extensible { + readonly code: ForwardCompatible< + "NETWORK" | "TLS" | "AUTHENTICATION" | "INCOMPATIBLE_VERSION" | + "PERMISSION_DENIED" | "RATE_LIMITED" | "TIMEOUT" | "INVALID_MESSAGE" | "INTERNAL" + >; + readonly message: string; + readonly retryable: boolean; + readonly details?: Readonly>; +} diff --git a/src/protocol/parser.test.ts b/src/protocol/parser.test.ts new file mode 100644 index 0000000..e7a1701 --- /dev/null +++ b/src/protocol/parser.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { demoSession } from "./fixtures"; +import { isProtocolVersion, isSession, parseSession, ProtocolParseError } from "./parser"; + +describe("协议解析", () => { + it("接受完整 Session 并保留未知枚举和扩展字段", () => { + const futureSession = { ...demoSession, state: "SUSPENDED_BY_POLICY", vendor_status: 42 }; + const parsed = parseSession(futureSession); + expect(parsed.state).toBe("SUSPENDED_BY_POLICY"); + expect(parsed.vendor_status).toBe(42); + }); + + it("拒绝缺少 project_id 的跨上下文 Session", () => { + const { project_id: _projectId, ...invalid } = demoSession; + expect(isSession(invalid)).toBe(false); + expect(() => parseSession(invalid)).toThrow(ProtocolParseError); + }); + + it("协议版本必须由非负整数构成", () => { + expect(isProtocolVersion({ major: 1, minor: 0, patch: 0, future: true })).toBe(true); + expect(isProtocolVersion({ major: 1, minor: -1, patch: 0 })).toBe(false); + }); +}); diff --git a/src/protocol/parser.ts b/src/protocol/parser.ts new file mode 100644 index 0000000..71f4740 --- /dev/null +++ b/src/protocol/parser.ts @@ -0,0 +1,49 @@ +import type { ProjectContext, ProtocolVersion, Session, SessionContext } from "./model"; + +export class ProtocolParseError extends Error { + constructor(message: string) { + super(message); + this.name = "ProtocolParseError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasString(value: Record, key: string): boolean { + return typeof value[key] === "string" && value[key] !== ""; +} + +export function isProtocolVersion(value: unknown): value is ProtocolVersion { + return isRecord(value) && ["major", "minor", "patch"].every( + (key) => Number.isInteger(value[key]) && (value[key] as number) >= 0, + ); +} + +export function isProjectContext(value: unknown): value is ProjectContext { + return isRecord(value) && hasString(value, "server_id") && hasString(value, "project_id"); +} + +export function isSessionContext(value: unknown): value is SessionContext { + return isProjectContext(value) && hasString(value, "session_id"); +} + +export function isSession(value: unknown): value is Session { + if (!isRecord(value) || !isSessionContext(value)) return false; + return hasString(value, "name") + && hasString(value, "state") + && hasString(value, "first_seen_at") + && hasString(value, "last_active_at") + && Array.isArray(value.tags) + && value.tags.every((tag) => typeof tag === "string") + && Array.isArray(value.capabilities) + && value.capabilities.every((capability) => typeof capability === "string"); +} + +export function parseSession(value: unknown): Session { + if (!isSession(value)) { + throw new ProtocolParseError("无效的 Session:缺少上下文字段或标准字段类型错误"); + } + return value; +}