Merge pull request 'GAZ-2: 定义标准协议 TypeScript 数据模型' (#2) from agent/docker/ea8c51d9 into main
Reviewed-on: #2
This commit is contained in:
commit
4ffa1c7c25
@ -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(桌面容器)
|
||||
|
||||
18
src/protocol/fixtures/index.ts
Normal file
18
src/protocol/fixtures/index.ts
Normal file
@ -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;
|
||||
166
src/protocol/model.ts
Normal file
166
src/protocol/model.ts
Normal file
@ -0,0 +1,166 @@
|
||||
/** 协议对象允许保留服务端新增字段,GUI 不应因未知字段而拒绝整个对象。 */
|
||||
export interface Extensible {
|
||||
readonly [extension: string]: unknown;
|
||||
}
|
||||
|
||||
declare const brand: unique symbol;
|
||||
export type Brand<Value, Name extends string> = Value & { readonly [brand]: Name };
|
||||
export type ServerId = Brand<string, "ServerId">;
|
||||
export type ProjectId = Brand<string, "ProjectId">;
|
||||
export type SessionId = Brand<string, "SessionId">;
|
||||
export type TaskId = Brand<string, "TaskId">;
|
||||
export type EventId = Brand<string, "EventId">;
|
||||
export type ArtifactId = Brand<string, "ArtifactId">;
|
||||
export type AuditId = Brand<string, "AuditId">;
|
||||
export type CapabilityId = Brand<string, "CapabilityId">;
|
||||
|
||||
export type Timestamp = Brand<string, "Timestamp">;
|
||||
export type Cursor = Brand<string, "Cursor">;
|
||||
|
||||
/** 已知值保留自动补全,同时允许服务端返回未来新增值。 */
|
||||
export type ForwardCompatible<Known extends string> = 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<T> 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<Record<string, unknown>>;
|
||||
|
||||
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<Record<string, string>>;
|
||||
readonly parameter_summary?: Readonly<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
}
|
||||
23
src/protocol/parser.test.ts
Normal file
23
src/protocol/parser.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
49
src/protocol/parser.ts
Normal file
49
src/protocol/parser.ts
Normal file
@ -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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasString(value: Record<string, unknown>, 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user