Remove legacy client-manager workflows

This commit is contained in:
npc0-hue
2026-09-03 13:08:08 +08:00
parent bf3c382d15
commit fe09d21a56
56 changed files with 304 additions and 4121 deletions
+3 -3
View File
@@ -86,7 +86,7 @@ Server list surfaces expose runtime package actions through platform APIs, while
- request dependency checks and typed dependency install jobs.
- open live server log stream metadata and request historical log backfill jobs.
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies. Log bodies are displayed verbatim when the plugin declares them; the browser does not redact or reinterpret them.
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run or component keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies. Log bodies are displayed verbatim when the plugin declares them; the browser does not redact or reinterpret them.
Manual UI smoke checklist:
@@ -104,8 +104,8 @@ LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/priv
```
This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `<LOCAL_DEBUG_ROOT>/browser-acceptance/`.
# Client Manager workspace
# Runtime Workspace
Client Manager lifecycle remains a typed Platform installation projection, but it is not exposed as a generic server-detail tab. Dedicated package/dependency actions live in server-list runtime actions or plugin-declared pages, and the browser polls active jobs without fabricating later phases.
Dedicated Run package, dependency, and plugin-declared operations live in server-list runtime actions or plugin pages, and the browser polls active jobs without fabricating later phases.
The UI keeps the black-mecha and magical-girl crystal-moonlight console materials and uses shared panel/command tokens. It renders no raw key, token, secret ref/value, host path, PID, socket, credential, DSN, RCON password, or Run endpoint address. 401/403 responses remain platform auth/capability errors, not local fallback success.
+1 -61
View File
@@ -122,7 +122,6 @@ const runtimeActions = {
{ key: "generate-run", label: "Generate run", available: true },
{ key: "download-run", label: "Download run", available: true },
{ key: "push-run-update", label: "Push run update", available: true },
{ key: "generate-client-manager", label: "Generate client manager", available: true },
{ key: "dependencies-check", label: "Check dependencies", available: true },
{ key: "historical-logs", label: "Historical logs", available: true }
]
@@ -476,53 +475,6 @@ describe("PlatformApiClient AI providers", () => {
count: 1
});
}
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
profileKey: "example-client-manager",
targetOs: "windows",
targetArch: "amd64",
repositoryUrl: "https://example.test/client-manager.git",
sourceRevision: "main",
idempotencyKey: "idem-client-generate"
});
return jsonResponse({
id: "client-dist-1",
serverInstanceId: server.id,
pluginId: plugin.id,
profileKey: "example-client-manager",
targetOs: "windows",
targetArch: "amd64",
repositoryUrl: "https://example.test/client-manager.git",
sourceRevision: "main",
buildJobId: "client-build-1",
artifactId: "artifact-client-1",
checksum: "sha256:clientchecksum",
keyGeneration: 1,
secretRef: "secret://runtime-keys/server-1/client-manager/example-client-manager/current",
status: "available",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/download") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ profileKey: "example-client-manager" });
return jsonResponse({ ...runtimeDownload, artifactId: "artifact-client-1", filename: "example-client-manager.exe" });
}
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/key/reset") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ componentKind: "client-manager", componentKey: "example-client-manager" });
return jsonResponse({
id: "runtime-key-server-1-client-2",
serverInstanceId: server.id,
componentKind: "client-manager",
componentKey: "example-client-manager",
secretRef: "secret://runtime-keys/server-1/client-manager/example-client-manager/current",
fingerprint: "def456abc123",
generation: 2,
status: "active",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/check") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", idempotencyKey: "idem-dep-check" });
return jsonResponse({ ...job, id: "job-dep-check", capability: "dependencies.check", targetKey: "dependencies/java-21" });
@@ -652,18 +604,6 @@ describe("PlatformApiClient AI providers", () => {
status: "queued"
});
await expect(client.listRunUpdates(server.id)).resolves.toMatchObject({ count: 1, items: [{ phase: "restart-requested", rollback: false }] });
await expect(
client.generateClientManager(server.id, {
profileKey: "example-client-manager",
targetOs: "windows",
targetArch: "amd64",
repositoryUrl: "https://example.test/client-manager.git",
sourceRevision: "main",
idempotencyKey: "idem-client-generate"
})
).resolves.toMatchObject({ artifactId: "artifact-client-1", profileKey: "example-client-manager" });
await expect(client.downloadLatestClientManager(server.id, { profileKey: "example-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" });
await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "example-client-manager" })).resolves.toMatchObject({ generation: 2 });
await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" });
await expect(client.getDependencyCatalog(server.id)).resolves.toMatchObject({ targetOs: "linux", plans: [{ digest: runtimeDigest }] });
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
@@ -673,7 +613,7 @@ describe("PlatformApiClient AI providers", () => {
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(46);
expect(fetchMock).toHaveBeenCalledTimes(43);
});
it("normalizes server file workspace null arrays from older platform responses", async () => {
-66
View File
@@ -16,19 +16,7 @@ import type {
ArtifactFilterRequest,
ArtifactListResponse,
AuthSessionResponse,
ClientManagerBuildRequest,
ClientManagerControlRequest,
ClientManagerDeployRequest,
ClientManagerDistributionResponse,
ClientManagerDownloadRequest,
ClientManagerInstallationListResponse,
ClientManagerInstallationResponse,
ClientManagerRetryRequest,
ClientManagerRevokeSessionRequest,
ClientManagerUninstallRequest,
ClientManagerUpdateRequest,
ComponentKeyResponse,
ComponentKeyResetRequest,
CurrentUserResponse,
DependencyCatalogResponse,
DependencyJobRequest,
@@ -116,7 +104,6 @@ import type {
} from "./types";
import { readWebRuntimeEnv } from "../schemas/env";
import { parseSafeDependencyCatalog, parseSafeRunUpdate, parseSafeRunUpdateList } from "../schemas/runtimeUpdates";
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "../schemas/clientManagerLifecycle";
import {
parseSafeGameClientBridgeCancellation,
parseSafeGameClientBridgeCommand,
@@ -352,59 +339,6 @@ export class PlatformApiClient {
return parseSafeRunUpdateList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/run/update`));
}
async generateClientManager(id: string, request: ClientManagerBuildRequest): Promise<ClientManagerDistributionResponse> {
return this.request<ClientManagerDistributionResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/generate`, {
method: "POST",
body: request
});
}
async downloadLatestClientManager(id: string, request: ClientManagerDownloadRequest = {}): Promise<ArtifactDownloadReferenceResponse> {
return this.request<ArtifactDownloadReferenceResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/download`, {
method: "POST",
body: request
});
}
async resetClientManagerKey(id: string, request: ComponentKeyResetRequest): Promise<ComponentKeyResponse> {
return this.request<ComponentKeyResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/key/reset`, {
method: "POST",
body: request
});
}
async listClientManagerLifecycles(id: string): Promise<ClientManagerInstallationListResponse> {
return parseSafeClientManagerLifecycleList(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers`));
}
async getClientManagerLifecycle(id: string, profileKey: string): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/${encodeURIComponent(profileKey)}`));
}
async deployClientManager(id: string, request: ClientManagerDeployRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/deploy`, { method: "POST", body: request }));
}
async controlClientManager(id: string, request: ClientManagerControlRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/control`, { method: "POST", body: request }));
}
async updateClientManager(id: string, request: ClientManagerUpdateRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/update`, { method: "POST", body: request }));
}
async retryClientManagerLifecycle(id: string, request: ClientManagerRetryRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/retry`, { method: "POST", body: request }));
}
async revokeClientManagerSession(id: string, request: ClientManagerRevokeSessionRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/revoke-session`, { method: "POST", body: request }));
}
async uninstallClientManager(id: string, request: ClientManagerUninstallRequest): Promise<ClientManagerInstallationResponse> {
return parseSafeClientManagerLifecycle(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/client-managers/uninstall`, { method: "POST", body: request }));
}
async getGameClientBridgeStatus(id: string): Promise<GameClientBridgeStatusResponse> {
return parseSafeGameClientBridgeStatus(await this.request<unknown>(`/server-instances/${encodeURIComponent(id)}/game-client-bridge`));
}
@@ -1,47 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
const lifecycle = {
id: "installation-1", serverInstanceId: "server-1", pluginId: "game.example", profileKey: "example-client-manager", targetOs: "windows", targetArch: "amd64",
status: "available", phase: "artifact available", desiredVersion: "1.0.0", desiredRevision: "rev-1", desiredArtifactId: "artifact-1", keyGeneration: 1,
deploymentGeneration: 0, health: "unknown", healthReason: "component is not installed", retryable: false, requiresRedeploy: false, updatedAt: "2026-07-18T08:00:00Z",
distribution: { id: "distribution-1", artifactId: "artifact-1", sourceRevision: "rev-1", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 1, status: "available" },
actions: [{ operation: "deploy", available: true }, { operation: "uninstall", available: false, reason: "not installed" }]
};
describe("PlatformApiClient Client Manager lifecycle", () => {
afterEach(() => vi.unstubAllGlobals());
it("uses typed Platform lifecycle routes and preserves action bodies", async () => {
const calls: Array<{ url: string; method: string; body?: unknown }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined });
return new Response(JSON.stringify(url.endsWith("/client-managers") && (init?.method ?? "GET") === "GET" ? { items: [lifecycle], count: 1 } : lifecycle), { status: 200, headers: { "Content-Type": "application/json" } });
}));
const client = new PlatformApiClient("/api/v1", () => "session-token");
await expect(client.listClientManagerLifecycles("server-1")).resolves.toMatchObject({ count: 1 });
await expect(client.getClientManagerLifecycle("server-1", "example-client-manager")).resolves.toMatchObject({ profileKey: "example-client-manager" });
await client.deployClientManager("server-1", { profileKey: "example-client-manager", distributionId: "distribution-1", expectedDeploymentGeneration: 0, idempotencyKey: "deploy-1" });
await client.controlClientManager("server-1", { profileKey: "example-client-manager", operation: "start", expectedDeploymentGeneration: 1, idempotencyKey: "start-1" });
await client.updateClientManager("server-1", { profileKey: "example-client-manager", distributionId: "distribution-2", expectedDeploymentGeneration: 1, approved: true, idempotencyKey: "update-1" });
await client.retryClientManagerLifecycle("server-1", { profileKey: "example-client-manager", expectedDeploymentGeneration: 2, idempotencyKey: "retry-1" });
await client.revokeClientManagerSession("server-1", { profileKey: "example-client-manager", reason: "operator revoked component session" });
await client.uninstallClientManager("server-1", { profileKey: "example-client-manager", expectedDeploymentGeneration: 2, confirmed: true, idempotencyKey: "uninstall-1" });
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"GET /api/v1/server-instances/server-1/client-managers",
"GET /api/v1/server-instances/server-1/client-managers/example-client-manager",
"POST /api/v1/server-instances/server-1/client-managers/deploy",
"POST /api/v1/server-instances/server-1/client-managers/control",
"POST /api/v1/server-instances/server-1/client-managers/update",
"POST /api/v1/server-instances/server-1/client-managers/retry",
"POST /api/v1/server-instances/server-1/client-managers/revoke-session",
"POST /api/v1/server-instances/server-1/client-managers/uninstall"
]);
expect(calls[4]?.body).toMatchObject({ approved: true, distributionId: "distribution-2" });
expect(calls[7]?.body).toMatchObject({ confirmed: true });
});
});
-3
View File
@@ -61,6 +61,3 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output only. It does not replay retained log entries, and accepted batches whose source entry timestamps predate the current SSE connection after a small clock-skew allowance stay out of the terminal stream; raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients, and internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
# Client Manager API projection
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
+4 -19
View File
@@ -10,12 +10,12 @@ const status = {
serverInstanceId: "server-1",
pluginId: "game.scum",
available: false,
reason: "compatible companion is offline",
reason: "plugin-owned bridge runtime is unavailable",
profiles: [{
pluginId: "game.scum",
profileKey: "scum-client",
available: false,
reason: "component heartbeat is unavailable",
reason: "declared runtime transport is unavailable",
commandTypes: ["scum.diagnostic.ping"],
snapshotTypes: ["scum.players"],
queryTemplateKeys: ["scum.player.search"]
@@ -98,22 +98,7 @@ const manifestDeclaration: GameClientBridgeManifestResponse = {
}],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }],
companion: {
profileKey: "scum-client-manager",
configTemplateKey: "client-config",
configSchemaRef: "schemas/companion/config.schema.json",
configFormat: "yaml",
platformBaseUrlSource: "run-control",
registrationProof: "hmac-sha256",
proofMaterialSource: "component-package",
proofMaterialEnv: "SCUM_COMPONENT_PROOF",
sessionMode: "component-session",
tlsPolicy: "verify-system-roots",
heartbeatIntervalSeconds: 30,
commandPollIntervalSeconds: 5,
requestTimeoutSeconds: 15
}
pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }]
};
const pluginBridgeProjection: Pick<GamePluginResponse, "gameClientBridge"> & Pick<MarketplacePluginResponse, "gameClientBridge"> = {
@@ -124,7 +109,7 @@ describe("PlatformApiClient Game Client Bridge operator API", () => {
afterEach(() => vi.unstubAllGlobals());
it("types plugin and marketplace manifest declarations", () => {
expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ type: "scum.diagnostic.ping" }], queryTemplates: [{ engine: "sqlite" }], companion: { tlsPolicy: "verify-system-roots", sessionMode: "component-session" } });
expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ type: "scum.diagnostic.ping" }], queryTemplates: [{ engine: "sqlite" }] });
expect(JSON.stringify(pluginBridgeProjection)).not.toMatch(/authKey|componentKey|sessionToken|credential|secretRef/i);
});
+1 -194
View File
@@ -79,22 +79,6 @@ export interface GameClientBridgePageContractResponse {
export interface GameClientBridgeFeatureDeclarationResponse { key: string; title: string; permission: string; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
export interface GameClientBridgeCompanionDeclarationResponse {
profileKey: string;
configTemplateKey: string;
configSchemaRef: string;
configFormat: "yaml";
platformBaseUrlSource: "run-control";
registrationProof: "hmac-sha256";
proofMaterialSource: "component-package";
proofMaterialEnv: string;
sessionMode: "component-session";
tlsPolicy: "verify-system-roots";
heartbeatIntervalSeconds: number;
commandPollIntervalSeconds: number;
requestTimeoutSeconds: number;
}
export interface GameClientBridgeManifestResponse {
commands: GameClientBridgeCommandDeclarationResponse[];
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
@@ -104,7 +88,6 @@ export interface GameClientBridgeManifestResponse {
maxCommands: number;
pages?: GameClientBridgePageContractResponse[];
features?: GameClientBridgeFeatureDeclarationResponse[];
companion?: GameClientBridgeCompanionDeclarationResponse;
}
export interface GameClientBridgeProfileDeclarationResponse {
@@ -262,7 +245,6 @@ export interface RuntimeLifecycleProfileResponse {
capabilities: string[];
actionRefs?: Record<string, string>;
transportKeys?: string[];
clientManagerRef?: string;
dllExtensionRefs?: string[];
platforms?: string[];
}
@@ -318,23 +300,6 @@ export interface RuntimeTransportProfileResponse {
capabilities: string[];
}
export interface RuntimeClientManagerProfileResponse {
key: string;
displayName?: string;
version?: string;
revision?: string;
repository: { url: string; revisionPolicy: string; branch?: string; tag?: string; revision?: string };
supportedTargets: Array<{ os: string; arch: string }>;
build: { system: string; workspaceRef?: string; entryRef?: string };
configTemplates?: Array<{ key: string; templateRef: string; outputRef: string }>;
outputArtifacts: string[];
deployment?: { mode: string; executableRef: string; arguments: string[]; autoStart: boolean; requiredRunCapabilities: string[] };
lifecycle?: { actions: string[]; startupTimeoutSeconds: number; stopTimeoutSeconds: number };
health?: { mode: string; intervalSeconds: number; degradedAfterSeconds: number; offlineAfterSeconds: number; requiredCapabilities: string[] };
compatibility?: { minimumVersion?: string; maximumVersion?: string; allowDowngrade: boolean };
updatePolicy?: { strategy: string; requireApproval: boolean; healthConfirmationSeconds: number; retainPrevious: boolean };
}
export interface RuntimeDLLExtensionProfileResponse {
key: string;
displayName: string;
@@ -360,7 +325,6 @@ export interface GamePluginRuntimeProfilesResponse {
serverDeployments?: RuntimeServerDeploymentProfileResponse[];
logSources?: RuntimeLogSourceResponse[];
transportProfiles?: RuntimeTransportProfileResponse[];
clientManagers?: RuntimeClientManagerProfileResponse[];
dllExtensions?: RuntimeDLLExtensionProfileResponse[];
}
@@ -821,165 +785,8 @@ export interface RunUpdateJobListResponse {
count: number;
}
export interface ClientManagerBuildRequest {
profileKey: string;
targetOs: string;
targetArch: string;
repositoryUrl: string;
sourceRevision?: string;
idempotencyKey?: string;
}
export interface ClientManagerDistributionResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
targetOs: string;
targetArch: string;
repositoryUrl: string;
sourceRevision: string;
buildJobId: string;
artifactId: string;
checksum: string;
keyGeneration: number;
secretRef: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface ClientManagerDownloadRequest {
profileKey?: string;
}
export type ClientManagerLifecycleStatus =
| "requested"
| "building"
| "available"
| "deploying"
| "installed"
| "registering"
| "online"
| "degraded"
| "offline"
| "updating"
| "rolling_back"
| "stopping"
| "uninstalled"
| "failed";
export type ClientManagerLifecycleOperation = "deploy" | "start" | "stop" | "restart" | "status" | "update" | "rollback" | "uninstall";
export interface ClientManagerLifecycleActionResponse {
operation: ClientManagerLifecycleOperation;
available: boolean;
reason?: string;
}
export interface ClientManagerLifecycleJobResponse {
id: string;
state: JobState;
progress: JobProgressBody;
attempt: number;
createdAt: string;
updatedAt: string;
}
export interface ClientManagerDistributionSummaryResponse {
id: string;
artifactId: string;
sourceRevision: string;
targetOs: string;
targetArch: string;
checksum: string;
keyGeneration: number;
status: string;
}
export interface ClientManagerInstallationResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
targetOs: string;
targetArch: string;
status: ClientManagerLifecycleStatus;
phase: string;
desiredVersion?: string;
activeVersion?: string;
previousVersion?: string;
desiredRevision?: string;
activeRevision?: string;
previousRevision?: string;
desiredArtifactId?: string;
activeArtifactId?: string;
previousArtifactId?: string;
keyGeneration: number;
deploymentGeneration: number;
currentJobId?: string;
lastSuccessfulJobId?: string;
lastOperation?: ClientManagerLifecycleOperation;
health: "unknown" | "healthy" | "degraded" | "unhealthy" | "offline";
healthReason?: string;
lastSeenAt?: string;
retryable: boolean;
requiresRedeploy: boolean;
installedAt?: string;
uninstalledAt?: string;
updatedAt: string;
distribution?: ClientManagerDistributionSummaryResponse;
job?: ClientManagerLifecycleJobResponse;
actions: ClientManagerLifecycleActionResponse[];
}
export interface ClientManagerInstallationListResponse {
items: ClientManagerInstallationResponse[];
count: number;
}
export interface ClientManagerDeployRequest {
profileKey: string;
distributionId: string;
expectedDeploymentGeneration?: number;
idempotencyKey: string;
}
export interface ClientManagerControlRequest {
profileKey: string;
operation: "start" | "stop" | "restart" | "status" | "rollback";
expectedDeploymentGeneration: number;
idempotencyKey: string;
}
export interface ClientManagerUpdateRequest {
profileKey: string;
distributionId: string;
expectedDeploymentGeneration: number;
approved: boolean;
idempotencyKey: string;
}
export interface ClientManagerRetryRequest {
profileKey: string;
expectedDeploymentGeneration: number;
idempotencyKey: string;
}
export interface ClientManagerRevokeSessionRequest {
profileKey: string;
reason: string;
}
export interface ClientManagerUninstallRequest {
profileKey: string;
expectedDeploymentGeneration: number;
confirmed: boolean;
idempotencyKey: string;
}
export interface ComponentKeyResetRequest {
componentKind: "run" | "client-manager" | string;
componentKind: "run" | string;
componentKey?: string;
}
@@ -1,221 +0,0 @@
import { Activity, Ban, KeyRound, PackageCheck, Play, RefreshCw, RotateCcw, ShieldAlert, Square, Trash2, UploadCloud } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { ClientManagerInstallationResponse, ClientManagerLifecycleOperation } from "../api/types";
import type { CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
import { cx } from "../utils/classes";
import { ConfirmDialog } from "./OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: ClientManagerInstallationResponse[] };
interface ClientManagerLifecyclePanelProps {
serverId: string;
serverName: string;
session: CurrentUserView;
operations: OperationTracker;
}
interface PendingConfirmation {
title: string;
description: string;
danger?: boolean;
execute: () => Promise<void>;
}
export function ClientManagerLifecyclePanel({ serverId, serverName, session, operations }: ClientManagerLifecyclePanelProps) {
const [state, setState] = useState<LoadState>({ status: "loading" });
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [confirmation, setConfirmation] = useState<PendingConfirmation | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const refresh = useCallback(async (showLoading = false) => {
if (showLoading) setState({ status: "loading" });
try {
const response = await platformApiClient.listClientManagerLifecycles(serverId);
setState({ status: "ready", items: response.items });
} catch (error) {
setState({ status: "error", reason: safeError(error, "Client Manager 状态加载失败") });
}
}, [serverId]);
useEffect(() => { void refresh(true); }, [refresh]);
const hasActiveJob = state.status === "ready" && state.items.some((item) => item.job && ["queued", "accepted", "running", "retrying"].includes(item.job.state));
useEffect(() => {
if (!hasActiveJob) return undefined;
const timer = window.setInterval(() => void refresh(), 2500);
return () => window.clearInterval(timer);
}, [hasActiveJob, refresh]);
async function runCommand(item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${serverId}:client-manager:${item.profileKey}`, requester: session.displayName });
setResult({ status: "pending", label: `${intent} 已提交,等待 Platform/Run 返回真实状态` });
try {
const next = await execute();
setState((current) => current.status === "ready" ? { status: "ready", items: current.items.map((entry) => entry.id === next.id ? next : entry) } : current);
const label = next.job ? `${intent} 已排队,job ${next.job.id}` : `${intent} 已完成状态更新`;
operations.succeed(operationId, label);
setResult({ status: "succeeded", label });
await refresh();
} catch (error) {
const reason = safeError(error, `${intent} 失败`);
operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason });
}
}
function confirmCommand(config: PendingConfirmation) {
setConfirmation(config);
}
return (
<article className="console-panel client-manager-lifecycle-panel" aria-label="Client Manager 生命周期">
<div className="panel-header">
<h2><PackageCheck size={17} /> Client Manager </h2>
<div className="action-strip">
{result && <ResultBadge status={result.status} label={result.label} />}
<button type="button" className="icon-command" title="刷新 Client Manager 状态" onClick={() => void refresh()}>
<RefreshCw size={15} /><span></span>
</button>
</div>
</div>
{state.status === "loading" && <LoadingState label="正在读取 Client Manager 部署与组件健康状态…" />}
{state.status === "error" && <ErrorState title="Client Manager 状态不可用" reason={state.reason} diagnosticId={`client-manager:${serverId}`} onRetry={() => void refresh(true)} />}
{state.status === "ready" && state.items.length === 0 && <EmptyState title="尚无 Client Manager 生命周期记录" description="先在运行分发区按插件声明构建 Client Manager;可用 artifact 会在这里进入部署闭环。" />}
{state.status === "ready" && state.items.length > 0 && (
<div className="client-manager-lifecycle-list">
{state.items.map((item) => (
<ClientManagerLifecycleRow
key={item.id}
item={item}
serverName={serverName}
runCommand={runCommand}
confirmCommand={confirmCommand}
/>
))}
</div>
)}
<ConfirmDialog
open={confirmation !== null}
title={confirmation?.title ?? ""}
description={confirmation?.description ?? ""}
confirmLabel="确认执行"
danger={confirmation?.danger}
busy={confirmBusy}
onCancel={() => setConfirmation(null)}
onConfirm={() => {
if (!confirmation) return;
setConfirmBusy(true);
void confirmation.execute().finally(() => {
setConfirmBusy(false);
setConfirmation(null);
});
}}
/>
</article>
);
}
interface ClientManagerLifecycleRowProps {
item: ClientManagerInstallationResponse;
serverName: string;
runCommand: (item: ClientManagerInstallationResponse, intent: string, execute: () => Promise<ClientManagerInstallationResponse>) => Promise<void>;
confirmCommand: (config: PendingConfirmation) => void;
}
function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmCommand }: ClientManagerLifecycleRowProps) {
const actionMap = useMemo(() => new Map(item.actions.map((action) => [action.operation, action])), [item.actions]);
const available = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.available ?? false;
const reason = (operation: ClientManagerLifecycleOperation) => actionMap.get(operation)?.reason ?? "Platform 当前状态不允许此操作";
const distributionId = item.distribution?.id ?? "";
const idempotency = (operation: string) => `client-manager.${operation}:${item.serverInstanceId}:${item.profileKey}:${Date.now()}`;
const control = (operation: "start" | "stop" | "restart" | "status" | "rollback") =>
platformApiClient.controlClientManager(item.serverInstanceId, { profileKey: item.profileKey, operation, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency(operation) });
const deploy = () => runCommand(item, item.requiresRedeploy ? "重新部署 Client Manager" : "部署 Client Manager", () => platformApiClient.deployClientManager(item.serverInstanceId, {
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("deploy")
}));
const update = () => runCommand(item, "更新 Client Manager", () => platformApiClient.updateClientManager(item.serverInstanceId, {
profileKey: item.profileKey, distributionId, expectedDeploymentGeneration: item.deploymentGeneration, approved: true, idempotencyKey: idempotency("update")
}));
return (
<section className="client-manager-lifecycle-row" aria-label={`${item.profileKey} lifecycle`}>
<div className="client-manager-lifecycle-head">
<div>
<strong>{item.profileKey}</strong>
<span className="provider-id">{item.targetOs}/{item.targetArch} · deployment generation {item.deploymentGeneration} · key generation {item.keyGeneration}</span>
</div>
<div className="tag-list">
<span className={cx("status-pill", lifecycleTone(item.status))}>{lifecycleLabel(item.status)}</span>
<span className={cx("status-pill", healthTone(item.health))}><Activity size={12} /> {healthLabel(item.health)}</span>
</div>
</div>
<div className="client-manager-version-grid">
<VersionCell label="目标版本" version={item.desiredVersion} revision={item.desiredRevision} artifact={item.desiredArtifactId} />
<VersionCell label="当前版本" version={item.activeVersion} revision={item.activeRevision} artifact={item.activeArtifactId} />
<VersionCell label="回滚版本" version={item.previousVersion} revision={item.previousRevision} artifact={item.previousArtifactId} />
<div className="client-manager-version-cell"><span></span><strong>{item.lastSeenAt ? `最后心跳 ${formatTime(item.lastSeenAt)}` : "等待独立注册"}</strong><small>{item.healthReason || "未收到安全健康原因"}</small></div>
</div>
<div className="client-manager-phase-line">
<span><ShieldAlert size={14} /> {item.phase || "等待生命周期事件"}</span>
{item.lastOperation && <span> {item.lastOperation}</span>}
{item.lastSuccessfulJobId && <span> job {item.lastSuccessfulJobId}</span>}
</div>
{item.job && (
<div className="client-manager-job-progress" aria-label="Client Manager job progress">
<div><span>job {item.job.id} · attempt {item.job.attempt} · {item.job.state}</span><strong>{item.job.progress.percent}%</strong></div>
<progress max={100} value={item.job.progress.percent} />
<small>{item.job.progress.message || "等待 Run 回报真实阶段"}</small>
</div>
)}
{(item.retryable || item.requiresRedeploy || item.status === "failed") && (
<div className="client-manager-recovery">
<ShieldAlert size={16} />
<span>{item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 job 失败原因后选择重新部署、回滚或卸载。"}</span>
</div>
)}
<div className="client-manager-command-grid">
<LifecycleButton icon={<UploadCloud size={14} />} label={item.activeArtifactId ? "重新部署" : "部署"} disabled={!available("deploy") || !distributionId} reason={!distributionId ? "没有可用 distribution" : reason("deploy")} onClick={deploy} />
<LifecycleButton icon={<Play size={14} />} label="启动" disabled={!available("start")} reason={reason("start")} onClick={() => void runCommand(item, "启动 Client Manager", () => control("start"))} />
<LifecycleButton icon={<Square size={14} />} label="停止" disabled={!available("stop")} reason={reason("stop")} onClick={() => void runCommand(item, "停止 Client Manager", () => control("stop"))} />
<LifecycleButton icon={<RefreshCw size={14} />} label="重启" disabled={!available("restart")} reason={reason("restart")} onClick={() => void runCommand(item, "重启 Client Manager", () => control("restart"))} />
<LifecycleButton icon={<Activity size={14} />} label="检查状态" disabled={!available("status")} reason={reason("status")} onClick={() => void runCommand(item, "检查 Client Manager 状态", () => control("status"))} />
<LifecycleButton icon={<UploadCloud size={14} />} label="更新" disabled={!available("update") || !distributionId} reason={!distributionId ? "没有兼容的可用 distribution" : reason("update")} onClick={() => confirmCommand({ title: "批准 Client Manager 更新", description: `${serverName}${item.profileKey}${item.activeVersion || "未安装"} 更新到 ${item.desiredVersion || "目标版本"}。Run 将 staged activate、健康确认,并在失败时恢复 previous slot。`, execute: update })} />
<LifecycleButton icon={<RotateCcw size={14} />} label="回滚" disabled={!available("rollback")} reason={reason("rollback")} onClick={() => confirmCommand({ title: "回滚 Client Manager", description: `确认将 ${item.profileKey} 回滚到 ${item.previousVersion || "previous slot"}?当前组件 session 将被撤销并需要重新注册。`, danger: true, execute: () => runCommand(item, "回滚 Client Manager", () => control("rollback")) })} />
<LifecycleButton icon={<RefreshCw size={14} />} label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} />
<LifecycleButton icon={<Ban size={14} />} label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} />
<LifecycleButton icon={<KeyRound size={14} />} label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} />
<LifecycleButton icon={<Trash2 size={14} />} label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName}${item.profileKey}Run 只会清理 Client Manager workspacePlatform 保留 build 与 artifact 记录。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} />
</div>
</section>
);
}
function VersionCell({ label, version, revision, artifact }: { label: string; version?: string; revision?: string; artifact?: string }) {
return <div className="client-manager-version-cell"><span>{label}</span><strong>{version || "--"}</strong><small>{revision ? `revision ${shortRef(revision)}` : "revision --"}{artifact ? ` · artifact ${shortRef(artifact)}` : ""}</small></div>;
}
function LifecycleButton({ icon, label, disabled, reason, danger, onClick }: { icon: ReactNode; label: string; disabled: boolean; reason: string; danger?: boolean; onClick: () => void }) {
return <button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : label} onClick={onClick}>{icon}<span>{label}</span></button>;
}
function lifecycleLabel(status: ClientManagerInstallationResponse["status"]): string {
return ({ requested: "已请求", building: "构建中", available: "可部署", deploying: "部署中", installed: "已安装", registering: "等待注册", online: "在线", degraded: "降级", offline: "离线", updating: "更新中", rolling_back: "回滚中", stopping: "停止中", uninstalled: "已卸载", failed: "失败" })[status];
}
function lifecycleTone(status: ClientManagerInstallationResponse["status"]): string { return ["online", "installed"].includes(status) ? "status-active" : ["failed", "offline", "uninstalled"].includes(status) ? "status-disabled" : "status-pending"; }
function healthLabel(health: ClientManagerInstallationResponse["health"]): string { return ({ unknown: "健康未知", healthy: "健康", degraded: "健康降级", unhealthy: "不健康", offline: "心跳离线" })[health]; }
function healthTone(health: ClientManagerInstallationResponse["health"]): string { return health === "healthy" ? "status-active" : health === "unknown" || health === "degraded" ? "status-pending" : "status-disabled"; }
function shortRef(value: string): string { return value.length > 18 ? `${value.slice(0, 18)}` : value; }
function formatTime(value: string): string { const time = new Date(value); return Number.isNaN(time.getTime()) ? "未知" : time.toLocaleString(); }
function safeError(error: unknown, fallback: string): string { const message = error instanceof Error ? error.message : fallback; return message.replace(/Bearer\s+\S+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]").slice(0, 240); }
@@ -21,7 +21,6 @@ Plugin page runs with safe platform context.
- `run.distribution.request`: platform-mediated Run distribution request.
- `dependencies.request`: declared dependency check or install request.
- `logs.backfill.request`: bounded historical log backfill request.
- `client-manager.request`: Client Manager lifecycle request through Platform.
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
- `ai.invoke`: platform-mediated AI invocation.
-4
View File
@@ -12,7 +12,6 @@ export type PluginPermission =
| "server.remote.access"
| "server.run.distribution"
| "server.dependencies.manage"
| "server.client-manager.manage"
| "server.game-client.read"
| "server.game-client.command"
| "server.game-client.maintenance"
@@ -28,7 +27,6 @@ export type PluginBridgeAction =
| "run.distribution.request"
| "dependencies.request"
| "logs.backfill.request"
| "client-manager.request"
| "plugin-lifecycle.request"
| "ai.invoke";
@@ -44,7 +42,6 @@ const pluginPermissions: readonly PluginPermission[] = [
"server.remote.access",
"server.run.distribution",
"server.dependencies.manage",
"server.client-manager.manage",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance",
@@ -61,7 +58,6 @@ const pluginBridgeActions: readonly PluginBridgeAction[] = [
"run.distribution.request",
"dependencies.request",
"logs.backfill.request",
"client-manager.request",
"plugin-lifecycle.request",
"ai.invoke"
];
+2 -2
View File
@@ -24,6 +24,6 @@ Pages must use the shared black-mecha / magical-girl visual system from `../them
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
- Preserve text/icons for status and operation results; do not rely on color-only cues.
- Read `../theme/README.md` before adding a new page surface pattern.
# Server Detail lifecycle behavior
# Server Detail operations behavior
The Server Detail Client Manager section is a real operations surface: it consumes safe Platform projections, polls only while a lifecycle job is active, shows current job attempts/progress, and provides recovery actions for retryable failure, stale key generation, failed update rollback, and offline health. Action buttons remain compact and disabled with the Platform-provided reason when declaration, permission, endpoint, artifact, target, or lifecycle state is not ready.
The Server Detail workspace consumes safe Platform projections for lifecycle, plugin-owned pages, terminal/log streams, and run distribution actions. Action buttons remain compact and disabled with the Platform-provided reason when declaration, permission, endpoint, artifact, target, or lifecycle state is not ready.
+2 -2
View File
@@ -23,6 +23,6 @@ Navigation entries are generated from the current user's capability set (`contra
- Server creation opens inline from 服务器管理 (no separate `/servers/new` page).
Server and plugin detail flows must use routes, modals, or drawers. Do not build a fixed left-list/right-detail page.
# Server Detail lifecycle route
# Server Detail operations route
The `serverDetail` route owns the Client Manager workspace as an overview operations surface. It is a detail workflow, not a permanent split pane: list pages remain full-width and destructive lifecycle commands use shared confirmation dialogs. Route state does not contain component secrets or Run session material.
The `serverDetail` route owns the server operations workspace: plugin-declared pages, lifecycle controls, AI assistance, and terminal/log drawers. It is a detail workflow, not a permanent split pane; list pages remain full-width and destructive lifecycle commands use shared confirmation dialogs. Route state does not contain component secrets or Run session material.
@@ -1,59 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseSafeClientManagerLifecycle, parseSafeClientManagerLifecycleList } from "./clientManagerLifecycle";
export const safeClientManagerLifecycleFixture = {
id: "client-manager-installation-1",
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client-manager",
targetOs: "windows",
targetArch: "amd64",
status: "online",
phase: "component heartbeat healthy",
desiredVersion: "2.0.0",
activeVersion: "2.0.0",
previousVersion: "1.0.0",
desiredRevision: "rev-2",
activeRevision: "rev-2",
previousRevision: "rev-1",
desiredArtifactId: "artifact-2",
activeArtifactId: "artifact-2",
previousArtifactId: "artifact-1",
keyGeneration: 3,
deploymentGeneration: 4,
currentJobId: "job-update-1",
lastSuccessfulJobId: "job-deploy-1",
lastOperation: "update",
health: "healthy",
healthReason: "component heartbeat healthy",
lastSeenAt: "2026-07-18T08:00:00Z",
retryable: false,
requiresRedeploy: false,
updatedAt: "2026-07-18T08:00:00Z",
distribution: { id: "distribution-2", artifactId: "artifact-2", sourceRevision: "rev-2", targetOs: "windows", targetArch: "amd64", checksum: `sha256:${"a".repeat(64)}`, keyGeneration: 3, status: "available" },
job: { id: "job-update-1", state: "running", progress: { percent: 65, message: "health confirmation" }, attempt: 1, createdAt: "2026-07-18T07:59:00Z", updatedAt: "2026-07-18T08:00:00Z" },
actions: [
{ operation: "start", available: false, reason: "already online" },
{ operation: "stop", available: true },
{ operation: "rollback", available: true }
]
} as const;
describe("Client Manager lifecycle schema", () => {
it("preserves safe lifecycle, job progress, versions and action availability", () => {
const parsed = parseSafeClientManagerLifecycle(safeClientManagerLifecycleFixture);
expect(parsed).toMatchObject({ status: "online", health: "healthy", activeVersion: "2.0.0", previousVersion: "1.0.0", job: { state: "running", progress: { percent: 65 } } });
expect(parseSafeClientManagerLifecycleList({ items: [safeClientManagerLifecycleFixture], count: 1 })).toMatchObject({ count: 1, items: [{ profileKey: "scum-client-manager" }] });
});
it.each([
{ runEndpointId: "run-private" },
{ pid: 4124 },
{ secretRef: "redacted" },
{ healthReason: "/Users/operator/client-manager" },
{ healthReason: "unix://private.sock" }
])("rejects machine and credential projection %#", (unsafe) => {
expect(() => parseSafeClientManagerLifecycle({ ...safeClientManagerLifecycleFixture, ...unsafe })).toThrow(/forbidden|sensitive/i);
});
});
@@ -1,117 +0,0 @@
import type {
ClientManagerInstallationListResponse,
ClientManagerInstallationResponse,
ClientManagerLifecycleActionResponse,
ClientManagerLifecycleOperation,
ClientManagerLifecycleStatus,
JobState
} from "../api/types";
const lifecycleStatuses = new Set<ClientManagerLifecycleStatus>([
"requested", "building", "available", "deploying", "installed", "registering", "online", "degraded", "offline", "updating", "rolling_back", "stopping", "uninstalled", "failed"
]);
const lifecycleOperations = new Set<ClientManagerLifecycleOperation>(["deploy", "start", "stop", "restart", "status", "update", "rollback", "uninstall"]);
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
const forbiddenKeys = new Set(["key", "token", "secretref", "secretvalue", "hostpath", "pid", "socket", "credential", "dsn", "password", "runendpointid"]);
const forbiddenFragments = ["secret://", "/users/", "/var/run/", "bearer ", "password=", "unix://", "tcp://", "mysql://", "sqlite://", "rcon://"];
export function parseSafeClientManagerLifecycleList(value: unknown): ClientManagerInstallationListResponse {
const record = object(value, "Client Manager lifecycle list");
rejectSensitiveProjection(record);
const items = array(record.items, "items").map(parseSafeClientManagerLifecycle);
const count = number(record.count, "count");
return { items, count };
}
export function parseSafeClientManagerLifecycle(value: unknown): ClientManagerInstallationResponse {
const record = object(value, "Client Manager lifecycle");
rejectSensitiveProjection(record);
const status = string(record.status, "status") as ClientManagerLifecycleStatus;
if (!lifecycleStatuses.has(status)) throw new Error("Client Manager lifecycle status is invalid");
const actions = array(record.actions, "actions").map(parseAction);
const result: ClientManagerInstallationResponse = {
id: string(record.id, "id"),
serverInstanceId: string(record.serverInstanceId, "serverInstanceId"),
pluginId: string(record.pluginId, "pluginId"),
profileKey: string(record.profileKey, "profileKey"),
targetOs: string(record.targetOs, "targetOs"),
targetArch: string(record.targetArch, "targetArch"),
status,
phase: string(record.phase, "phase"),
keyGeneration: number(record.keyGeneration, "keyGeneration"),
deploymentGeneration: number(record.deploymentGeneration, "deploymentGeneration"),
health: health(record.health),
retryable: boolean(record.retryable, "retryable"),
requiresRedeploy: boolean(record.requiresRedeploy, "requiresRedeploy"),
updatedAt: string(record.updatedAt, "updatedAt"),
actions
};
copyOptionalStrings(record, result, ["desiredVersion", "activeVersion", "previousVersion", "desiredRevision", "activeRevision", "previousRevision", "desiredArtifactId", "activeArtifactId", "previousArtifactId", "currentJobId", "lastSuccessfulJobId", "healthReason", "lastSeenAt", "installedAt", "uninstalledAt"]);
if (record.lastOperation !== undefined) {
const operation = string(record.lastOperation, "lastOperation") as ClientManagerLifecycleOperation;
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager lifecycle operation is invalid");
result.lastOperation = operation;
}
if (record.distribution !== undefined) {
const distribution = object(record.distribution, "distribution");
result.distribution = {
id: string(distribution.id, "distribution.id"), artifactId: string(distribution.artifactId, "distribution.artifactId"), sourceRevision: string(distribution.sourceRevision, "distribution.sourceRevision"),
targetOs: string(distribution.targetOs, "distribution.targetOs"), targetArch: string(distribution.targetArch, "distribution.targetArch"), checksum: string(distribution.checksum, "distribution.checksum"),
keyGeneration: number(distribution.keyGeneration, "distribution.keyGeneration"), status: string(distribution.status, "distribution.status")
};
}
if (record.job !== undefined) {
const job = object(record.job, "job");
const state = string(job.state, "job.state") as JobState;
if (!jobStates.has(state)) throw new Error("Client Manager job state is invalid");
const progress = object(job.progress, "job.progress");
result.job = { id: string(job.id, "job.id"), state, progress: { percent: number(progress.percent, "job.progress.percent"), message: optionalString(progress.message) }, attempt: number(job.attempt, "job.attempt"), createdAt: string(job.createdAt, "job.createdAt"), updatedAt: string(job.updatedAt, "job.updatedAt") };
}
return result;
}
function parseAction(value: unknown): ClientManagerLifecycleActionResponse {
const action = object(value, "action");
const operation = string(action.operation, "action.operation") as ClientManagerLifecycleOperation;
if (!lifecycleOperations.has(operation)) throw new Error("Client Manager action is invalid");
return { operation, available: boolean(action.available, "action.available"), reason: optionalString(action.reason) };
}
function rejectSensitiveProjection(value: unknown, key = ""): void {
if (typeof value === "string") {
const normalized = value.toLowerCase();
if (forbiddenFragments.some((fragment) => normalized.includes(fragment))) throw new Error("Client Manager response contains sensitive machine data");
return;
}
if (Array.isArray(value)) {
value.forEach((item) => rejectSensitiveProjection(item, key));
return;
}
if (value && typeof value === "object") {
for (const [childKey, child] of Object.entries(value)) {
if (forbiddenKeys.has(childKey.toLowerCase())) throw new Error("Client Manager response contains a forbidden field");
rejectSensitiveProjection(child, childKey);
}
}
}
function copyOptionalStrings(source: Record<string, unknown>, target: ClientManagerInstallationResponse, keys: Array<keyof ClientManagerInstallationResponse>) {
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value !== "") (target as unknown as Record<string, unknown>)[key] = value;
}
}
function object(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] { if (!Array.isArray(value)) throw new Error(`${label} must be an array`); return value; }
function string(value: unknown, label: string): string { if (typeof value !== "string" || value === "") throw new Error(`${label} must be a string`); return value; }
function optionalString(value: unknown): string | undefined { return typeof value === "string" && value !== "" ? value : undefined; }
function number(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a number`); return value; }
function boolean(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); return value; }
function health(value: unknown): ClientManagerInstallationResponse["health"] {
if (value === "unknown" || value === "healthy" || value === "degraded" || value === "unhealthy" || value === "offline") return value;
throw new Error("Client Manager health is invalid");
}
-20
View File
@@ -1,5 +1,4 @@
import type {
ClientManagerBuildRequest,
DependencyJobRequest,
LogBackfillRequest,
RunDistributionGenerateRequest,
@@ -92,25 +91,6 @@ export function runUpdateRequest(serverInstanceId: string, artifactId: string, c
};
}
export function clientManagerBuildRequest(input: {
serverInstanceId: string;
profileKey: string;
targetOs: string;
targetArch: string;
repositoryUrl: string;
sourceRevision?: string;
sequence?: number;
}): ClientManagerBuildRequest {
return {
profileKey: input.profileKey.trim(),
targetOs: input.targetOs,
targetArch: input.targetArch,
repositoryUrl: input.repositoryUrl.trim(),
sourceRevision: input.sourceRevision?.trim() || undefined,
idempotencyKey: runtimeIdempotencyKey("client-manager.generate", input.serverInstanceId, input.sequence ?? Date.now())
};
}
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", planDigest = "", sequence = Date.now()): DependencyJobRequest {
return {
probeKey: probeKey.trim(),
-21
View File
@@ -576,26 +576,6 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.plugin-control-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--jelly-highlight),var(--glass-wash),rgba(255,255,255,.24);box-shadow:inset 0 1px 0 var(--crystal-rim);min-width:0}
.plugin-control-row>div{min-width:0}
.plugin-control-row p{margin:2px 0 0;color:var(--ink-faint);font-size:12.5px}
.client-manager-lifecycle-panel h2{display:inline-flex;align-items:center;gap:7px}
.client-manager-lifecycle-list{display:grid;gap:12px;margin-top:12px}
.client-manager-lifecycle-row{display:grid;gap:12px;padding:14px;border-block:1px solid color-mix(in srgb,var(--line-strong) 78%,transparent);background:color-mix(in srgb,var(--surface) 42%,transparent);box-shadow:inset 3px 0 0 color-mix(in srgb,var(--accent) 72%,transparent);min-width:0}
.client-manager-job-progress>div,.client-manager-lifecycle-head,.client-manager-phase-line{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;min-width:0}
.client-manager-lifecycle-head>div:first-child{display:grid;gap:4px;min-width:0}
.client-manager-lifecycle-head strong{color:var(--ink);font-size:15px;overflow-wrap:anywhere}
.client-manager-version-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
.client-manager-version-cell{display:grid;align-content:center;gap:3px;min-height:72px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 80%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 70%,transparent)}
.client-manager-version-cell small,.client-manager-version-cell span{color:var(--ink-soft);font-size:11px;overflow-wrap:anywhere}
.client-manager-version-cell strong{color:var(--ink);font-size:14px;overflow-wrap:anywhere}
.client-manager-phase-line{justify-content:flex-start;color:var(--ink-soft);font-size:12px}
.client-manager-phase-line span:first-child{display:inline-flex;align-items:center;gap:5px;color:var(--ink);font-weight:700}
.client-manager-job-progress{display:grid;gap:6px;padding:10px;border:1px solid color-mix(in srgb,var(--accent) 42%,var(--line));border-radius:6px;background:color-mix(in srgb,var(--accent-soft) 36%,transparent);color:var(--ink-soft);font-size:12px}
.client-manager-job-progress progress{width:100%;height:8px;accent-color:var(--accent)}
.client-manager-recovery{display:flex;align-items:flex-start;gap:8px;padding:10px;border-left:3px solid var(--gold);background:color-mix(in srgb,var(--gold-soft) 38%,transparent);color:var(--ink);font-size:12px;line-height:1.5}
.client-manager-recovery svg{flex:0 0 auto;margin-top:1px}
.client-manager-command-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(112px,1fr));gap:7px}
.client-manager-command-grid .icon-command{justify-content:center;min-width:0}
@media (max-width:1080px){.client-manager-version-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
}
.maintenance-triage-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
.triage-card{min-height:120px;display:grid;align-content:start;gap:6px;padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--corner-sparkle),var(--surface);background-size:52px 52px,auto,auto,auto;background-position:right 8px top 6px,center,center,center;background-repeat:no-repeat;color:var(--ink-soft);cursor:pointer;text-align:left;box-shadow:inset 0 1px 0 var(--crystal-rim),var(--panel-shadow)}
.triage-card svg{color:var(--accent-deep)}
@@ -728,7 +708,6 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.catalog-grid,.console-grid,.metric-grid,.overview-two-col,.resource-list-item,.server-card-grid{grid-template-columns:1fr}
.form-grid,.server-metrics,.server-workspace{grid-template-columns:1fr}
.terminal-command-form{grid-template-columns:1fr}
.client-manager-command-grid,.client-manager-version-grid{grid-template-columns:1fr}
.server-card-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
.server-toolbar{align-items:stretch}
-2
View File
@@ -189,8 +189,6 @@ function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
return ["server.dependencies.manage"];
case "logs.backfill.request":
return ["server.logs.read"];
case "client-manager.request":
return ["server.client-manager.manage"];
case "plugin-lifecycle.request":
return ["server.lifecycle"];
case "ai.invoke":