diff --git a/openspec/changes/fix-server-deployment-trigger/.openspec.yaml b/openspec/changes/fix-server-deployment-trigger/.openspec.yaml new file mode 100644 index 0000000..e8209ff --- /dev/null +++ b/openspec/changes/fix-server-deployment-trigger/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-28 diff --git a/openspec/changes/fix-server-deployment-trigger/design.md b/openspec/changes/fix-server-deployment-trigger/design.md new file mode 100644 index 0000000..d754d0c --- /dev/null +++ b/openspec/changes/fix-server-deployment-trigger/design.md @@ -0,0 +1,39 @@ +## Context + +The Platform already validates and dispatches `POST /api/v1/server-instances/{id}/deploy`; it accepts a draft or failed server only after its dedicated Run endpoint is registered. The server detail page exposes start, stop, and edit actions, but never calls that deployment API. As a result, a newly registered Run remains correctly idle and a failed server cannot be retried through the console. + +## Goals / Non-Goals + +**Goals:** + +- Make the existing deployment transition available to an authorized operator from the server detail header. +- Use the current server config version and a unique idempotency key, then display ordinary operation feedback and refresh state. +- Make the action unavailable while installation is already active or the state is not deployable. + +**Non-Goals:** + +- Do not change the backend API, deployment plan, Run protocol, SCUM install sequence, or automatic-start behavior. +- Do not expose protected directories, commands, tokens, or remote connectivity details. +- Do not change the separate build target / dedicated Run identity boundary. + +## Decisions + +- Add the control beside existing lifecycle controls rather than auto-dispatching when a Run heartbeats. Registration proves connectivity only; automatic installation would make starting a downloaded executable perform a material remote write without a final operator action. +- Reuse `platformApiClient.deployServerInstance` rather than duplicating request logic. This preserves backend authorization, version fencing, idempotency, and job projection behavior. +- Reuse the existing operation store and `refresh()` callback so the detail page follows the same feedback pattern as start/stop and immediately shows the job state. + +## Risks / Trade-offs + +- [A stale page submits an outdated version] → The backend's `expectedConfigVersion` check rejects it; the UI refreshes after the error. +- [Users mistake registration for completed installation] → The action label explicitly distinguishes Deploy from Start, and the queued job result is shown through the standard task feedback. +- [A duplicate click creates duplicate work] → A fresh idempotency key is submitted and the backend enforces its lifecycle idempotency rules. + +## Migration Plan + +1. Deploy the frontend change with the existing Platform API. +2. Open a failed or draft server whose dedicated Run is online and select Deploy / Retry deploy. +3. Confirm the resulting install job is assigned to the dedicated endpoint. Rollback only removes the UI control; no data migration is required. + +## Open Questions + +None. diff --git a/openspec/changes/fix-server-deployment-trigger/proposal.md b/openspec/changes/fix-server-deployment-trigger/proposal.md new file mode 100644 index 0000000..3cd3940 --- /dev/null +++ b/openspec/changes/fix-server-deployment-trigger/proposal.md @@ -0,0 +1,24 @@ +## Why + +The dedicated SCUM Run can register successfully, but a failed or draft server has no visible Platform action that submits its existing deployment definition. Operators therefore see an idle Run startup log and cannot advance the installation workflow. + +## What Changes + +- Add an explicit Deploy / Retry deploy control to the server detail actions for draft and failed servers. +- Submit the existing protected deployment definition through the existing authenticated deployment API, using the current config version and a fresh idempotency key. +- Present the queued deployment result in the normal operation feedback and refresh the server/job view. + +## Capabilities + +### New Capabilities + +- `server-deployment-trigger`: Operator-facing dispatch of an already-configured draft or failed server deployment after its dedicated Run registers. + +### Modified Capabilities + +- None. + +## Impact + +- Affects `platform_web/pages/ServerDetailPage.tsx` and its focused UI tests. +- Reuses the existing `POST /api/v1/server-instances/{id}/deploy` contract; no new server API, remote access, credentials, or host data are introduced. diff --git a/openspec/changes/fix-server-deployment-trigger/specs/server-deployment-trigger/spec.md b/openspec/changes/fix-server-deployment-trigger/specs/server-deployment-trigger/spec.md new file mode 100644 index 0000000..6e51875 --- /dev/null +++ b/openspec/changes/fix-server-deployment-trigger/specs/server-deployment-trigger/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Deployable server details expose an explicit deployment trigger +The management console SHALL expose an authorized Deploy action for a server in `draft` or `failed` state when a deployment definition is present. The control MUST remain unavailable for non-deployable states and while an installation action is pending. + +#### Scenario: Retry a failed server after its dedicated Run registers +- **WHEN** an operator opens a failed SCUM server whose dedicated Run endpoint is online +- **THEN** the detail actions expose a Retry deploy control +- **AND** the control is distinct from Start and does not expose protected deployment inputs + +#### Scenario: Active installation does not offer a duplicate trigger +- **WHEN** a server is in `installing` state or its deployment operation is pending +- **THEN** the console does not allow another deployment submission + +### Requirement: Deployment trigger uses the existing fenced lifecycle dispatch +The management console SHALL submit deployment through the existing server deployment API with the instance's current config version and an idempotency key, then refresh the server detail after acceptance or failure. + +#### Scenario: Deployment is accepted +- **WHEN** the operator activates Deploy for an eligible server +- **THEN** the console submits the current expected config version and a unique idempotency key to the existing deployment API +- **AND** it reports the queued job through the standard operation feedback and refreshes the detail state diff --git a/openspec/changes/fix-server-deployment-trigger/tasks.md b/openspec/changes/fix-server-deployment-trigger/tasks.md new file mode 100644 index 0000000..9ed9577 --- /dev/null +++ b/openspec/changes/fix-server-deployment-trigger/tasks.md @@ -0,0 +1,9 @@ +## 1. Server detail deployment action + +- [x] 1.1 Add an explicit Deploy / Retry deploy handler that invokes the existing fenced API with current config version and operation feedback. +- [x] 1.2 Render the action only for deployable draft or failed instances and prevent duplicate submission while pending. + +## 2. Verification + +- [x] 2.1 Extend focused server-detail tests to cover the deploy trigger and its state guards. +- [x] 2.2 Run frontend tests, the structure check, and strict OpenSpec validation. diff --git a/platform_web/contracts/serverManagement.ts b/platform_web/contracts/serverManagement.ts index f82c113..6aba67c 100644 --- a/platform_web/contracts/serverManagement.ts +++ b/platform_web/contracts/serverManagement.ts @@ -115,6 +115,10 @@ 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 a84a2ca..18c606c 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -162,6 +162,16 @@ 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" ? "重试部署服务器" : "部署服务器")'); + expect(serverDetailPageSource).not.toContain('capability: "process.install"'); + }); + it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => { expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)"); expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 851783d..b1f6847 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; +import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, Rocket, 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 { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement"; +import { canDeployServer, canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement"; import { serverDetailSections, serverIsOnline, @@ -212,6 +212,26 @@ 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; @@ -276,6 +296,16 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
{stateLabel(instance.data.state)} + {canDeployServer(instance.data.state) && }