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" && 只有自定义启动器需要这些设置。执行目录留空时,节点以服务器目录执行。 用于插件声明的逻辑连接,不是服务器目录或游戏配置。高级启动设置
运行连接设置