diff --git a/openspec/changes/auto-managed-server-deployment/.openspec.yaml b/openspec/changes/auto-managed-server-deployment/.openspec.yaml new file mode 100644 index 0000000..08a199a --- /dev/null +++ b/openspec/changes/auto-managed-server-deployment/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-07-28 +goal: Treat guided-install selection as authorization for secure automatic + deployment and recovery. diff --git a/openspec/changes/auto-managed-server-deployment/README.md b/openspec/changes/auto-managed-server-deployment/README.md new file mode 100644 index 0000000..35123d3 --- /dev/null +++ b/openspec/changes/auto-managed-server-deployment/README.md @@ -0,0 +1,3 @@ +# auto-managed-server-deployment + +Automatically deploy guided server installations when their dedicated Run registers. diff --git a/openspec/changes/auto-managed-server-deployment/design.md b/openspec/changes/auto-managed-server-deployment/design.md new file mode 100644 index 0000000..6337d78 --- /dev/null +++ b/openspec/changes/auto-managed-server-deployment/design.md @@ -0,0 +1,20 @@ +## Decision + +The Platform owns the desired deployment definition. A successful, component-authenticated hello from the reserved dedicated Run endpoint is the trigger to enqueue the first guided installation. The enqueue uses a stable idempotency key derived from the server and deployment revision, so reconnects cannot duplicate work. + +The automatic path is restricted to target-bound `guided-install` servers in `draft`. Existing-server and custom-command modes remain explicit because a missing directory can be intentional or user-owned. A future reconcile capability may safely repair guided deployments after an attested drift check; it must not be simulated by blindly reinstalling on every hello. + +## Flow + +1. Create stores a target-bound draft and protected guided definition. +2. The user generates, downloads, and starts the dedicated Run. +3. Platform validates and persists the component hello/session. +4. Platform atomically advances the draft to installing and creates one `process.install` job. +5. Run claims the job through the existing job channel. + +## Boundaries + +- No host path, command, credential, or socket is returned to Platform Web or plugins. +- A running server is never overwritten by this registration trigger. +- Failed jobs use the existing bounded retry policy; reconnects do not create unbounded retries. +- The former deploy endpoint may remain for compatibility, but is not part of the normal user journey. diff --git a/openspec/changes/auto-managed-server-deployment/proposal.md b/openspec/changes/auto-managed-server-deployment/proposal.md new file mode 100644 index 0000000..eb49e02 --- /dev/null +++ b/openspec/changes/auto-managed-server-deployment/proposal.md @@ -0,0 +1,15 @@ +## Why + +Creating a guided server already captures the user's installation intent, directory, and game configuration. Requiring a second Deploy click after the dedicated Run registers exposes an internal bootstrap stage and leaves a healthy Run idle. + +## What Changes + +- Treat `guided-install` as authorization to deploy automatically once the server-scoped Run successfully registers. +- Keep existing-server and custom-command definitions non-destructive: registration never silently reinstalls them. +- Remove Deploy/Retry deploy from the normal server-detail flow; registration and durable job state become the source of deployment progress. + +## Impact + +- `platform/` schedules the initial fenced install from successful dedicated Run registration. +- `platform_web/` presents registration as an automatic deployment wait state rather than an operator action. +- The independent Run contract remains channelized; its SCUM executor work is validated separately and is not exposed to the browser. diff --git a/openspec/changes/auto-managed-server-deployment/specs/managed-server-deployment/spec.md b/openspec/changes/auto-managed-server-deployment/specs/managed-server-deployment/spec.md new file mode 100644 index 0000000..e4104d6 --- /dev/null +++ b/openspec/changes/auto-managed-server-deployment/specs/managed-server-deployment/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Guided creation authorizes automatic initial deployment +The Platform SHALL queue one fenced `process.install` job when the dedicated Run for a target-bound draft with a `guided-install` definition successfully registers. + +#### Scenario: Dedicated Run registers for a guided draft +- **WHEN** the Run presents the valid server-scoped component identity for a guided draft +- **THEN** the Platform persists the session and queues the guided install using the stored deployment revision +- **AND** the server transitions to `installing` without another browser action + +#### Scenario: The dedicated Run reconnects +- **WHEN** the Run registers again after the automatic installation has been queued +- **THEN** the Platform does not create another installation job for the same deployment revision + +### Requirement: Automatic registration dispatch is non-destructive outside guided installs +The Platform SHALL NOT automatically reinstall existing-server or custom-command deployments solely because their Run registers. + +#### Scenario: Existing server Run registers +- **WHEN** a target-bound draft uses `existing-server` and its dedicated Run registers +- **THEN** the Platform records the Run session without creating an installation job + +### Requirement: Normal server management does not require a manual deployment click +The management console SHALL present guided deployment as automatically pending after dedicated Run registration rather than as a Deploy or Retry deploy button. + +#### Scenario: Guided draft awaits Run registration +- **WHEN** an operator opens a newly created guided draft before its dedicated Run has registered +- **THEN** the console directs the operator to generate and start the dedicated Run +- **AND** it does not offer a separate Deploy or Retry deploy action diff --git a/openspec/changes/auto-managed-server-deployment/tasks.md b/openspec/changes/auto-managed-server-deployment/tasks.md new file mode 100644 index 0000000..d26e7cb --- /dev/null +++ b/openspec/changes/auto-managed-server-deployment/tasks.md @@ -0,0 +1,13 @@ +## 1. Platform automatic dispatch + +- [x] 1.1 Queue a fenced guided install after an accepted dedicated Run hello, with stable revision idempotency and no duplicate reconnect dispatch. +- [x] 1.2 Cover guided automatic dispatch and non-guided no-op behavior with service tests. + +## 2. Management workflow + +- [x] 2.1 Remove the normal manual Deploy/Retry deploy controls and describe automatic deployment after Run registration. +- [x] 2.2 Update focused frontend tests for the automatic workflow. + +## 3. Verification + +- [x] 3.1 Run focused backend/frontend tests, type checking, structure validation, and strict OpenSpec validation. diff --git a/platform/service/control.go b/platform/service/control.go index f31a712..981ef1b 100644 --- a/platform/service/control.go +++ b/platform/service/control.go @@ -110,6 +110,9 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R return domain.RunControlHelloResult{}, err } svc.runSessions[hello.RunEndpointID] = session + if err := svc.queueManagedGuidedDeploymentAfterRegistration(hello); err != nil { + return domain.RunControlHelloResult{}, err + } featureFlags := []string{"control.hello", "control.heartbeat", "signed-envelope.v1.optional"} if session.RequireSignedRequests { featureFlags[2] = "signed-envelope.v1.required" @@ -125,6 +128,28 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R }), nil } +// queueManagedGuidedDeploymentAfterRegistration advances only a newly-created, +// target-bound guided server. Selecting guided-install is the owner's prior +// authorization for this bounded write; reconnects remain idempotent. +func (svc *CoreService) queueManagedGuidedDeploymentAfterRegistration(hello domain.RunControlHello) error { + if hello.ComponentKind != domain.DistributionComponentRun || strings.TrimSpace(hello.ServerInstanceID) == "" { + return nil + } + instance, err := svc.store.ServerInstances().Get(hello.ServerInstanceID) + if err != nil { + return err + } + if strings.TrimSpace(instance.DeploymentTargetID) == "" || instance.RunEndpointID != hello.RunEndpointID || instance.State != domain.ServerInstanceStateDraft || instance.Deployment.Mode != domain.ServerDeploymentModeGuided { + return nil + } + _, err = svc.deployServerInstance(domain.ServerLifecycleCommand{ + ServerInstanceID: instance.ID, + ExpectedConfigVersion: instance.ConfigVersion, + IdempotencyKey: fmt.Sprintf("managed-deploy:%s:r%d", instance.ID, instance.Deployment.Revision), + }) + return err +} + func (svc *CoreService) validateDedicatedRunHello(hello domain.RunControlHello) error { if hello.ComponentKind != domain.DistributionComponentRun { return validationError("component-authenticated run hello must use the run component") diff --git a/platform/service/control_test.go b/platform/service/control_test.go index cacbd13..f4a6f69 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -268,6 +268,71 @@ func TestCoreServiceComponentRunCannotClaimDistributionBuild(t *testing.T) { } } +func TestCoreServiceDedicatedRunRegistrationAutomaticallyDeploysGuidedDraftOnly(t *testing.T) { + svc, _ := newLifecycleRunService(t) + plugin := createLifecyclePlugin(t, svc) + endpoint, err := svc.store.RunEndpoints().Get("run-local") + if err != nil { + t.Fatalf("get bootstrap endpoint: %v", err) + } + endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityDistributionBuild, domain.JobCapabilityDeploymentPlan) + if err := svc.store.RunEndpoints().Update(endpoint); err != nil { + t.Fatalf("enable bootstrap capabilities: %v", err) + } + owner := createServiceUserAndLogin(t, svc, domain.User{ID: "managed-deploy-owner", DisplayName: "Managed Deploy Owner", Email: "managed-deploy@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"}) + + guided, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-guided", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Guided", IdempotencyKey: "managed-guided-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: "C:\\scumserver"}}) + if err != nil { + t.Fatalf("create guided draft: %v", err) + } + if guided.Instance.State != domain.ServerInstanceStateDraft { + t.Fatalf("expected draft before Run registration, got %+v", guided.Instance) + } + registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) + stored, err := svc.GetServerInstance(guided.Instance.ID) + if err != nil || stored.State != domain.ServerInstanceStateInstalling { + t.Fatalf("guided registration should queue install, server=%+v err=%v", stored, err) + } + jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID}) + if err != nil || len(jobs) != 1 || jobs[0].Capability != domain.LifecycleCapabilityInstall { + t.Fatalf("expected one automatic install job, jobs=%+v err=%v", jobs, err) + } + registerDedicatedRunForTest(t, svc, guided.Instance, plugin.ID) + jobs, _ = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: guided.Instance.ID}) + if len(jobs) != 1 { + t.Fatalf("Run reconnect must not duplicate automatic install, jobs=%+v", jobs) + } + + existing, err := svc.CreateServerInstanceWorkflowForSession(owner, domain.ServerLifecycleCreate{ID: "managed-existing", PluginID: plugin.ID, DeploymentTargetID: "run-local", Name: "Managed Existing", IdempotencyKey: "managed-existing-create", ProfileKey: "local", Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeExisting, ServerRoot: "C:\\existing-scum"}}) + if err != nil { + t.Fatalf("create existing draft: %v", err) + } + registerDedicatedRunForTest(t, svc, existing.Instance, plugin.ID) + jobs, err = svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: existing.Instance.ID}) + if err != nil || len(jobs) != 0 { + t.Fatalf("existing-server registration must not reinstall, jobs=%+v err=%v", jobs, err) + } +} + +func registerDedicatedRunForTest(t *testing.T, svc *CoreService, instance domain.ServerInstance, pluginID string) { + t.Helper() + key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "") + if err != nil { + t.Fatalf("get dedicated Run key: %v", err) + } + hello := validRunControlHello() + hello.RunEndpointID = instance.RunEndpointID + hello.RegistrationToken = plainKey + hello.ServerInstanceID = instance.ID + hello.PluginID = pluginID + hello.ComponentKind = domain.DistributionComponentRun + hello.KeyGeneration = key.Generation + hello.CapabilityReport.Capabilities = append(hello.CapabilityReport.Capabilities, domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.JobCapabilityDeploymentPlan, "logs.read") + if result, err := svc.RegisterRunHello(hello); err != nil || !result.Accepted { + t.Fatalf("register dedicated Run: result=%+v err=%v", result, err) + } +} + func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) { svc := newTestCoreService() hello, err := svc.RegisterRunHello(validRunControlHello()) diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index 9689344..edf108d 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -91,10 +91,17 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan } func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { - if err := validator.ValidateServerLifecycleCommand(command); err != nil { + if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { return domain.ServerLifecycleResult{}, err } - if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil { + return svc.deployServerInstance(command) +} + +// deployServerInstance is the platform-owned transition from a saved deployment +// definition to one fenced install job. Callers must already have established +// the authority to act for the server. +func (svc *CoreService) deployServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { + if err := validator.ValidateServerLifecycleCommand(command); err != nil { return domain.ServerLifecycleResult{}, err } instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID) diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index e93a1bf..b465984 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -149,7 +149,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,节点以服务器目录执行。

} {kind === "create" && bindingFields.length > 0 &&
运行连接设置

用于插件声明的逻辑连接,不是服务器目录或游戏配置。

{bindingFields.map((field) => )}
} } - {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" ? "部署目标" : "目标"}{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令仅在本次显式展示后可见。"}
} + {step === reviewStep &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
{kind === "create" ? "部署目标" : "目标"}{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}{form.deploymentMode === "guided-install" &&
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
}{isScum &&
完成条件安装/扫描、映射、验证全部通过
}
{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}{kind === "create" ? "随后生成并启动专属 Run;新建并安装模式会在它注册后自动部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}
}
{step < reviewStep ? : }
; diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index 6aba67c..f82c113 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -115,10 +115,6 @@ export function canStopServer(state: ServerInstanceState): boolean { return state === "running"; } -export function canDeployServer(state: ServerInstanceState): boolean { - return state === "draft" || state === "failed"; -} - export function isPendingJobState(state: JobResponse["state"]): boolean { return state === "queued" || state === "accepted" || state === "running" || state === "retrying"; } diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index 18c606c..9b2fad6 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -162,13 +162,10 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain('capability: "process.stop"'); }); - it("submits explicit deploy and retry-deploy actions only through the fenced deployment API", () => { - expect(serverDetailPageSource).toContain("canDeployServer(instance.data.state)"); - expect(serverDetailPageSource).toContain("requestDeployment(instance.data)"); - expect(serverDetailPageSource).toContain("platformApiClient.deployServerInstance(current.id"); - expect(serverDetailPageSource).toContain('serverLifecycleCommandRequest(current, "deploy")'); - expect(serverDetailPageSource).toContain('instance.data.state === "failed" ? "重试部署" : "部署"'); - expect(serverDetailPageSource).toContain('operations.isPending(instance.data.id, instance.data.state === "failed" ? "重试部署服务器" : "部署服务器")'); + it("leaves guided deployment to the dedicated Run registration workflow", () => { + expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)"); + expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)"); + expect(serverDetailPageSource).not.toContain("platformApiClient.deployServerInstance(current.id"); expect(serverDetailPageSource).not.toContain('capability: "process.install"'); }); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index b1f6847..851783d 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, Rocket, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; +import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; @@ -50,7 +50,7 @@ import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } import type { PageComponentProps } from "../contracts/page"; import { jobCapabilityLabel } from "../contracts/jobPresentation"; import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; -import { canDeployServer, canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement"; +import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement"; import { serverDetailSections, serverIsOnline, @@ -212,26 +212,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa }); } - function requestDeployment(current: ServerInstanceResponse) { - const retry = current.state === "failed"; - const intent = retry ? "重试部署服务器" : "部署服务器"; - setConfirm({ - title: intent, - description: `${retry ? "重新" : ""}部署 ${current.name}(${current.id})将向已注册的专属 Run 提交安装任务,确认继续?`, - run: async () => { - const operationId = operations.begin({ intent, targetKind: "server", targetId: current.id, requester: session.displayName }); - try { - const result = await platformApiClient.deployServerInstance(current.id, serverLifecycleCommandRequest(current, "deploy")); - operations.succeed(operationId, `部署任务 ${result.job.id}(${result.job.capability})已派发`, result.job); - await refresh(); - } catch (error) { - operations.fail(operationId, error instanceof Error ? error.message : "部署任务提交失败", operationId); - await refresh(); - } - } - }); - } - async function saveDeploymentWorkflow(form: ServerCreateFormState) { if (instance.status !== "ready") return; const current = instance.data; @@ -296,16 +276,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
{stateLabel(instance.data.state)} - {canDeployServer(instance.data.state) && }