feat: auto-deploy guided servers on run registration

This commit is contained in:
npc0-hue
2026-07-28 16:57:09 +08:00
parent e2d0bc0595
commit 7f64765c1c
15 changed files with 191 additions and 55 deletions
@@ -0,0 +1,4 @@
schema: spec-driven
created: 2026-07-28
goal: Treat guided-install selection as authorization for secure automatic
deployment and recovery.
@@ -0,0 +1,3 @@
# auto-managed-server-deployment
Automatically deploy guided server installations when their dedicated Run registers.
@@ -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.
@@ -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.
@@ -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
@@ -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.
+25
View File
@@ -110,6 +110,9 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
return domain.RunControlHelloResult{}, err return domain.RunControlHelloResult{}, err
} }
svc.runSessions[hello.RunEndpointID] = session 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"} featureFlags := []string{"control.hello", "control.heartbeat", "signed-envelope.v1.optional"}
if session.RequireSignedRequests { if session.RequireSignedRequests {
featureFlags[2] = "signed-envelope.v1.required" featureFlags[2] = "signed-envelope.v1.required"
@@ -125,6 +128,28 @@ func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.R
}), nil }), 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 { func (svc *CoreService) validateDedicatedRunHello(hello domain.RunControlHello) error {
if hello.ComponentKind != domain.DistributionComponentRun { if hello.ComponentKind != domain.DistributionComponentRun {
return validationError("component-authenticated run hello must use the run component") return validationError("component-authenticated run hello must use the run component")
+65
View File
@@ -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) { func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
hello, err := svc.RegisterRunHello(validRunControlHello()) hello, err := svc.RegisterRunHello(validRunControlHello())
+9 -2
View File
@@ -91,10 +91,17 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
} }
func (svc *CoreService) DeployServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) { 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 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 return domain.ServerLifecycleResult{}, err
} }
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID) instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
@@ -149,7 +149,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>} {form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><label><select name="shell" value={form.shell} onChange={updateForm}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></label><label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label><label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label></div></details>}
{kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>} {kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>}
</div>} </div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>{kind === "create" ? "部署目标" : "目标"}</span><strong>{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "随后生成并启动专属 Run它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令仅在本次显式展示后可见。"}</span></div></div>} {step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>{kind === "create" ? "部署目标" : "目标"}</span><strong>{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存草稿并保留专属 Run" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "随后生成并启动专属 Run新建并安装模式会在它注册后自动部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后由平台保留受保护部署设置;路径和命令仅在本次显式展示后可见。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div> <div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? closeWorkflow() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
</form> </form>
</ManagementDialog>; </ManagementDialog>;
@@ -115,10 +115,6 @@ export function canStopServer(state: ServerInstanceState): boolean {
return state === "running"; return state === "running";
} }
export function canDeployServer(state: ServerInstanceState): boolean {
return state === "draft" || state === "failed";
}
export function isPendingJobState(state: JobResponse["state"]): boolean { export function isPendingJobState(state: JobResponse["state"]): boolean {
return state === "queued" || state === "accepted" || state === "running" || state === "retrying"; return state === "queued" || state === "accepted" || state === "running" || state === "retrying";
} }
+4 -7
View File
@@ -162,13 +162,10 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain('capability: "process.stop"'); expect(serverDetailPageSource).not.toContain('capability: "process.stop"');
}); });
it("submits explicit deploy and retry-deploy actions only through the fenced deployment API", () => { it("leaves guided deployment to the dedicated Run registration workflow", () => {
expect(serverDetailPageSource).toContain("canDeployServer(instance.data.state)"); expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)");
expect(serverDetailPageSource).toContain("requestDeployment(instance.data)"); expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)");
expect(serverDetailPageSource).toContain("platformApiClient.deployServerInstance(current.id"); expect(serverDetailPageSource).not.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" ? "重试部署服务器" : "部署服务器")');
expect(serverDetailPageSource).not.toContain('capability: "process.install"'); expect(serverDetailPageSource).not.toContain('capability: "process.install"');
}); });
+2 -32
View File
@@ -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 { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
@@ -50,7 +50,7 @@ import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge }
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { jobCapabilityLabel } from "../contracts/jobPresentation"; import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; 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 { import {
serverDetailSections, serverDetailSections,
serverIsOnline, 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) { async function saveDeploymentWorkflow(form: ServerCreateFormState) {
if (instance.status !== "ready") return; if (instance.status !== "ready") return;
const current = instance.data; const current = instance.data;
@@ -296,16 +276,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
</div> </div>
<div className="action-strip"> <div className="action-strip">
<span className={cx("status-pill", statusClass(instance.data.state))}>{stateLabel(instance.data.state)}</span> <span className={cx("status-pill", statusClass(instance.data.state))}>{stateLabel(instance.data.state)}</span>
{canDeployServer(instance.data.state) && <button
type="button"
className="primary-command"
disabled={deployment.status !== "ready" || !deployment.data.mode || operations.isPending(instance.data.id, instance.data.state === "failed" ? "重试部署服务器" : "部署服务器")}
title={deployment.status !== "ready" || !deployment.data.mode ? "请先配置部署定义" : undefined}
onClick={() => requestDeployment(instance.data)}
>
<Rocket size={15} />
<span>{instance.data.state === "failed" ? "重试部署" : "部署"}</span>
</button>}
<button <button
type="button" type="button"
className="icon-command" className="icon-command"
+1 -1
View File
@@ -157,7 +157,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName }); const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
try { try {
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(nextForm)); const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(nextForm));
operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};请生成并注册专属 Run 后再部署。`, result.job.id ? result.job : undefined); operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};请生成并启动专属 Run,注册成功后平台会自动部署。`, result.job.id ? result.job : undefined);
setForm(defaultServerCreateForm(plugins, endpoints)); setForm(defaultServerCreateForm(plugins, endpoints));
setShowCreate(false); setShowCreate(false);
await refresh(); await refresh();
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types"; import type { GamePluginResponse } from "../api/types";
import { canDeployServer, defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement"; import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
import { serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement"; import { serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
const plugin: GamePluginResponse = { const plugin: GamePluginResponse = {
@@ -42,13 +42,6 @@ const plugin: GamePluginResponse = {
}; };
describe("runtime profile server creation contracts", () => { describe("runtime profile server creation contracts", () => {
it("allows deployment only from draft or failed server states", () => {
expect(canDeployServer("draft")).toBe(true);
expect(canDeployServer("failed")).toBe(true);
expect(canDeployServer("installing")).toBe(false);
expect(canDeployServer("ready")).toBe(false);
});
it("derives logical binding fields from the selected profile", () => { it("derives logical binding fields from the selected profile", () => {
expect(runtimeBindingFields(plugin, "local")).toEqual([ expect(runtimeBindingFields(plugin, "local")).toEqual([
{ key: "java-runtime", required: false, sensitive: false }, { key: "java-runtime", required: false, sensitive: false },