fix: 调试发布run
This commit is contained in:
@@ -27,6 +27,14 @@ Do not place implementation code outside the matching root. Shared contracts mus
|
||||
- If an OpenSpec change is created, update proposal/design/specs/tasks before implementation when behavior, architecture, or validation rules change.
|
||||
- If an OpenSpec change is created, do not mark its tasks complete until verification evidence exists, and run `openspec validate <change> --strict` before completion.
|
||||
|
||||
## Task Creation Rules
|
||||
|
||||
When creating a task, include the following prompt boundaries before implementation starts:
|
||||
|
||||
- Positive prompt (正向提示词): clearly state the intended outcome, accepted success criteria, and the first-party product area the work supports.
|
||||
- Directional prompt (方向提示词): state the expected implementation direction, affected project root, relevant existing patterns to preserve, and verification command or evidence expected.
|
||||
- Boundary prompt (任务边界): explicitly list out-of-scope areas, forbidden product expansions, and files or roots that must not be touched unless the task explicitly requires them.
|
||||
|
||||
## Structure Rules
|
||||
|
||||
Backend roots must keep these concerns in fixed directories:
|
||||
|
||||
@@ -6,7 +6,7 @@ This repository is the game server management platform workspace. It replaces th
|
||||
- `platform_web/`: management console frontend.
|
||||
- `plugins/`: game management plugin workspace. A plugin defines how to create and manage one server type, and one installed plugin can create many server instances.
|
||||
|
||||
The machine-side executor source lives in the separate `git@git.npc0.com:admin343/run.git` repository. Local debug and Docker workflows can keep it at `./run` as an ignored nested checkout, or use another path through `RUN_REPO_DIR`.
|
||||
The machine-side executor source lives in the separate `git@git.npc0.com:admin343/run.git` repository. Local debug and Docker workflows can keep the editable source at `./run` as an ignored nested checkout, or use another path through `RUN_SOURCE_DIR` / legacy `RUN_REPO_DIR`. Local debug snapshots that source into `.local-debug` before building or starting run.
|
||||
|
||||
## Product Scope
|
||||
|
||||
@@ -69,7 +69,7 @@ Run focused checks when working in one root:
|
||||
Run executor checks are owned by the separate run checkout:
|
||||
|
||||
```bash
|
||||
(cd "${RUN_REPO_DIR:-./run}" && go test ./...)
|
||||
(cd "${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-./run}}" && go test ./...)
|
||||
```
|
||||
|
||||
Run the API-backed local debug workspace when you need platform, run, platform_web, and the dev plugin fixture together:
|
||||
@@ -83,12 +83,10 @@ scripts/local-debug-smoke.sh
|
||||
See `docs/local-debug-workspace.md` for ports, disposable data roots, log files, reset steps, smoke evidence, and the required browser walkthrough. This workflow treats frontend local auth fallback as a verification failure.
|
||||
Use `LOCAL_DEBUG_SELF_START=true scripts/local-debug-smoke.sh` when you need the smoke command to own the temporary local stack for the duration of the verification.
|
||||
|
||||
Start local processes:
|
||||
For local debug, prefer the managed scripts because they snapshot run source into the closed `.local-debug` build bucket and start the bootstrap worker from the built binary:
|
||||
|
||||
```bash
|
||||
(cd platform && go run ./cmd/platform)
|
||||
(cd "${RUN_REPO_DIR:-./run}" && go run ./cmd/run)
|
||||
(cd platform_web && npm run dev)
|
||||
scripts/local-debug-start.sh
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
@@ -156,9 +154,8 @@ Typical local debugging:
|
||||
cp platform/.env.example platform/.env
|
||||
cp platform_web/.env.example platform_web/.env
|
||||
|
||||
(cd platform && set -a && source .env && set +a && go run ./cmd/platform)
|
||||
(cd "${RUN_REPO_DIR:-./run}" && go run ./cmd/run)
|
||||
(cd platform_web && npm run dev)
|
||||
scripts/local-debug-start.sh
|
||||
scripts/local-debug-smoke.sh
|
||||
```
|
||||
|
||||
Most common edits:
|
||||
@@ -167,7 +164,10 @@ Most common edits:
|
||||
- Platform file persistence: `PLATFORM_STORAGE_BACKEND=file`, `PLATFORM_METADATA_PATH`, `PLATFORM_LOG_DIR`.
|
||||
- Platform MySQL metadata: `PLATFORM_STORAGE_BACKEND=mysql`, `PLATFORM_MYSQL_DSN=platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true`.
|
||||
- Log body persistence: `PLATFORM_LOG_BODY_BACKEND=file`, `PLATFORM_LOG_DIR`.
|
||||
- Run source checkout: `RUN_REPO_DIR=./run` by default; `run/` is ignored by the browser repository.
|
||||
- Run editable source checkout: `RUN_SOURCE_DIR=./run` by default; legacy `RUN_REPO_DIR` is accepted as an alias; `run/` is ignored by the browser repository.
|
||||
- Run closed build bucket: `RUN_BUILD_BUCKET_ROOT=.local-debug/run/build-buckets`.
|
||||
- Run build source snapshot: `RUN_BUILD_SOURCE_ROOT=.local-debug/run/build-buckets/source/current`.
|
||||
- Run bootstrap binary: `RUN_BOOTSTRAP_BIN=.local-debug/run/build-buckets/bootstrap/bin/run`.
|
||||
- Run worker mode: `RUN_MODE=worker`.
|
||||
- Run-to-platform URL: `RUN_PLATFORM_URL=http://127.0.0.1:8080` locally, `http://platform:8080` in Docker.
|
||||
- Run local data: `RUN_WORKSPACE_ROOT`, `RUN_SPOOL_ROOT`.
|
||||
|
||||
@@ -6,7 +6,8 @@ The local debug workspace runs the real platform API, run worker, platform_web c
|
||||
|
||||
- Platform listens on `http://127.0.0.1:18080` by default.
|
||||
- platform_web listens on `http://127.0.0.1:5173` by default and proxies `/api/v1` plus `/healthz` to platform.
|
||||
- Run worker is loaded from `RUN_REPO_DIR`, defaulting to the ignored nested `./run` checkout, and registers as `run-local-debug`.
|
||||
- Run editable source is loaded from `RUN_SOURCE_DIR` / legacy `RUN_REPO_DIR`, defaulting to the ignored nested `./run` checkout.
|
||||
- Run source is snapshotted into `.local-debug/run/build-buckets/source/current`, then the local bootstrap worker is built into `.local-debug/run/build-buckets/bootstrap/bin/run` and registers as `run-local-debug`.
|
||||
- Disposable state lives under `.local-debug/`.
|
||||
- Logs live under `.local-debug/logs/`.
|
||||
- PIDs live under `.local-debug/pids/`.
|
||||
@@ -60,7 +61,11 @@ Key platform variables:
|
||||
|
||||
Key run variables:
|
||||
|
||||
- `RUN_REPO_DIR=./run`
|
||||
- `RUN_SOURCE_DIR=./run`
|
||||
- `RUN_REPO_DIR=./run` as a legacy alias for `RUN_SOURCE_DIR`
|
||||
- `RUN_BUILD_BUCKET_ROOT=.local-debug/run/build-buckets`
|
||||
- `RUN_BUILD_SOURCE_ROOT=.local-debug/run/build-buckets/source/current`
|
||||
- `RUN_BOOTSTRAP_BIN=.local-debug/run/build-buckets/bootstrap/bin/run`
|
||||
- `RUN_MODE=worker`
|
||||
- `RUN_PLATFORM_URL=http://127.0.0.1:18080`
|
||||
- `RUN_ENDPOINT_ID=run-local-debug`
|
||||
@@ -96,6 +101,9 @@ The smoke command verifies:
|
||||
- dev plugin manifest validation and registration through `POST /api/v1/game-plugins/register-manifest`.
|
||||
- run endpoint heartbeat through `GET /api/v1/run/endpoints?status=online`.
|
||||
- server lifecycle fixture setup through `POST /api/v1/server-instances/workflows/create`.
|
||||
- SCUM run package generation through Platform-dispatched `distribution.build` when the endpoint advertises build capability.
|
||||
- latest run download opening through `POST /api/v1/server-instances/{id}/run/download`.
|
||||
- generated artifact content reads through `/api/v1/artifacts/{id}/content` with chunk, total size, and checksum verification.
|
||||
- job, log stream, artifact, marketplace, and server list references.
|
||||
- `PLATFORM_API_PROXY` and `VITE_PLATFORM_API_BASE_URL=/api/v1`.
|
||||
- `VITE_ENABLE_LOCAL_AUTH_FALLBACK=false`.
|
||||
@@ -173,7 +181,9 @@ The start script wraps these commands with the local debug environment:
|
||||
|
||||
```bash
|
||||
(cd platform && go run ./cmd/platform)
|
||||
(cd "${RUN_REPO_DIR:-./run}" && go run ./cmd/run)
|
||||
source scripts/local-debug-env.sh
|
||||
local_debug_build_bootstrap_run
|
||||
"$RUN_BOOTSTRAP_BIN"
|
||||
npm --prefix platform_web run dev -- --port 5173
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-21
|
||||
@@ -0,0 +1,66 @@
|
||||
## Context
|
||||
|
||||
There are two local run checkouts: `/Users/tasia/Desktop/code/browser/run` and `/Users/tasia/Desktop/code/run`. Both point at `git.npc0.com:admin343/run.git`, but neither should be treated as the final generated runtime artifact. The current-directory checkout is an ignored editable source input for convenience and project-size control. Local debug must copy or upload that source into an ignored closed build bucket before any build-capable worker uses it. The sibling checkout has unrelated dirty changes and should not be required by this workflow.
|
||||
|
||||
Platform already queues `distribution.build` jobs and exposes safe browser download routes only after the build artifact is available. The run worker already has a real build path that fetches secret-bearing build input through a leased job channel, copies an approved build source, builds a target executable, packages it with config, uploads it through the artifact channel, and returns `artifact://<id>` as the terminal job result. This change closes the remaining gaps around source snapshotting, closed build bucket scoping, local smoke proof, and concurrency evidence.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Make local debug use the run checkout under this repository root as editable source input only.
|
||||
- Snapshot that source into an ignored closed build bucket before the bootstrap worker starts or any generated run distribution is built.
|
||||
- Build and start the local bootstrap worker from the bucket snapshot instead of `go run`-ing `browser/run` directly.
|
||||
- Keep run as a separate Git repository and keep browser from tracking run source files.
|
||||
- Build run packages in plugin/job-scoped isolated directories below the closed bucket so multiple servers for the same plugin cannot overwrite each other's source, config, archive, upload, or result.
|
||||
- Prove download works after build completion by opening the latest run distribution reference and reading artifact content chunks.
|
||||
- Report the exact configuration surface needed to run and verify the flow.
|
||||
|
||||
**Non-Goals:**
|
||||
- Do not move run source into browser's tracked source tree.
|
||||
- Do not delete or mutate `/Users/tasia/Desktop/code/run`; only stop using it as the default local debug target.
|
||||
- Do not add arbitrary shell execution, cloud hosting, billing, SaaS marketplace flows, plugin raw credentials, or direct sockets.
|
||||
- Do not implement production signing/KMS or rollout rings.
|
||||
- Do not require a live Windows host for local acceptance; local smoke can cross-compile Windows packages and verify the artifact archive.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Local debug separates editable source from build buckets
|
||||
|
||||
`scripts/local-debug-env.sh` will introduce `RUN_SOURCE_DIR` as the editable source checkout, defaulting to `$LOCAL_DEBUG_ROOT_DIR/run`. The legacy `RUN_REPO_DIR` environment variable remains an alias for source selection for compatibility, but it is not the build or execution artifact path.
|
||||
|
||||
The scripts will snapshot `RUN_SOURCE_DIR` into `RUN_BUILD_SOURCE_ROOT`, defaulting under `$LOCAL_DEBUG_ROOT/run/build-buckets/source/current`. Local debug will build `RUN_BOOTSTRAP_BIN` from that snapshot, then start the bootstrap worker binary. Platform-dispatched `distribution.build` jobs will copy from the bucket snapshot into plugin/job workspaces, never from the editable checkout.
|
||||
|
||||
Alternative considered: set `RUN_BUILD_SOURCE_ROOT=$RUN_SOURCE_DIR` and run from `browser/run`. Rejected because it conflates source input with build/runtime artifacts and allows the editable tree to become the effective execution directory.
|
||||
|
||||
### Decision 2: Build workspaces are plugin/job-scoped
|
||||
|
||||
The run worker will create distribution build workspaces under `RUN_WORKSPACE_ROOT/distribution-builds/<pluginId>/<jobId>`. The plugin dimension keeps same-plugin build queues inspectable and ready for per-plugin scheduling, while the job dimension prevents two servers or two idempotency keys from sharing mutable files. Artifact IDs and build job IDs remain Platform-derived and server-scoped.
|
||||
|
||||
Alternative considered: one directory per server. Rejected because multiple builds for the same plugin should queue and isolate by job, not mutate a long-lived per-server build tree.
|
||||
|
||||
### Decision 3: Download proof is mandatory after build success
|
||||
|
||||
Local smoke should keep the existing action availability check, but when `generate-run` is available it must generate a run package, wait for the build job to succeed, open `/run/download`, read content chunks, verify size/checksum metadata, and reject forbidden fragments. A build-capable run endpoint that cannot produce a downloadable artifact is a failing smoke.
|
||||
|
||||
### Decision 4: `/Users/tasia/Desktop/code/run` remains optional
|
||||
|
||||
The sibling checkout is not required for this workflow once local debug snapshots from `browser/run` by default. It can remain for manual comparison or be removed by the user later, but this change will not delete it, mutate it, or depend on it.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Running `go mod download` during local smoke may need network if caches are cold. Mitigation: tests exercise build logic without network where possible; full smoke may require pre-cached modules or an approved network-capable environment.
|
||||
- [Risk] `RUN_MAX_JOBS>1` does not by itself make the current worker execute multiple jobs at once if its main loop is synchronous. Mitigation: workspace isolation is still required and tested directly; worker parallelism can remain a future scheduler improvement.
|
||||
- [Risk] Cross-compiling Windows packages on macOS validates packaging but not Windows service activation. Mitigation: local acceptance checks archive content and platform artifact flow; OS-native activation remains a target-environment proof.
|
||||
- [Risk] Two run checkouts can confuse operators. Mitigation: local debug prints the editable source, bucket snapshot, bootstrap binary, and final handoff documents the optional sibling checkout clearly.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Update local debug defaults to snapshot `browser/run` into `.local-debug` and pass the snapshot as `RUN_BUILD_SOURCE_ROOT`.
|
||||
2. Harden run distribution build workspace naming and add concurrency/isolation tests in the run checkout.
|
||||
3. Extend local smoke to download and checksum the generated run artifact.
|
||||
4. Run OpenSpec validation, structure checks, focused Platform tests, focused run tests, and smoke/script syntax checks.
|
||||
5. Rollback by pointing `RUN_SOURCE_DIR`/`RUN_REPO_DIR` at another checkout and disabling the new smoke assertions; generated artifacts remain ordinary platform artifacts.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether to keep `/Users/tasia/Desktop/code/run` as a personal scratch checkout is an operator workspace decision; it is not required by local debug after this change.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
The run build/download flow is split across Platform, platform_web, local debug scripts, and the independent run checkout. The editable run source may live at `browser/run` for convenience, but that directory must be treated as source input only. Local debug must snapshot or upload that source into an ignored, closed build bucket, let Platform dispatch `distribution.build`, build from the bucket, and prove the generated artifact can be downloaded and executed/updated without using the editable source tree as the runtime artifact.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Treat `run/` under this repository root as the editable independent run source checkout for local debug, while keeping it ignored by the browser repository and still owned by `git@git.npc0.com:admin343/run.git`.
|
||||
- Snapshot the editable run source into a closed ignored local build bucket before starting build-capable local debug flows; `RUN_BUILD_SOURCE_ROOT` must point at that bucket snapshot, not the editable checkout.
|
||||
- Build the local bootstrap run worker from the bucket snapshot instead of executing `browser/run` in place; generated run packages remain Platform-dispatched `distribution.build` artifacts.
|
||||
- Harden run distribution build workspaces so build output is isolated by plugin and job, not by server-wide mutable directories or editable source folders.
|
||||
- Add tests that prove two servers for the same plugin can generate separate run distributions without artifact/config/key/result cross-talk.
|
||||
- Upgrade local smoke proof so `scum-alpha` run generation is mandatory when the run endpoint advertises `distribution.build`, then download the generated artifact and verify safe metadata.
|
||||
- Document every configuration value operators must provide or may tune for local debug and run distribution builds.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `run-build-download-flow`: Covers local-debug source snapshotting, closed build buckets, plugin/job-scoped run package builds, browser-safe run artifact downloads, and same-plugin multi-server build isolation.
|
||||
|
||||
### Modified Capabilities
|
||||
- `run-distribution-and-client-managers`: Completed implementation must use the current-directory run checkout only as source input and prove generated run artifacts are downloadable.
|
||||
- `artifact-transfer-channel`: Completed implementation must prove browser downloads and run artifact uploads remain chunked, checksummed, and free of leaked host paths or secrets.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected roots: `scripts/`, `platform/`, `platform_web/`, and the ignored independent checkout at `run/`.
|
||||
- Affected local configuration: `RUN_SOURCE_DIR`/legacy `RUN_REPO_DIR`, `RUN_BUILD_BUCKET_ROOT`, `RUN_BUILD_SOURCE_ROOT`, `RUN_BOOTSTRAP_BIN`, `RUN_WORKSPACE_ROOT`, `RUN_SPOOL_ROOT`, `RUN_MAX_JOBS`, `RUN_PLATFORM_URL`, `RUN_ENDPOINT_ID`, platform storage/artifact paths, and bootstrap credentials.
|
||||
- Verification requires structure checks, Platform tests, run tests from `run/`, frontend tests where touched, OpenSpec validation, and local debug smoke evidence.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Local debug snapshots run source into a closed build bucket
|
||||
The system SHALL default local debug run source input to the independent `run/` checkout under the browser repository root when that checkout exists, SHALL keep that checkout outside browser Git tracking, and SHALL snapshot that source into an ignored closed build bucket before build-capable local debug execution.
|
||||
|
||||
#### Scenario: Default run source snapshot resolution
|
||||
- **WHEN** local debug scripts start the run worker without an explicit `RUN_SOURCE_DIR` or legacy `RUN_REPO_DIR`
|
||||
- **THEN** they use `<browser-root>/run` as editable source input, copy it into `RUN_BUILD_SOURCE_ROOT` under an ignored local build bucket, and start the local bootstrap worker from a binary built from that snapshot
|
||||
|
||||
#### Scenario: Sibling checkout is optional
|
||||
- **WHEN** `/Users/tasia/Desktop/code/run` exists or does not exist
|
||||
- **THEN** local debug behavior does not depend on that sibling checkout unless `RUN_SOURCE_DIR` or legacy `RUN_REPO_DIR` is explicitly overridden
|
||||
|
||||
### Requirement: Run distribution builds are plugin and job isolated
|
||||
The run worker SHALL build generated run and client-manager distributions in a closed workspace scoped by plugin ID and job ID, and SHALL NOT use a mutable per-server build directory or editable source checkout for source, config, archive, upload, or terminal result state.
|
||||
|
||||
#### Scenario: Same plugin builds for multiple servers
|
||||
- **WHEN** two `distribution.build` jobs for different servers but the same plugin run concurrently or back-to-back
|
||||
- **THEN** each job writes to a distinct plugin/job workspace and uploads only its assigned artifact ID
|
||||
|
||||
#### Scenario: Build package config isolation
|
||||
- **WHEN** a run distribution archive is produced
|
||||
- **THEN** its config belongs to the job's server instance, plugin, endpoint, target, key generation, and auth key without leaking those secret values through API or UI responses
|
||||
|
||||
### Requirement: Generated run artifacts are downloadable after build success
|
||||
The system SHALL make a generated run distribution downloadable only after the build job succeeds and the referenced artifact is available, checksummed, and owned by the build job.
|
||||
|
||||
#### Scenario: Download latest generated run
|
||||
- **WHEN** a server has an available run distribution
|
||||
- **THEN** `/api/v1/server-instances/{id}/run/download` returns a browser-safe artifact reference and `/api/v1/artifacts/{artifactId}/content` returns bounded byte ranges with checksum headers
|
||||
|
||||
#### Scenario: No synthetic success before artifact upload
|
||||
- **WHEN** a distribution build result is reported before the artifact upload is available
|
||||
- **THEN** Platform rejects the terminal success and the distribution remains non-downloadable
|
||||
|
||||
### Requirement: Local smoke proves run build and download
|
||||
The local debug smoke SHALL fail when a build-capable run endpoint cannot complete run generation and artifact download for the SCUM fixture.
|
||||
|
||||
#### Scenario: Build-capable endpoint smoke
|
||||
- **WHEN** `scum-alpha` exposes `generate-run` as available
|
||||
- **THEN** smoke generates the run package, waits for the build job to succeed, opens the latest download reference, reads the artifact content, verifies size/checksum metadata, and rejects forbidden fragments
|
||||
|
||||
#### Scenario: Build-unavailable endpoint smoke
|
||||
- **WHEN** the endpoint does not advertise `distribution.build`
|
||||
- **THEN** smoke records that generation is unavailable without claiming a fake run artifact was built
|
||||
@@ -0,0 +1,33 @@
|
||||
## Prompt Boundaries
|
||||
|
||||
- [x] 0.1 Positive prompt (正向提示词): complete the first-party 服务器管理 run generation/download flow so `game.scum` servers can build, download, and safely reuse generated run artifacts with per-server keys and auditable jobs.
|
||||
- [x] 0.2 Directional prompt (方向提示词): preserve Platform-owned authorization, current platform_web visual style, `browser/run` as an ignored independent checkout, plugin/job-scoped run build workspaces, and verification through `scripts/check-structure.sh`, focused Go tests, OpenSpec validation, and local debug smoke.
|
||||
- [x] 0.3 Boundary prompt (任务边界): do not add billing, cloud host sales, arbitrary shell execution, raw key exposure, direct plugin transports, tracked browser/run source, or destructive changes to `/Users/tasia/Desktop/code/run`.
|
||||
|
||||
## 1. Local Debug Source Snapshot And Build Bucket
|
||||
|
||||
- [x] 1.1 Add `RUN_SOURCE_DIR` (legacy alias `RUN_REPO_DIR`) for editable source input and keep `/Users/tasia/Desktop/code/run` optional.
|
||||
- [x] 1.2 Snapshot `RUN_SOURCE_DIR` into `RUN_BUILD_SOURCE_ROOT` under `RUN_BUILD_BUCKET_ROOT` before local debug build-capable execution.
|
||||
- [x] 1.3 Build and start the local bootstrap run worker from the bucket snapshot instead of directly executing `browser/run`.
|
||||
- [x] 1.4 Make smoke evidence include the resolved source, bucket, snapshot, bootstrap binary, workspace, spool, and queue configuration.
|
||||
|
||||
## 2. Run Build Isolation
|
||||
|
||||
- [x] 2.1 Scope run distribution build workspaces by plugin ID and job ID.
|
||||
- [x] 2.2 Add run tests proving two same-plugin server builds produce distinct workspaces, artifact IDs, and package configs.
|
||||
- [x] 2.3 Confirm generated archive packaging still includes the executable and config for Linux/tar.gz and Windows/zip targets where locally testable.
|
||||
|
||||
## 3. Download And Smoke Proof
|
||||
|
||||
- [x] 3.1 Extend local smoke to open the latest run download reference after build success.
|
||||
- [x] 3.2 Read generated run artifact content in chunks and verify total size plus checksum metadata.
|
||||
- [x] 3.3 Keep forbidden-fragment checks over distribution, job, artifact, download reference, and chunk evidence.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Run `openspec validate complete-run-build-download-flow --strict`.
|
||||
- [x] 4.2 Run `scripts/check-structure.sh`.
|
||||
- [x] 4.3 Run focused Platform distribution/artifact tests.
|
||||
- [x] 4.4 Run focused `run/` distribution build tests.
|
||||
- [x] 4.5 Run `bash -n scripts/local-debug-smoke.sh scripts/local-debug-start.sh scripts/local-debug-env.sh`.
|
||||
- [x] 4.6 Run local debug smoke or record any environment blocker precisely.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-21
|
||||
@@ -0,0 +1,35 @@
|
||||
## Context
|
||||
|
||||
Server deletion currently reuses the archive path and already restricts the action to the instance owner or a platform administrator. What it does not do is re-check the caller's password before removing the server from active use, which leaves a destructive action one click away once a session is active.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Require a password confirmation before server deletion is accepted.
|
||||
- Preserve the existing owner/platform-admin authorization rule.
|
||||
- Keep the current soft-delete behavior that marks the server instance deleted and preserves history.
|
||||
|
||||
**Non-Goals:**
|
||||
- Implementing hard delete or permanent record erasure.
|
||||
- Changing unrelated server lifecycle permissions.
|
||||
- Adding a new authentication system or password reset flow.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Keep the existing `DELETE /api/v1/server-instances/{id}` route and extend it with a JSON body containing the current password. This avoids inventing a parallel delete endpoint and keeps the UI and API aligned.
|
||||
- Verify deletion authorization in the service layer, not only in the frontend. The request must still be rejected even if the browser skips the confirmation UI.
|
||||
- Reuse the current session user's stored password hash and existing `verifyPassword` helper. No new credential store or token exchange is needed.
|
||||
- Return a generic forbidden response when the password confirmation fails. The UI can present that as a password-confirmation failure without exposing hash or account details.
|
||||
- Surface deletion from the server list card's "运行操作" popover in a "危险操作" group instead of placing it inside the detail metadata panel. Runtime actions remain permission-gated, while eligible creators/owners and platform admins can still reach the delete confirmation.
|
||||
- Update the user-facing copy from "归档" to "删除" so the destructive intent is clear wherever the action is exposed.
|
||||
|
||||
Alternatives considered:
|
||||
- Separate confirm endpoint: rejected because it adds another round trip without changing the security model.
|
||||
- Query-string password: rejected because sensitive data should not live in the URL.
|
||||
- Hard delete: rejected because the platform already models server removal as a deleted state with retained history.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] Sending a password in the request body increases sensitivity of the delete call. → The request already runs over authenticated HTTPS; the frontend must avoid persisting the value beyond the dialog.
|
||||
- [Risk] The UI and API could drift if one side keeps "archive" wording or if the delete entry reappears in details. → Keep the confirmation dialog and API call site in the list runtime action flow together.
|
||||
- [Risk] Password confirmation may feel redundant to power users. → Keep the rule limited to destructive deletion only, not to normal lifecycle operations.
|
||||
@@ -0,0 +1,23 @@
|
||||
## Why
|
||||
|
||||
Server deletion currently trusts role and ownership alone, which is too loose for a destructive action. The UI also lets users trigger deletion without re-entering their password, so a stolen session or stray click can remove a server too easily.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Require server delete to be explicitly confirmed with the current user password.
|
||||
- Allow deletion only for the server creator/owner or a platform administrator.
|
||||
- Keep the existing archive/delete flow, but expose the destructive action from the server list runtime actions with an intentional password confirmation.
|
||||
- Return a clear authorization or password error when the confirmation fails.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `server-deletion`: deletion authorization and password confirmation for server instances.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
- `platform/` delete handler, service authorization, and password verification logic.
|
||||
- `platform_web/` server list runtime-action delete confirmation dialog and API client request payload.
|
||||
- Automated tests covering authorization, password failure, and successful deletion.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Authorized server deletion
|
||||
The system SHALL allow a server instance to be deleted only when the authenticated user is the instance owner or a platform administrator.
|
||||
|
||||
#### Scenario: Owner deletes a server
|
||||
- **WHEN** the instance owner submits a delete request for their server
|
||||
- **THEN** the system SHALL accept the request if all other delete checks pass
|
||||
|
||||
#### Scenario: Non-owner cannot delete
|
||||
- **WHEN** an authenticated user who is neither the owner nor a platform administrator submits a delete request
|
||||
- **THEN** the system SHALL reject the request with forbidden access
|
||||
|
||||
### Requirement: Password confirmation for deletion
|
||||
The system SHALL require the authenticated user to provide their current account password with every server delete request and SHALL reject the request if the password is missing or does not match the current session user.
|
||||
|
||||
#### Scenario: Password mismatch
|
||||
- **WHEN** the authenticated user submits the delete request with an incorrect password
|
||||
- **THEN** the system SHALL reject the request with forbidden access
|
||||
|
||||
#### Scenario: Password required
|
||||
- **WHEN** the authenticated user submits the delete request without a password
|
||||
- **THEN** the system SHALL reject the request as invalid input or forbidden access
|
||||
|
||||
### Requirement: Safe server removal state
|
||||
The system SHALL continue to reject deletion when the server instance is running or installing, and SHALL otherwise mark the server instance as deleted while preserving historical records.
|
||||
|
||||
#### Scenario: Running server cannot be deleted
|
||||
- **WHEN** a delete request targets a running server instance
|
||||
- **THEN** the system SHALL reject the request and keep the server instance intact
|
||||
|
||||
#### Scenario: Successful deletion marks deleted state
|
||||
- **WHEN** a valid delete request targets a stopped or ready server instance
|
||||
- **THEN** the system SHALL mark the server instance as deleted and return the updated instance
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. Backend delete confirmation
|
||||
|
||||
- [x] 1.1 Add a server delete request DTO and extend the service/API contract to accept the current session password on delete.
|
||||
- [x] 1.2 Verify the current session password in the server deletion flow after owner/admin authorization and keep the existing deleted-state behavior.
|
||||
- [x] 1.3 Update API handler docs and backend tests for owner/admin success, password failure, and unsafe-state rejection.
|
||||
|
||||
## 2. Frontend delete flow
|
||||
|
||||
- [x] 2.1 Update the server detail delete confirmation dialog to collect a password and submit it with the delete request.
|
||||
- [x] 2.2 Rename the user-facing action copy from archive to delete where the destructive action is exposed.
|
||||
- [x] 2.3 Update the API client, contracts, and frontend tests for the new delete payload and confirmation state.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Run the structure check and focused backend/frontend tests for the delete flow.
|
||||
|
||||
## 4. Follow-up UI placement
|
||||
|
||||
- [x] 4.1 Move the delete confirmation entry from server detail metadata to the server list runtime action popover.
|
||||
- [x] 4.2 Update frontend tests and verification for the new delete entry placement.
|
||||
@@ -1285,8 +1285,8 @@ func (h *coreHandlers) serverInstances(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// serverInstanceDetail godoc
|
||||
// @Summary Get, update, or archive server instance
|
||||
// @Description Returns one server instance by ID, updates safe metadata, or archives it by marking the instance deleted after safety validation.
|
||||
// @Summary Get, update, or delete server instance
|
||||
// @Description Returns one server instance by ID, updates safe metadata, or deletes it by marking the instance deleted after safety validation and password confirmation. Delete requests send a JSON body with the current password.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -1323,7 +1323,12 @@ func (h *coreHandlers) serverInstanceDetail(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.ServerInstanceFromDomain(instance))
|
||||
case http.MethodDelete:
|
||||
_, err := h.core.ArchiveServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
request, err := decodeJSON[dto.ServerDeletionRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
_, err = h.core.DeleteServerInstanceForSession(bearerToken(r), r.PathValue("id"), request.Password)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
|
||||
@@ -740,30 +740,30 @@ func TestServerInstanceManagementAPI(t *testing.T) {
|
||||
}
|
||||
|
||||
running := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-running-archive",
|
||||
ID: "server-running-delete",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "SCUM Running Archive",
|
||||
Name: "SCUM Running Delete",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, adminSession)
|
||||
unsafeArchive := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, "", adminSession)
|
||||
assertErrorResponse(t, unsafeArchive, http.StatusBadRequest, errorCodeValidation)
|
||||
unsafeDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+running.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
||||
assertErrorResponse(t, unsafeDelete, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
archived := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", "", adminSession)
|
||||
assertStatus(t, archived, http.StatusNoContent)
|
||||
deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/server-management", mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
||||
assertStatus(t, deleted, http.StatusNoContent)
|
||||
activeList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances", adminSession)
|
||||
for _, item := range activeList.Items {
|
||||
if item.ID == "server-management" {
|
||||
t.Fatalf("archived server should be hidden from normal list: %+v", activeList)
|
||||
t.Fatalf("deleted server should be hidden from normal list: %+v", activeList)
|
||||
}
|
||||
}
|
||||
deletedList := getJSONWithAuth[dto.ServerInstanceListResponse](t, router, "/api/v1/server-instances?state=deleted", adminSession)
|
||||
if deletedList.Count != 1 || deletedList.Items[0].ID != "server-management" || deletedList.Items[0].State != domain.ServerInstanceStateDeleted {
|
||||
t.Fatalf("expected explicit deleted filter to return archived server, got %+v", deletedList)
|
||||
t.Fatalf("expected explicit deleted filter to return deleted server, got %+v", deletedList)
|
||||
}
|
||||
|
||||
blank := ""
|
||||
invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-archive", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession)
|
||||
invalidUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/server-instances/server-running-delete", dto.ServerInstanceUpdateRequest{Name: &blank}, adminSession)
|
||||
assertErrorResponse(t, invalidUpdate, http.StatusBadRequest, errorCodeValidation)
|
||||
}
|
||||
|
||||
@@ -849,6 +849,59 @@ func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
||||
assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden)
|
||||
}
|
||||
|
||||
func TestServerInstanceDeleteRequiresOwnershipAndPasswordConfirmation(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
||||
ID: "user-delete-owner-api",
|
||||
DisplayName: "Delete Owner API",
|
||||
Email: "delete-owner-api@example.test",
|
||||
Roles: []string{"server-owner"},
|
||||
Password: "secret-password",
|
||||
}, adminSession)
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
||||
ID: "user-delete-other-api",
|
||||
DisplayName: "Delete Other API",
|
||||
Email: "delete-other-api@example.test",
|
||||
Roles: []string{"server-admin"},
|
||||
Password: "secret-password",
|
||||
}, adminSession)
|
||||
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "delete-owner-api@example.test", Password: "secret-password"}).SessionID
|
||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "delete-other-api@example.test", Password: "secret-password"}).SessionID
|
||||
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-delete-api",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "Delete API Server",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
}, ownerSession)
|
||||
adminTarget := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-delete-admin-api",
|
||||
PluginID: "server.scum",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "Delete Admin API Server",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
}, ownerSession)
|
||||
|
||||
missingPassword := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{}), ownerSession)
|
||||
assertErrorResponse(t, missingPassword, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
wrongPassword := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "wrong-password"}), ownerSession)
|
||||
assertErrorResponse(t, wrongPassword, http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
forbiddenDelete := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "secret-password"}), otherSession)
|
||||
assertErrorResponse(t, forbiddenDelete, http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
adminDeleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+adminTarget.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "operator-local"}), adminSession)
|
||||
assertStatus(t, adminDeleted, http.StatusNoContent)
|
||||
|
||||
deleted := requestWithAuth(t, router, http.MethodDelete, "/api/v1/server-instances/"+instance.ID, mustJSON(t, dto.ServerDeletionRequest{Password: "secret-password"}), ownerSession)
|
||||
assertStatus(t, deleted, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestAIProviderAPIResponseDoesNotExposeRawSecretFields(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
|
||||
@@ -456,6 +456,10 @@ type ServerInstanceUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type ServerDeletionRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type ServerMemberRequest struct {
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
@@ -600,7 +600,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
||||
State: job.State,
|
||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
|
||||
ResultRef: job.ResultRef,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs)},
|
||||
LeaseToken: leaseToken,
|
||||
Attempt: job.Attempt,
|
||||
MaxAttempts: job.RetryPolicy.MaxAttempts,
|
||||
|
||||
@@ -510,7 +510,7 @@ func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request
|
||||
}
|
||||
|
||||
func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time.Time) error {
|
||||
if job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
|
||||
if !strings.HasPrefix(job.ID, "job-plugin-lifecycle-") || job.ExecutionInput.LifecycleOperation == "" || job.ExecutionInput.PluginID == "" {
|
||||
return nil
|
||||
}
|
||||
svc.productionMu.Lock()
|
||||
|
||||
@@ -98,7 +98,7 @@ type Core interface {
|
||||
ListServerAdministratorCandidates(string, string) ([]domain.User, error)
|
||||
AddServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
||||
RemoveServerAdministrator(string, string, string) (domain.ServerInstance, error)
|
||||
ArchiveServerInstanceForSession(string, string) (domain.ServerInstance, error)
|
||||
DeleteServerInstanceForSession(string, string, string) (domain.ServerInstance, error)
|
||||
GetPlatformResourceUsage() (domain.PlatformResourceUsage, error)
|
||||
ListServerMetricsForSession(string) ([]domain.ServerMetrics, error)
|
||||
GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error)
|
||||
@@ -2091,7 +2091,7 @@ func (svc *CoreService) RemoveServerAdministrator(sessionID string, serverInstan
|
||||
return domain.CopyServerInstance(instance), nil
|
||||
}
|
||||
|
||||
func (svc *CoreService) ArchiveServerInstanceForSession(sessionID string, serverInstanceID string) (domain.ServerInstance, error) {
|
||||
func (svc *CoreService) DeleteServerInstanceForSession(sessionID string, serverInstanceID string, password string) (domain.ServerInstance, error) {
|
||||
user, err := svc.GetCurrentUser(sessionID)
|
||||
if err != nil {
|
||||
return domain.ServerInstance{}, err
|
||||
@@ -2103,8 +2103,14 @@ func (svc *CoreService) ArchiveServerInstanceForSession(sessionID string, server
|
||||
if !isPlatformAdmin(user) && instance.OwnerUserID != user.ID {
|
||||
return domain.ServerInstance{}, ErrForbidden
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return domain.ServerInstance{}, validationError("password is required")
|
||||
}
|
||||
if !verifyPassword(user.PasswordHash, password) {
|
||||
return domain.ServerInstance{}, forbiddenError("password confirmation failed")
|
||||
}
|
||||
if instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateInstalling {
|
||||
return domain.ServerInstance{}, validationError("running or installing server instances must be stopped before archive")
|
||||
return domain.ServerInstance{}, validationError("running or installing server instances must be stopped before delete")
|
||||
}
|
||||
if instance.State == domain.ServerInstanceStateDeleted {
|
||||
return domain.CopyServerInstance(instance), nil
|
||||
|
||||
@@ -414,6 +414,105 @@ func TestCoreServiceScopesServerAccessAndMembership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceDeletesServerInstancesWithPasswordConfirmation(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
ownerSession := createServiceUserAndLogin(t, svc, domain.User{
|
||||
ID: "user-delete-owner",
|
||||
DisplayName: "Delete Owner",
|
||||
Email: "delete-owner@example.test",
|
||||
Roles: []string{"server-owner"},
|
||||
PasswordHash: "secret-password",
|
||||
})
|
||||
adminSession := createServiceUserAndLogin(t, svc, domain.User{
|
||||
ID: "user-delete-admin",
|
||||
DisplayName: "Delete Admin",
|
||||
Email: "delete-admin@example.test",
|
||||
Roles: []string{"platform-admin"},
|
||||
PasswordHash: "secret-password",
|
||||
})
|
||||
otherSession := createServiceUserAndLogin(t, svc, domain.User{
|
||||
ID: "user-delete-other",
|
||||
DisplayName: "Delete Other",
|
||||
Email: "delete-other@example.test",
|
||||
Roles: []string{"server-admin"},
|
||||
PasswordHash: "secret-password",
|
||||
})
|
||||
|
||||
ownerInstance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
||||
ID: "server-delete-owner",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "Delete Owner Server",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create owner instance: %v", err)
|
||||
}
|
||||
adminTarget, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
||||
ID: "server-delete-admin",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "Delete Admin Target",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create admin target: %v", err)
|
||||
}
|
||||
runningTarget, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{
|
||||
ID: "server-delete-running",
|
||||
PluginID: plugin.ID,
|
||||
RunEndpointID: endpoint.ID,
|
||||
Name: "Delete Running Target",
|
||||
State: domain.ServerInstanceStateReady,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create running target: %v", err)
|
||||
}
|
||||
runningTarget.State = domain.ServerInstanceStateRunning
|
||||
if err := svc.store.ServerInstances().Update(runningTarget); err != nil {
|
||||
t.Fatalf("set running target state: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.DeleteServerInstanceForSession(otherSession, ownerInstance.ID, "secret-password"); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected non-owner delete to be forbidden, got %v", err)
|
||||
}
|
||||
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, ""); err == nil {
|
||||
t.Fatalf("expected missing password to fail")
|
||||
} else {
|
||||
var validationErr validator.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected missing password to be validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, "wrong-password"); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("expected wrong password to be forbidden, got %v", err)
|
||||
}
|
||||
|
||||
deletedOwner, err := svc.DeleteServerInstanceForSession(ownerSession, ownerInstance.ID, "secret-password")
|
||||
if err != nil {
|
||||
t.Fatalf("delete owner instance: %v", err)
|
||||
}
|
||||
if deletedOwner.State != domain.ServerInstanceStateDeleted {
|
||||
t.Fatalf("expected deleted owner state, got %+v", deletedOwner)
|
||||
}
|
||||
deletedAdmin, err := svc.DeleteServerInstanceForSession(adminSession, adminTarget.ID, "secret-password")
|
||||
if err != nil {
|
||||
t.Fatalf("delete admin target: %v", err)
|
||||
}
|
||||
if deletedAdmin.State != domain.ServerInstanceStateDeleted {
|
||||
t.Fatalf("expected deleted admin state, got %+v", deletedAdmin)
|
||||
}
|
||||
if _, err := svc.DeleteServerInstanceForSession(ownerSession, runningTarget.ID, "secret-password"); err == nil {
|
||||
t.Fatalf("expected running instance delete to fail")
|
||||
} else {
|
||||
var validationErr validator.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected running delete to be validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
|
||||
@@ -220,7 +220,11 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
||||
Capability: capability,
|
||||
TargetKey: actionRef,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: binding.ProfileKey},
|
||||
ExecutionInput: domain.JobExecutionInput{
|
||||
WorkspaceScope: binding.ProfileKey,
|
||||
PluginID: plugin.ID,
|
||||
LifecycleOperation: lifecycleExecutionOperation(action),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Job{}, err
|
||||
@@ -231,6 +235,21 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func lifecycleExecutionOperation(action domain.ServerLifecycleAction) string {
|
||||
switch action {
|
||||
case domain.ServerLifecycleActionCreate:
|
||||
return "install"
|
||||
case domain.ServerLifecycleActionStart:
|
||||
return "start"
|
||||
case domain.ServerLifecycleActionStop:
|
||||
return "stop"
|
||||
case domain.ServerLifecycleActionStatus:
|
||||
return "status"
|
||||
default:
|
||||
return string(action)
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeProfileActionRef(actions domain.PluginLifecycleActions, action domain.ServerLifecycleAction) string {
|
||||
switch action {
|
||||
case domain.ServerLifecycleActionCreate:
|
||||
|
||||
@@ -25,6 +25,9 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall {
|
||||
t.Fatalf("expected install workflow result, got %+v", created)
|
||||
}
|
||||
if created.Job.ExecutionInput.PluginID != "server.scum" || created.Job.ExecutionInput.WorkspaceScope != "local" || created.Job.ExecutionInput.LifecycleOperation != "install" {
|
||||
t.Fatalf("expected install job to carry plugin/profile metadata, got %+v", created.Job.ExecutionInput)
|
||||
}
|
||||
|
||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
|
||||
ready, err := svc.GetServerInstance("server-1")
|
||||
@@ -46,6 +49,9 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
if started.Action != domain.ServerLifecycleActionStart || started.Job.Capability != domain.LifecycleCapabilityStart {
|
||||
t.Fatalf("expected start workflow result, got %+v", started)
|
||||
}
|
||||
if started.Job.ExecutionInput.PluginID != "server.scum" || started.Job.ExecutionInput.WorkspaceScope != "local" || started.Job.ExecutionInput.LifecycleOperation != "start" {
|
||||
t.Fatalf("expected start job to carry plugin/profile metadata, got %+v", started.Job.ExecutionInput)
|
||||
}
|
||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
|
||||
running, err := svc.GetServerInstance("server-1")
|
||||
if err != nil {
|
||||
@@ -66,6 +72,9 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
||||
if stopped.Action != domain.ServerLifecycleActionStop || stopped.Job.Capability != domain.LifecycleCapabilityStop {
|
||||
t.Fatalf("expected stop workflow result, got %+v", stopped)
|
||||
}
|
||||
if stopped.Job.ExecutionInput.PluginID != "server.scum" || stopped.Job.ExecutionInput.WorkspaceScope != "local" || stopped.Job.ExecutionInput.LifecycleOperation != "stop" {
|
||||
t.Fatalf("expected stop job to carry plugin/profile metadata, got %+v", stopped.Job.ExecutionInput)
|
||||
}
|
||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
|
||||
final, err := svc.GetServerInstance("server-1")
|
||||
if err != nil {
|
||||
@@ -282,6 +291,9 @@ func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessi
|
||||
if !claim.HasJob || claim.Job.Capability != capability {
|
||||
t.Fatalf("expected claimed lifecycle job %s, got %+v", capability, claim)
|
||||
}
|
||||
if claim.Job.ExecutionInput.PluginID == "" || claim.Job.ExecutionInput.WorkspaceScope == "" {
|
||||
t.Fatalf("expected lifecycle claim to carry plugin/profile metadata, got %+v", claim.Job.ExecutionInput)
|
||||
}
|
||||
if serverInstanceID != "" && claim.Job.ServerInstanceID != serverInstanceID {
|
||||
t.Fatalf("expected claimed lifecycle job for %s, got %+v", serverInstanceID, claim.Job)
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
return jsonResponse({ ...server, name: "Example Survival Renamed" });
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "DELETE") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({ password: "secret-password" });
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url.endsWith("/api/v1/metrics/platform")) {
|
||||
@@ -600,7 +601,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.updateServerInstance(server.id, { name: "Example Survival Renamed" })).resolves.toMatchObject({ name: "Example Survival Renamed" });
|
||||
await expect(client.archiveServerInstance(server.id)).resolves.toBeUndefined();
|
||||
await expect(client.deleteServerInstance(server.id, { password: "secret-password" })).resolves.toBeUndefined();
|
||||
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
|
||||
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
|
||||
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
|
||||
|
||||
@@ -91,6 +91,7 @@ import type {
|
||||
ServerConfigWriteApprovalRequest,
|
||||
ServerConfigWriteDispatchResponse,
|
||||
ServerInstanceListResponse,
|
||||
ServerDeletionRequest,
|
||||
ServerInstanceUpdateRequest,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberListResponse,
|
||||
@@ -482,8 +483,8 @@ export class PlatformApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async archiveServerInstance(id: string): Promise<void> {
|
||||
return this.request<void>(`/server-instances/${encodeURIComponent(id)}`, { method: "DELETE", parseJson: false });
|
||||
async deleteServerInstance(id: string, request: ServerDeletionRequest): Promise<void> {
|
||||
return this.request<void>(`/server-instances/${encodeURIComponent(id)}`, { method: "DELETE", body: request, parseJson: false });
|
||||
}
|
||||
|
||||
async getPlatformResourceUsage(): Promise<PlatformResourceUsageResponse> {
|
||||
|
||||
@@ -407,6 +407,10 @@ export interface ServerInstanceUpdateRequest {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ServerDeletionRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ServerInstanceListResponse {
|
||||
items: ServerInstanceResponse[];
|
||||
count: number;
|
||||
|
||||
@@ -26,6 +26,9 @@ describe("shared operation dialogs", () => {
|
||||
expect(operationControlsSource).toContain('event.key === "Escape"');
|
||||
expect(operationControlsSource).toContain('document.body.style.overflow = "hidden"');
|
||||
expect(operationControlsSource).toContain("previousFocus?.focus()");
|
||||
expect(operationControlsSource).toContain("const onCloseRef = useRef(onClose)");
|
||||
expect(operationControlsSource).toContain('querySelector<HTMLElement>("input:not([disabled]), select:not([disabled]), textarea:not([disabled])")');
|
||||
expect(operationControlsSource).toContain("}, [open]);");
|
||||
});
|
||||
|
||||
it("keeps management forms in a dialog surface", () => {
|
||||
|
||||
@@ -8,12 +8,13 @@ interface ConfirmDialogProps {
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, description, confirmLabel, danger, busy, onConfirm, onCancel, children }: ConfirmDialogProps) {
|
||||
export function ConfirmDialog({ open, title, description, confirmLabel, danger, busy, confirmDisabled, onConfirm, onCancel, children }: ConfirmDialogProps) {
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const panelRef = useDialogLifecycle(open, onCancel, !busy);
|
||||
@@ -45,7 +46,7 @@ export function ConfirmDialog({ open, title, description, confirmLabel, danger,
|
||||
<button type="button" onClick={onCancel} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className={danger ? "confirm-danger" : "confirm-primary"} onClick={onConfirm} disabled={busy}>
|
||||
<button type="button" className={danger ? "confirm-danger" : "confirm-primary"} onClick={onConfirm} disabled={busy || confirmDisabled}>
|
||||
{busy ? "提交中…" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
@@ -97,6 +98,13 @@ export function ManagementDialog({ open, title, description, wide, onClose, chil
|
||||
|
||||
function useDialogLifecycle(open: boolean, onClose: () => void, canClose: boolean) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const canCloseRef = useRef(canClose);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
canCloseRef.current = canClose;
|
||||
}, [canClose, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -104,13 +112,15 @@ function useDialogLifecycle(open: boolean, onClose: () => void, canClose: boolea
|
||||
}
|
||||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const panel = panelRef.current;
|
||||
const focusTarget = panel?.querySelector<HTMLElement>("button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])");
|
||||
const focusTarget =
|
||||
panel?.querySelector<HTMLElement>("input:not([disabled]), select:not([disabled]), textarea:not([disabled])") ??
|
||||
panel?.querySelector<HTMLElement>("button:not([disabled])");
|
||||
focusTarget?.focus();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && canClose) {
|
||||
if (event.key === "Escape" && canCloseRef.current) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +132,7 @@ function useDialogLifecycle(open: boolean, onClose: () => void, canClose: boolea
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
previousFocus?.focus();
|
||||
};
|
||||
}, [canClose, onClose, open]);
|
||||
}, [open]);
|
||||
|
||||
return panelRef;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { projectRuntimeTrackedJob, runtimeBuildStages } from "./RuntimeTaskProgress";
|
||||
import { projectRuntimeTrackedJob, runtimeBuildStages, runtimeRunBuildStages } from "./RuntimeTaskProgress";
|
||||
|
||||
describe("distribution build job progress", () => {
|
||||
it("projects worker progress messages onto the real build stage", () => {
|
||||
@@ -51,4 +51,16 @@ describe("distribution build job progress", () => {
|
||||
expect(projection.status).toBe("running");
|
||||
expect(projection.message).toContain("第 2 次尝试");
|
||||
});
|
||||
|
||||
it("projects run generation onto the compact four-step run build flow", () => {
|
||||
const projection = projectRuntimeTrackedJob(runtimeRunBuildStages, {
|
||||
id: "job-run-build",
|
||||
state: "running",
|
||||
progress: { percent: 72, message: "build_compile: compiling run target" }
|
||||
});
|
||||
|
||||
expect(runtimeRunBuildStages.map((stage) => stage.label)).toEqual(["拉取 run 更新", "检测构建环境", "构建中", "构建完成"]);
|
||||
expect(projection).toMatchObject({ status: "running", currentStageKey: "build_compile" });
|
||||
expect(projection.stageStatus).toMatchObject({ git_sync: "completed", env_check: "completed", build_compile: "running", package_finalize: "pending" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface RuntimeTaskDialogAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
kind?: "primary" | "default" | "danger";
|
||||
}
|
||||
|
||||
@@ -56,6 +57,13 @@ export const runtimeBuildStages: RuntimeTaskStage[] = [
|
||||
{ key: "package_finalize", label: "打包成功", description: "注入配置、校验 checksum、生成 artifact。" }
|
||||
];
|
||||
|
||||
export const runtimeRunBuildStages: RuntimeTaskStage[] = [
|
||||
{ key: "git_sync", label: "拉取 run 更新", description: "同步平台批准的 run 源码或发布版本。" },
|
||||
{ key: "env_check", label: "检测构建环境", description: "检查 Go 工具链、目标平台和隔离构建目录。" },
|
||||
{ key: "build_compile", label: "构建中", description: "编译所选平台和架构的 run 二进制。" },
|
||||
{ key: "package_finalize", label: "构建完成", description: "校验 checksum、生成 artifact,并登记可下载产物。" }
|
||||
];
|
||||
|
||||
export const runtimeDownloadStages: RuntimeTaskStage[] = [
|
||||
{ key: "scope_check", label: "权限校验", description: "确认当前服务器范围和 artifact 授权。" },
|
||||
{ key: "artifact_lookup", label: "定位产物", description: "读取最新可下载 run 包引用。" },
|
||||
@@ -319,6 +327,8 @@ export function RuntimeTaskProgressDialog({ task, onClose, actions = [] }: Runti
|
||||
}
|
||||
|
||||
const activeStage = task.stages.find((stage) => stage.key === task.currentStageKey) ?? task.stages[0];
|
||||
const activeStageIndex = Math.max(0, task.stages.findIndex((stage) => stage.key === activeStage?.key));
|
||||
const activeStepLabel = task.stages.length > 0 ? `第 ${activeStageIndex + 1}/${task.stages.length} 步` : "无阶段";
|
||||
const statusLabel = runtimeTaskStatusLabel(task.status);
|
||||
const closeLabel = task.status === "running" ? "后台运行" : "关闭";
|
||||
|
||||
@@ -338,6 +348,12 @@ export function RuntimeTaskProgressDialog({ task, onClose, actions = [] }: Runti
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="runtime-task-progress-summary" aria-label="进度展示">
|
||||
<span>进度展示</span>
|
||||
<strong>{activeStage ? `${activeStepLabel} · ${activeStage.label}` : statusLabel}</strong>
|
||||
<small>{task.status === "running" ? "平台正在执行当前阶段,完成后会自动推进下一步。" : task.status === "succeeded" ? "所有阶段已完成,可下载产物或继续后续操作。" : "任务已停止,请查看失败原因和运行日志。"}</small>
|
||||
</div>
|
||||
|
||||
<div className="runtime-task-meter" aria-label={`${task.title} 进度 ${Math.round(task.percent)}%`}>
|
||||
<div className="runtime-task-meter-row">
|
||||
<span>{activeStage?.label ?? statusLabel}</span>
|
||||
@@ -401,6 +417,7 @@ export function RuntimeTaskProgressDialog({ task, onClose, actions = [] }: Runti
|
||||
type="button"
|
||||
className={cx(action.kind === "primary" && "confirm-primary", action.kind === "danger" && "confirm-danger")}
|
||||
disabled={action.disabled || task.status === "running"}
|
||||
title={action.title ?? action.label}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label.includes("下载") ? <Download size={14} /> : <PackageCheck size={14} />}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
|
||||
export type ServerWorkflowViewState = "api" | "local" | "saving";
|
||||
export type ServerLifecycleActionLabel = "create" | "start" | "stop" | "refresh";
|
||||
export type ServerRemovalAction = "archive";
|
||||
export type ServerRemovalAction = "delete";
|
||||
|
||||
export interface ServerCreateFormState {
|
||||
id: string;
|
||||
@@ -136,6 +136,6 @@ export function serverMetadataFormFromInstance(instance: ServerInstanceResponse)
|
||||
return { name: instance.name };
|
||||
}
|
||||
|
||||
export function canArchiveServer(state: ServerInstanceState): boolean {
|
||||
export function canDeleteServer(state: ServerInstanceState): boolean {
|
||||
return state !== "running" && state !== "installing" && state !== "deleted";
|
||||
}
|
||||
|
||||
@@ -162,12 +162,16 @@ describe("first-party console pages", () => {
|
||||
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
|
||||
expect(serversPageSource).toContain("<ManagementDialog");
|
||||
expect(serversPageSource).toContain('className="provider-form dialog-form"');
|
||||
expect(serversPageSource).toContain('name="name"');
|
||||
expect(serversPageSource).toContain('name="profileKey"');
|
||||
expect(serversPageSource).toContain("runtimeBindingFields");
|
||||
expect(serversPageSource).toContain("createProfileUnavailable");
|
||||
expect(serversPageSource).toContain("当前插件没有声明运行配置");
|
||||
expect(serversPageSource).toContain("updateBinding(field.key");
|
||||
expect(serversPageSource).toContain('type={field.sensitive ? "password" : "text"}');
|
||||
expect(serversPageSource).toContain("serverCreateRequestFromForm(form)");
|
||||
expect(serversPageSource).not.toContain('name="id"');
|
||||
expect(serversPageSource).not.toContain("实例 ID");
|
||||
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
|
||||
expect(serversPageSource).not.toContain(forbidden);
|
||||
}
|
||||
@@ -181,10 +185,41 @@ describe("first-party console pages", () => {
|
||||
expect(serversPageSource).not.toContain("<details");
|
||||
});
|
||||
|
||||
it("places server deletion in the list runtime actions with password confirmation", () => {
|
||||
const deleteHandlerSource = serversPageSource.split("async function handleDeleteServer")[1]?.split("function openRunTargetSelection")[0] ?? "";
|
||||
expect(serversPageSource).toContain("deleteServerInstance(deleteConfirmation.serverInstanceId, { password: deletePassword })");
|
||||
expect(serversPageSource).toContain("serverDeleteConfirmation(card.instance)");
|
||||
expect(serversPageSource).toContain("serverDeleteDisabledReason(session, card.instance)");
|
||||
expect(serversPageSource).toContain("危险操作");
|
||||
expect(serversPageSource).toContain("删除服务器");
|
||||
expect(serversPageSource).toContain("请输入当前登录密码");
|
||||
expect(serversPageSource).toContain("canOpenActions");
|
||||
expect(deleteHandlerSource).toContain("await refresh();");
|
||||
expect(deleteHandlerSource).not.toContain("onNavigate");
|
||||
expect(serverDetailPageSource).not.toContain("deleteServerInstance");
|
||||
expect(serverDetailPageSource).not.toContain("serverDeleteConfirmation");
|
||||
});
|
||||
|
||||
it("surfaces runtime actions through progress dialogs with build stages", () => {
|
||||
const generateRunSource = serversPageSource.split("async function generateRunForTarget")[1]?.split("async function handleQuickRuntimeAction")[0] ?? "";
|
||||
expect(serversPageSource).toContain("RuntimeTaskProgressDialog");
|
||||
expect(serversPageSource).toContain("选择生成平台");
|
||||
expect(serversPageSource).toContain("runTargetSelection");
|
||||
expect(serversPageSource).toContain("runtimeRunBuildStages");
|
||||
expect(serversPageSource).toContain("requireQuickRuntimeActionAvailable(instance.id, action)");
|
||||
expect(serversPageSource).toContain("该操作不可用");
|
||||
expect(generateRunSource.indexOf("runtimeTask.runTrackedTask")).toBeLessThan(generateRunSource.indexOf('await requireQuickRuntimeActionAvailable(instance.id, "generate-run")'));
|
||||
expect(serverDetailPageSource).toContain("RuntimeTaskProgressDialog");
|
||||
expect(serverDetailPageSource).toContain("runtimeRunBuildStages");
|
||||
expect(runtimeTaskProgressSource).toContain("runtimeBuildStages");
|
||||
expect(runtimeTaskProgressSource).toContain("runtimeRunBuildStages");
|
||||
expect(runtimeTaskProgressSource).toContain("进度展示");
|
||||
expect(runtimeTaskProgressSource).toContain("activeStepLabel");
|
||||
expect(runtimeTaskProgressSource).toContain("平台正在执行当前阶段");
|
||||
expect(runtimeTaskProgressSource).toContain("拉取 run 更新");
|
||||
expect(runtimeTaskProgressSource).toContain("检测构建环境");
|
||||
expect(runtimeTaskProgressSource).toContain("构建中");
|
||||
expect(runtimeTaskProgressSource).toContain("构建完成");
|
||||
expect(runtimeTaskProgressSource).toContain("拉取代码");
|
||||
expect(runtimeTaskProgressSource).toContain("安装环境");
|
||||
expect(runtimeTaskProgressSource).toContain("编译构建");
|
||||
|
||||
@@ -47,12 +47,13 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
|
||||
});
|
||||
|
||||
it("uses typed server metadata and archive APIs without local fallback mutation", () => {
|
||||
it("uses typed server metadata APIs and keeps destructive deletion out of detail metadata", () => {
|
||||
expect(serverDetailPageSource).toContain("updateServerInstance(instance.id");
|
||||
expect(serverDetailPageSource).toContain("archiveServerInstance(instance.id)");
|
||||
expect(serverDetailPageSource).toContain("serverMetadataUpdateRequestFromForm");
|
||||
expect(serverDetailPageSource).toContain("canArchiveServer(instance.state)");
|
||||
expect(serverDetailPageSource).toContain("配置读取不可用");
|
||||
expect(serverDetailPageSource).not.toContain("deleteServerInstance");
|
||||
expect(serverDetailPageSource).not.toContain("serverDeleteConfirmation");
|
||||
expect(serverDetailPageSource).not.toContain("canDeleteServer(instance.state)");
|
||||
expect(serverDetailPageSource).not.toContain("请输入当前登录密码");
|
||||
expect(serverDetailPageSource).not.toContain("fallbackConfig");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, 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";
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
runtimeDependencyStages,
|
||||
runtimeDownloadStages,
|
||||
runtimeLogStages,
|
||||
runtimeRunBuildStages,
|
||||
runtimeUpdateStages,
|
||||
type RuntimeTaskDialogAction,
|
||||
type RuntimeTaskStage,
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
@@ -60,7 +61,6 @@ import {
|
||||
logBackfillRequest,
|
||||
runDistributionGenerateRequest,
|
||||
runUpdateRequest,
|
||||
serverArchiveConfirmation,
|
||||
serverLifecycleCommandRequest,
|
||||
serverMetadataUpdateRequestFromForm
|
||||
} from "../schemas/serverManagement";
|
||||
@@ -312,7 +312,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
session={session}
|
||||
operations={operations}
|
||||
onChanged={(next) => setInstance({ status: "ready", data: next })}
|
||||
onArchived={() => onNavigate("servers")}
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
||||
@@ -360,14 +359,11 @@ interface ServerMetadataSectionProps {
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
onChanged: (instance: ServerInstanceResponse) => void;
|
||||
onArchived: () => void;
|
||||
}
|
||||
|
||||
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
|
||||
function ServerMetadataSection({ instance, session, operations, onChanged }: ServerMetadataSectionProps) {
|
||||
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||||
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(serverMetadataFormFromInstance(instance));
|
||||
@@ -388,23 +384,6 @@ function ServerMetadataSection({ instance, session, operations, onChanged, onArc
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveServer() {
|
||||
setConfirmBusy(true);
|
||||
const operationId = operations.begin({ intent: "归档服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
try {
|
||||
await platformApiClient.archiveServerInstance(instance.id);
|
||||
operations.succeed(operationId, `服务器已归档:${instance.id}`);
|
||||
setResult({ status: "succeeded", label: `${instance.name} 已归档` });
|
||||
setConfirmArchive(null);
|
||||
onArchived();
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "服务器归档失败");
|
||||
setResult({ status: "failed", label: error instanceof Error ? error.message : "归档失败,平台拒绝当前状态" });
|
||||
} finally {
|
||||
setConfirmBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="server metadata">
|
||||
<div className="panel-header">
|
||||
@@ -423,27 +402,8 @@ function ServerMetadataSection({ instance, session, operations, onChanged, onArc
|
||||
<Pencil size={14} />
|
||||
<span>保存名称</span>
|
||||
</button>
|
||||
<button type="button" className="icon-command danger-command" disabled={!canArchiveServer(instance.state)} onClick={() => setConfirmArchive(serverArchiveConfirmation(instance))}>
|
||||
<Archive size={14} />
|
||||
<span>归档</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{!canArchiveServer(instance.state) && (
|
||||
<p className="provider-id" style={{ marginTop: 10 }}>
|
||||
运行中、安装中或已归档的服务器不能直接归档;请先停止或等待状态稳定。
|
||||
</p>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmArchive !== null}
|
||||
title="归档服务器"
|
||||
description={`确认归档 ${confirmArchive?.name ?? ""}(${confirmArchive?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
|
||||
confirmLabel="确认归档"
|
||||
danger
|
||||
busy={confirmBusy}
|
||||
onCancel={() => setConfirmArchive(null)}
|
||||
onConfirm={() => void archiveServer()}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -930,7 +890,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
async function pushRunArtifact(artifact: { artifactId: string; checksum?: string }) {
|
||||
setRuntimeTaskActions([]);
|
||||
await runOperation(
|
||||
"推送 run 更新",
|
||||
"更新 run",
|
||||
() => platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)),
|
||||
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
|
||||
{
|
||||
@@ -1050,14 +1010,14 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
},
|
||||
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建,artifact ${distribution.artifactId},generation ${distribution.keyGeneration}`,
|
||||
{
|
||||
description: `为 ${instance.name} 构建 ${targetOs}/${targetArch} run 包,包含拉取代码、安装环境、编译和打包进度。`,
|
||||
stages: runtimeBuildStages,
|
||||
description: `为 ${instance.name} 构建 ${targetOs}/${targetArch} run 包,展示拉取 run 更新、检测构建环境、构建中和构建完成进度。`,
|
||||
stages: runtimeRunBuildStages,
|
||||
trackedJobId: (distribution) => distribution.buildJobId,
|
||||
afterSuccess: (distribution) => {
|
||||
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
|
||||
setRuntimeTaskActions([
|
||||
{ label: "下载 run", kind: "primary", onClick: () => void downloadRunArtifact(artifact) },
|
||||
{ label: "推送更新", onClick: () => void pushRunArtifact(artifact) }
|
||||
{ label: "更新 run", disabled: !serverIsOnline(instance.state), title: serverIsOnline(instance.state) ? "更新 run" : "run 未运行,无法在线更新", onClick: () => void pushRunArtifact(artifact) }
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1087,12 +1047,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
|
||||
}
|
||||
)
|
||||
}
|
||||
secondaryLabel="推送更新"
|
||||
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
|
||||
secondaryReason={latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
|
||||
secondaryLabel="更新 run"
|
||||
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null || !serverIsOnline(instance.state)}
|
||||
secondaryReason={!serverIsOnline(instance.state) ? "run 未运行,无法在线更新" : latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
|
||||
onSecondary={() =>
|
||||
void runOperation(
|
||||
"推送 run 更新",
|
||||
"更新 run",
|
||||
async () => {
|
||||
const artifact = latestRunArtifact();
|
||||
if (!artifact) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles } from "lucide-react";
|
||||
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
runtimeDependencyStages,
|
||||
runtimeDownloadStages,
|
||||
runtimeLogStages,
|
||||
runtimeRunBuildStages,
|
||||
runtimeUpdateStages,
|
||||
useRuntimeTaskController
|
||||
} from "../components/RuntimeTaskProgress";
|
||||
import { ManagementDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import {
|
||||
canDeleteServer,
|
||||
defaultServerCreateForm,
|
||||
endpointLabel,
|
||||
pluginLabel,
|
||||
@@ -32,7 +34,8 @@ import {
|
||||
logBackfillRequest,
|
||||
runDistributionGenerateRequest,
|
||||
runUpdateRequest,
|
||||
serverCreateRequestFromForm
|
||||
serverCreateRequestFromForm,
|
||||
serverDeleteConfirmation
|
||||
} from "../schemas/serverManagement";
|
||||
import { isPlatformAdmin } from "../contracts/workspace";
|
||||
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
|
||||
@@ -40,6 +43,12 @@ import { cx } from "../utils/classes";
|
||||
|
||||
type ListState = "loading" | "ready" | "error";
|
||||
|
||||
interface RunTargetSelectionState {
|
||||
instance: ServerInstanceResponse;
|
||||
targetOs: string;
|
||||
targetArch: string;
|
||||
}
|
||||
|
||||
const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
|
||||
{ id: "all", label: "全部" },
|
||||
{ id: "online", label: "在线" },
|
||||
@@ -63,6 +72,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const runtimeTask = useRuntimeTaskController();
|
||||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
|
||||
const [deletePassword, setDeletePassword] = useState("");
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
setListState("loading");
|
||||
@@ -175,30 +188,98 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteServer() {
|
||||
if (!deleteConfirmation) {
|
||||
return;
|
||||
}
|
||||
setDeleteBusy(true);
|
||||
const operationId = operations.begin({ intent: "删除服务器", targetKind: "server", targetId: deleteConfirmation.serverInstanceId, requester: session.displayName });
|
||||
try {
|
||||
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, { password: deletePassword });
|
||||
operations.succeed(operationId, `服务器已删除:${deleteConfirmation.serverInstanceId}`);
|
||||
setDeleteConfirmation(null);
|
||||
setDeletePassword("");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "服务器删除失败", operationId);
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
setDeletePassword("");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function openRunTargetSelection(instance: ServerInstanceResponse) {
|
||||
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
|
||||
setRuntimeTaskActions([]);
|
||||
setRunTargetSelection({ instance, targetOs: defaults.runOs, targetArch: "amd64" });
|
||||
}
|
||||
|
||||
async function handleRunTargetSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!runTargetSelection) {
|
||||
return;
|
||||
}
|
||||
const { instance, targetOs, targetArch } = runTargetSelection;
|
||||
setRunTargetSelection(null);
|
||||
await generateRunForTarget(instance, targetOs, targetArch);
|
||||
}
|
||||
|
||||
async function generateRunForTarget(instance: ServerInstanceResponse, targetOs: string, targetArch: string) {
|
||||
const intent = quickRuntimeActionLabel("generate-run");
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:generate-run`, requester: session.displayName });
|
||||
setRuntimeTaskActions([]);
|
||||
try {
|
||||
const distribution = await runtimeTask.runTrackedTask({
|
||||
title: intent,
|
||||
description: `${instance.name}(${instance.id})选择生成平台 ${targetOs}/${targetArch},正在拉取 run 更新并构建可下载产物。`,
|
||||
stages: runtimeRunBuildStages,
|
||||
start: async () => {
|
||||
await requireQuickRuntimeActionAvailable(instance.id, "generate-run");
|
||||
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
|
||||
return { value: distribution, jobId: distribution.buildJobId };
|
||||
},
|
||||
poll: (jobId) => platformApiClient.getJob(jobId)
|
||||
});
|
||||
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
|
||||
const message = `run ${distribution.targetOs}/${distribution.targetArch} 已构建完成,artifact ${distribution.artifactId}`;
|
||||
operations.succeed(operationId, message);
|
||||
runtimeTask.succeedTask(message);
|
||||
setRuntimeTaskActions([
|
||||
{
|
||||
label: "下载 run",
|
||||
kind: "primary",
|
||||
onClick: () => void downloadGeneratedRun(instance, artifact)
|
||||
},
|
||||
{
|
||||
label: "更新 run",
|
||||
disabled: !serverIsOnline(instance.state),
|
||||
title: serverIsOnline(instance.state) ? "更新 run" : "run 未运行,无法在线更新",
|
||||
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
|
||||
}
|
||||
]);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "运行操作失败";
|
||||
operations.fail(operationId, message, operationId);
|
||||
runtimeTask.failTask(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickRuntimeAction(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction) {
|
||||
if (action === "generate-run") {
|
||||
openRunTargetSelection(instance);
|
||||
return;
|
||||
}
|
||||
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
|
||||
const intent = quickRuntimeActionLabel(action);
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
|
||||
setRuntimeTaskActions([]);
|
||||
let generatedRunArtifact: { artifactId: string; checksum?: string } | null = null;
|
||||
let generatedClientProfile: string | null = null;
|
||||
try {
|
||||
await requireQuickRuntimeActionAvailable(instance.id, action);
|
||||
let message: string;
|
||||
if (action === "generate-run") {
|
||||
const distribution = await runtimeTask.runTrackedTask({
|
||||
title: intent,
|
||||
description: quickRuntimeTaskDescription(instance, action),
|
||||
stages: runtimeBuildStages,
|
||||
start: async () => {
|
||||
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
|
||||
return { value: distribution, jobId: distribution.buildJobId };
|
||||
},
|
||||
poll: (jobId) => platformApiClient.getJob(jobId)
|
||||
});
|
||||
generatedRunArtifact = { artifactId: distribution.artifactId };
|
||||
message = `run 二进制已构建并上传,artifact ${distribution.artifactId}`;
|
||||
} else if (action === "generate-client-manager") {
|
||||
if (action === "generate-client-manager") {
|
||||
const distribution = await runtimeTask.runTrackedTask({
|
||||
title: intent,
|
||||
description: quickRuntimeTaskDescription(instance, action),
|
||||
@@ -261,20 +342,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
}
|
||||
operations.succeed(operationId, message);
|
||||
runtimeTask.succeedTask(message);
|
||||
if (generatedRunArtifact) {
|
||||
const artifact = generatedRunArtifact;
|
||||
setRuntimeTaskActions([
|
||||
{
|
||||
label: "下载 run",
|
||||
kind: "primary",
|
||||
onClick: () => void downloadGeneratedRun(instance, artifact)
|
||||
},
|
||||
{
|
||||
label: "推送更新",
|
||||
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
|
||||
}
|
||||
]);
|
||||
} else if (generatedClientProfile) {
|
||||
if (generatedClientProfile) {
|
||||
const clientProfile = generatedClientProfile;
|
||||
setRuntimeTaskActions([
|
||||
{
|
||||
@@ -314,10 +382,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
|
||||
async function pushGeneratedRunUpdate(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
|
||||
setRuntimeTaskActions([]);
|
||||
const operationId = operations.begin({ intent: "推送 run 更新", targetKind: "server", targetId: `${instance.id}:push-run-update`, requester: session.displayName });
|
||||
const operationId = operations.begin({ intent: "更新 run", targetKind: "server", targetId: `${instance.id}:push-run-update`, requester: session.displayName });
|
||||
try {
|
||||
const message = await runtimeTask.runTask({
|
||||
title: "推送 run 更新",
|
||||
title: "更新 run",
|
||||
description: `${instance.name} 将使用刚生成的 artifact ${artifact.artifactId} 派发 run 自更新任务。`,
|
||||
stages: runtimeUpdateStages,
|
||||
executeStageIndex: 2,
|
||||
@@ -357,6 +425,9 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
}
|
||||
|
||||
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
|
||||
const runTargetPlugin = runTargetSelection ? plugins.find((plugin) => plugin.id === runTargetSelection.instance.pluginId) : undefined;
|
||||
const runTargetOsOptions = runPlatformOptions(runTargetPlugin, runTargetSelection?.targetOs ?? "linux");
|
||||
const runTargetArchOptions = ["amd64", "arm64"];
|
||||
|
||||
return (
|
||||
<section className="servers-page" aria-labelledby="server-page-title">
|
||||
@@ -413,10 +484,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
>
|
||||
<form className="provider-form dialog-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
实例 ID
|
||||
<input name="id" value={form.id} onChange={updateForm} placeholder="server-example-3" required />
|
||||
</label>
|
||||
<label>
|
||||
名称
|
||||
<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required />
|
||||
@@ -481,6 +548,53 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
</form>
|
||||
</ManagementDialog>
|
||||
|
||||
<ManagementDialog
|
||||
open={runTargetSelection !== null}
|
||||
title="选择生成平台"
|
||||
description="选择 run 目标平台和架构后,平台会展示拉取 run 更新、检测构建环境、构建中、构建完成的进度。"
|
||||
onClose={() => setRunTargetSelection(null)}
|
||||
>
|
||||
<form className="provider-form dialog-form" onSubmit={(event) => void handleRunTargetSubmit(event)} aria-label="选择生成平台">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
生成平台
|
||||
<select
|
||||
value={runTargetSelection?.targetOs ?? ""}
|
||||
onChange={(event) => setRunTargetSelection((current) => (current ? { ...current, targetOs: event.target.value } : current))}
|
||||
required
|
||||
>
|
||||
{runTargetOsOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{runPlatformLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
架构
|
||||
<select
|
||||
value={runTargetSelection?.targetArch ?? "amd64"}
|
||||
onChange={(event) => setRunTargetSelection((current) => (current ? { ...current, targetArch: event.target.value } : current))}
|
||||
required
|
||||
>
|
||||
{runTargetArchOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={() => setRunTargetSelection(null)}>取消</button>
|
||||
<button type="submit" className="confirm-primary">
|
||||
<Sparkles size={16} />
|
||||
<span>开始生成</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ManagementDialog>
|
||||
|
||||
<div className="server-toolbar" role="search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
@@ -533,12 +647,41 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
metricsPending={metricsPending}
|
||||
metricsUnavailable={Boolean(metricsError)}
|
||||
canManage={canManageServers}
|
||||
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
|
||||
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
|
||||
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
||||
onDelete={() => {
|
||||
setDeletePassword("");
|
||||
setDeleteConfirmation(serverDeleteConfirmation(card.instance));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmation !== null}
|
||||
title="删除服务器"
|
||||
description={`确认删除 ${deleteConfirmation?.name ?? ""}(${deleteConfirmation?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
|
||||
confirmLabel="确认删除"
|
||||
danger
|
||||
busy={deleteBusy}
|
||||
confirmDisabled={deletePassword.trim() === ""}
|
||||
onCancel={() => {
|
||||
setDeleteConfirmation(null);
|
||||
setDeletePassword("");
|
||||
}}
|
||||
onConfirm={() => void handleDeleteServer()}
|
||||
>
|
||||
<label>
|
||||
请输入当前登录密码
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={deletePassword}
|
||||
onChange={(event) => setDeletePassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
|
||||
</section>
|
||||
);
|
||||
@@ -554,18 +697,43 @@ type ServerQuickRuntimeAction =
|
||||
| "live-logs"
|
||||
| "historical-logs";
|
||||
|
||||
async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> {
|
||||
const runtimeActions = await platformApiClient.getServerRuntimeActions(serverInstanceId);
|
||||
const runtimeAction = runtimeActions.actions.find((candidate) => candidate.key === action);
|
||||
if (!runtimeAction) {
|
||||
throw new Error("平台未返回该运行操作");
|
||||
}
|
||||
if (!runtimeAction.available) {
|
||||
throw new Error(`该操作不可用:${runtimeAction.reason || "平台暂未开放该操作"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function serverDeleteDisabledReason(session: PageComponentProps["session"], instance: ServerInstanceResponse): string {
|
||||
if (!isPlatformAdmin(session) && instance.ownerUserId !== session.id) {
|
||||
return "仅创建人或平台管理员可删除";
|
||||
}
|
||||
if (!canDeleteServer(instance.state)) {
|
||||
return "运行中、安装中或已删除的服务器不能直接删除";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
interface ServerCardProps {
|
||||
card: ServerCardView;
|
||||
metricsPending: boolean;
|
||||
metricsUnavailable: boolean;
|
||||
canManage: boolean;
|
||||
deleteDisabledReason: string;
|
||||
onOpen: () => void;
|
||||
onQuickAction: (action: ServerQuickRuntimeAction) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpen, onQuickAction }: ServerCardProps) {
|
||||
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onQuickAction, onDelete }: ServerCardProps) {
|
||||
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
|
||||
const online = serverIsOnline(instance.state);
|
||||
const canDelete = deleteDisabledReason === "";
|
||||
const canOpenActions = canManage || canDelete;
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
@@ -584,7 +752,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const menuWidth = Math.min(320, Math.max(220, viewportWidth - 24));
|
||||
const estimatedMenuHeight = 232;
|
||||
const estimatedMenuHeight = 288;
|
||||
const left = Math.min(Math.max(12, rect.right - menuWidth), Math.max(12, viewportWidth - menuWidth - 12));
|
||||
const belowTop = rect.bottom + 8;
|
||||
const top = belowTop + estimatedMenuHeight <= viewportHeight - 12 ? belowTop : Math.max(12, rect.top - estimatedMenuHeight - 8);
|
||||
@@ -636,6 +804,9 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
|
||||
}, [closeMenu, menuOpen]);
|
||||
|
||||
const chooseQuickAction = (action: ServerQuickRuntimeAction) => {
|
||||
if (!canManage) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
onQuickAction(action);
|
||||
};
|
||||
@@ -679,7 +850,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
|
||||
<Sparkles size={14} />
|
||||
<span>详情</span>
|
||||
</button>
|
||||
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canManage} title={canManage ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
|
||||
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
|
||||
<span>运行操作</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -692,13 +863,32 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpe
|
||||
<span className="runtime-action-group-label">{group.label}</span>
|
||||
<div className="runtime-action-grid">
|
||||
{group.actions.map((action) => (
|
||||
<button key={action} type="button" className="runtime-action-item" role="menuitem" onClick={() => chooseQuickAction(action)}>
|
||||
<button key={action} type="button" className="runtime-action-item" role="menuitem" disabled={!canManage} title={canManage ? quickRuntimeActionLabel(action) : "当前账号没有运行操作权限"} onClick={() => chooseQuickAction(action)}>
|
||||
<span>{quickRuntimeActionLabel(action)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<section className="runtime-action-group" aria-label="危险操作">
|
||||
<span className="runtime-action-group-label">危险操作</span>
|
||||
<div className="runtime-action-grid">
|
||||
<button
|
||||
type="button"
|
||||
className="runtime-action-item danger-command"
|
||||
role="menuitem"
|
||||
disabled={deleteDisabledReason !== ""}
|
||||
title={deleteDisabledReason || "删除服务器"}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>删除服务器</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
@@ -724,7 +914,7 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||
case "download-run":
|
||||
return "下载 run";
|
||||
case "push-run-update":
|
||||
return "推送更新";
|
||||
return "更新 run";
|
||||
case "generate-client-manager":
|
||||
return "生成客户端";
|
||||
case "dependencies-check":
|
||||
@@ -739,7 +929,10 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||
}
|
||||
|
||||
function quickRuntimeStages(action: ServerQuickRuntimeAction) {
|
||||
if (action === "generate-run" || action === "generate-client-manager") {
|
||||
if (action === "generate-run") {
|
||||
return runtimeRunBuildStages;
|
||||
}
|
||||
if (action === "generate-client-manager") {
|
||||
return runtimeBuildStages;
|
||||
}
|
||||
if (action === "download-run") {
|
||||
@@ -782,6 +975,34 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function runPlatformOptions(plugin: GamePluginResponse | undefined, fallback: string): string[] {
|
||||
const options = new Set<string>();
|
||||
const add = (value: string | undefined) => {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized) {
|
||||
options.add(normalized);
|
||||
}
|
||||
};
|
||||
add(fallback);
|
||||
plugin?.supportedOs?.forEach(add);
|
||||
plugin?.runtimeProfiles?.lifecycleProfiles?.forEach((profile) => profile.platforms?.forEach(add));
|
||||
["linux", "windows", "darwin"].forEach(add);
|
||||
return [...options];
|
||||
}
|
||||
|
||||
function runPlatformLabel(platform: string): string {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return "Linux";
|
||||
case "windows":
|
||||
return "Windows";
|
||||
case "darwin":
|
||||
return "macOS";
|
||||
default:
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
|
||||
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return format(value);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
|
||||
import { serverCreateRequestFromForm } from "./serverManagement";
|
||||
import { serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
|
||||
|
||||
const plugin: GamePluginResponse = {
|
||||
id: "game.runtime",
|
||||
@@ -72,4 +72,16 @@ describe("runtime profile server creation contracts", () => {
|
||||
bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" }
|
||||
});
|
||||
});
|
||||
|
||||
it("generates server instance ids from the visible server name", () => {
|
||||
const form = defaultServerCreateForm([plugin], []);
|
||||
const request = serverCreateRequestFromForm({ ...form, name: " Runtime Server ", bindings: {} }, 17);
|
||||
|
||||
expect(request).toMatchObject({
|
||||
id: "server-runtime-server-17",
|
||||
name: "Runtime Server",
|
||||
idempotencyKey: "web:create:server-runtime-server-17:17"
|
||||
});
|
||||
expect(serverInstanceIdFromName("测试服", 18)).toBe("server-18");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
import type { ServerCreateFormState, ServerMetadataFormState, ServerRemovalConfirmationState } from "../contracts/serverManagement";
|
||||
|
||||
export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
|
||||
const id = form.id.trim();
|
||||
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
|
||||
return {
|
||||
id,
|
||||
pluginId: form.pluginId.trim(),
|
||||
@@ -24,6 +24,20 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
|
||||
};
|
||||
}
|
||||
|
||||
export function serverInstanceIdFromName(name: string, sequence = Date.now()): string {
|
||||
const normalized = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/g, "");
|
||||
const suffix = String(Math.max(0, Math.floor(Number.isFinite(sequence) ? sequence : Date.now())));
|
||||
return normalized ? `server-${normalized}-${suffix}` : `server-${suffix}`;
|
||||
}
|
||||
|
||||
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
|
||||
return {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
@@ -36,9 +50,9 @@ export function serverMetadataUpdateRequestFromForm(form: ServerMetadataFormStat
|
||||
return { name: form.name.trim() };
|
||||
}
|
||||
|
||||
export function serverArchiveConfirmation(instance: ServerInstanceResponse): ServerRemovalConfirmationState {
|
||||
export function serverDeleteConfirmation(instance: ServerInstanceResponse): ServerRemovalConfirmationState {
|
||||
return {
|
||||
action: "archive",
|
||||
action: "delete",
|
||||
serverInstanceId: instance.id,
|
||||
name: instance.name,
|
||||
state: instance.state
|
||||
|
||||
@@ -32,6 +32,14 @@ describe("platform web shared theme CSS", () => {
|
||||
expect(css).toContain(".runtime-action-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))");
|
||||
});
|
||||
|
||||
it("keeps runtime task progress display visually explicit", () => {
|
||||
const css = compact(readThemeCss());
|
||||
|
||||
expect(css).toContain(".runtime-task-progress-summary{display:grid");
|
||||
expect(css).toContain(".runtime-task-progress-summary>span{color:var(--accent)");
|
||||
expect(css).toContain(".runtime-task-meter-track{height:10px");
|
||||
});
|
||||
|
||||
it("keeps server card stat tiles readable over busy backgrounds", () => {
|
||||
const css = compact(readThemeCss());
|
||||
|
||||
|
||||
@@ -585,14 +585,17 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.confirm-panel p{margin:0;color:var(--ink-soft);font-size:14px}
|
||||
.confirm-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
|
||||
.confirm-actions button{min-height:38px;padding:0 16px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:8px;border:1px solid var(--line-strong);background:var(--surface-solid);color:var(--ink-soft);cursor:pointer}
|
||||
.confirm-actions .confirm-primary{background:linear-gradient(135deg,var(--accent),var(--accent-deep));border-color:var(--accent-deep);color:#fff;font-weight:700}
|
||||
.confirm-actions .confirm-primary{border-color:var(--accent-deep);color:#fff;font-weight:700}
|
||||
.confirm-actions .confirm-danger{background:var(--danger);border-color:var(--danger);color:#fff;font-weight:700}
|
||||
.runtime-task-backdrop{z-index:55}
|
||||
.runtime-task-panel{width:min(720px,100%);gap:16px}
|
||||
.runtime-task-header{align-items:flex-start}
|
||||
.runtime-task-header>span:first-child{display:grid;gap:4px;min-width:0}
|
||||
.runtime-task-header strong{color:var(--ink);font-size:18px}
|
||||
.runtime-task-current small,.runtime-task-header small,.runtime-task-stage-copy small{color:var(--ink-faint);font-size:12px;line-height:1.45}
|
||||
.runtime-task-current small,.runtime-task-header small,.runtime-task-progress-summary small,.runtime-task-stage-copy small{color:var(--ink-faint);font-size:12px;line-height:1.45}
|
||||
.runtime-task-progress-summary{display:grid;gap:4px;padding:12px;border:1px solid color-mix(in srgb,var(--accent) 50%,var(--line));border-radius:8px;background:linear-gradient(135deg,color-mix(in srgb,var(--accent-soft) 34%,transparent),transparent 58%),var(--glass-wash),color-mix(in srgb,var(--surface) 86%,var(--accent-soft));box-shadow:inset 0 1px 0 var(--crystal-rim),0 12px 26px color-mix(in srgb,var(--accent) 12%,transparent)}
|
||||
.runtime-task-progress-summary>span{color:var(--accent);font-size:11px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}
|
||||
.runtime-task-progress-summary strong{color:var(--ink);font-size:15px}
|
||||
.runtime-task-meter{display:grid;gap:8px}
|
||||
.runtime-task-meter-row{display:flex;justify-content:space-between;gap:10px;color:var(--ink-soft);font-size:13px}
|
||||
.runtime-task-meter-row strong{color:var(--ink)}
|
||||
|
||||
+117
-5
@@ -26,7 +26,8 @@ export PLATFORM_BOOTSTRAP_ADMIN_PASSWORD="${PLATFORM_BOOTSTRAP_ADMIN_PASSWORD:-o
|
||||
export PLATFORM_SECRET_ENVELOPE_KEY="${PLATFORM_SECRET_ENVELOPE_KEY:-local-debug-secret-envelope-key-change-me}"
|
||||
export PLATFORM_AI_PROVIDER_MODE="${PLATFORM_AI_PROVIDER_MODE:-mock}"
|
||||
|
||||
export RUN_REPO_DIR="${RUN_REPO_DIR:-$(cd "$LOCAL_DEBUG_ROOT_DIR/.." && pwd)/run}"
|
||||
export RUN_SOURCE_DIR="${RUN_SOURCE_DIR:-${RUN_REPO_DIR:-$LOCAL_DEBUG_ROOT_DIR/run}}"
|
||||
export RUN_REPO_DIR="$RUN_SOURCE_DIR"
|
||||
export RUN_MODE="${RUN_MODE:-worker}"
|
||||
export RUN_PLATFORM_URL="${RUN_PLATFORM_URL:-http://127.0.0.1:$LOCAL_DEBUG_PLATFORM_PORT}"
|
||||
export RUN_ENDPOINT_ID="${RUN_ENDPOINT_ID:-run-local-debug}"
|
||||
@@ -34,6 +35,9 @@ export RUN_DISPLAY_NAME="${RUN_DISPLAY_NAME:-Local Debug Run}"
|
||||
export RUN_VERSION="${RUN_VERSION:-0.1.0-local-debug}"
|
||||
export RUN_REGISTRATION_TOKEN="${RUN_REGISTRATION_TOKEN:-local-debug-registration}"
|
||||
export RUN_WORKSPACE_ROOT="${RUN_WORKSPACE_ROOT:-$LOCAL_DEBUG_ROOT/run/workspace}"
|
||||
export RUN_BUILD_BUCKET_ROOT="${RUN_BUILD_BUCKET_ROOT:-$LOCAL_DEBUG_ROOT/run/build-buckets}"
|
||||
export RUN_BUILD_SOURCE_ROOT="${RUN_BUILD_SOURCE_ROOT:-$RUN_BUILD_BUCKET_ROOT/source/current}"
|
||||
export RUN_BOOTSTRAP_BIN="${RUN_BOOTSTRAP_BIN:-$RUN_BUILD_BUCKET_ROOT/bootstrap/bin/run}"
|
||||
export RUN_SPOOL_ROOT="${RUN_SPOOL_ROOT:-$LOCAL_DEBUG_ROOT/run/spool}"
|
||||
export RUN_MAX_JOBS="${RUN_MAX_JOBS:-1}"
|
||||
export RUN_HEARTBEAT_INTERVAL_MS="${RUN_HEARTBEAT_INTERVAL_MS:-2000}"
|
||||
@@ -52,12 +56,120 @@ local_debug_web_url() {
|
||||
printf 'http://127.0.0.1:%s' "$LOCAL_DEBUG_WEB_PORT"
|
||||
}
|
||||
|
||||
ensure_local_run_repo() {
|
||||
if [[ ! -f "$RUN_REPO_DIR/go.mod" ]]; then
|
||||
printf 'run repo not found at %s\n' "$RUN_REPO_DIR" >&2
|
||||
printf 'clone git@git.npc0.com:admin343/run.git beside this repo or set RUN_REPO_DIR to the independent run checkout\n' >&2
|
||||
local_debug_plugin_source_dir() {
|
||||
case "$1" in
|
||||
game.example)
|
||||
printf '%s/plugins/examples/dev-game-plugin' "$LOCAL_DEBUG_ROOT_DIR"
|
||||
;;
|
||||
game.scum)
|
||||
printf '%s/plugins/examples/scum-server-plugin' "$LOCAL_DEBUG_ROOT_DIR"
|
||||
;;
|
||||
*)
|
||||
printf 'unknown local-debug plugin id: %s\n' "$1" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
local_debug_seed_lifecycle_scope() {
|
||||
local source_dir="$1"
|
||||
local scope="$2"
|
||||
local true_binary="/usr/bin/true"
|
||||
if [[ ! -x "$true_binary" ]]; then
|
||||
printf 'required local true executable not found at %s\n' "$true_binary" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$scope/actions" "$scope/bin"
|
||||
local action
|
||||
for action in install start stop restart status; do
|
||||
if [[ -f "$source_dir/actions/$action.json" ]]; then
|
||||
cp "$source_dir/actions/$action.json" "$scope/actions/$action.json"
|
||||
fi
|
||||
done
|
||||
cp "$true_binary" "$scope/bin/install-server"
|
||||
cp "$true_binary" "$scope/bin/game-server"
|
||||
chmod +x "$scope/bin/install-server" "$scope/bin/game-server"
|
||||
}
|
||||
|
||||
local_debug_seed_plugin_lifecycle_template() {
|
||||
local plugin_id="$1"
|
||||
local profile_key="${2:-run-local}"
|
||||
local source_dir
|
||||
source_dir="$(local_debug_plugin_source_dir "$plugin_id")"
|
||||
local_debug_seed_lifecycle_scope "$source_dir" "$RUN_WORKSPACE_ROOT/plugins/$plugin_id/$profile_key"
|
||||
}
|
||||
|
||||
local_debug_seed_server_lifecycle_workspace() {
|
||||
local server_id="$1"
|
||||
local plugin_id="$2"
|
||||
local profile_key="${3:-run-local}"
|
||||
local source_dir
|
||||
source_dir="$(local_debug_plugin_source_dir "$plugin_id")"
|
||||
local_debug_seed_lifecycle_scope "$source_dir" "$RUN_WORKSPACE_ROOT/$server_id"
|
||||
local_debug_seed_lifecycle_scope "$source_dir" "$RUN_WORKSPACE_ROOT/instances/$server_id/$profile_key"
|
||||
}
|
||||
|
||||
local_debug_prepare_run_lifecycle_templates() {
|
||||
mkdir -p "$RUN_WORKSPACE_ROOT"
|
||||
local_debug_seed_plugin_lifecycle_template game.example run-local
|
||||
local_debug_seed_plugin_lifecycle_template game.scum run-local
|
||||
}
|
||||
|
||||
local_debug_require_bucket_path() {
|
||||
local target="$1"
|
||||
local parent
|
||||
parent="$(dirname "$target")"
|
||||
mkdir -p "$parent"
|
||||
local resolved_parent
|
||||
resolved_parent="$(cd "$parent" && pwd -P)"
|
||||
local resolved="$resolved_parent/$(basename "$target")"
|
||||
case "$resolved" in
|
||||
"$LOCAL_DEBUG_ROOT"/* | /private/tmp/browser-local-debug-*/* | /tmp/browser-local-debug-*/*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
printf 'refusing to clear non-local-debug build bucket path: %s\n' "$target" >&2
|
||||
printf 'set RUN_BUILD_BUCKET_ROOT/RUN_BUILD_SOURCE_ROOT under %s or an approved browser-local-debug temp root\n' "$LOCAL_DEBUG_ROOT" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_local_run_repo() {
|
||||
if [[ ! -f "$RUN_SOURCE_DIR/go.mod" ]]; then
|
||||
printf 'run source repo not found at %s\n' "$RUN_SOURCE_DIR" >&2
|
||||
printf 'clone git@git.npc0.com:admin343/run.git into %s/run or set RUN_SOURCE_DIR/RUN_REPO_DIR to another independent run checkout\n' "$LOCAL_DEBUG_ROOT_DIR" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
local_debug_prepare_run_build_source() {
|
||||
ensure_local_run_repo
|
||||
local_debug_require_bucket_path "$RUN_BUILD_SOURCE_ROOT"
|
||||
rm -rf "$RUN_BUILD_SOURCE_ROOT"
|
||||
mkdir -p "$RUN_BUILD_SOURCE_ROOT"
|
||||
rsync -a --delete \
|
||||
--exclude '.git/' \
|
||||
--exclude '.local-debug/' \
|
||||
--exclude '.run-workspace/' \
|
||||
--exclude '.tmp/' \
|
||||
--exclude 'node_modules/' \
|
||||
"$RUN_SOURCE_DIR"/ "$RUN_BUILD_SOURCE_ROOT"/
|
||||
cat >"$RUN_BUILD_SOURCE_ROOT/.local-debug-source.json" <<EOF
|
||||
{
|
||||
"sourceDir": "$RUN_SOURCE_DIR",
|
||||
"buildSourceRoot": "$RUN_BUILD_SOURCE_ROOT",
|
||||
"createdBy": "scripts/local-debug-env.sh"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
local_debug_build_bootstrap_run() {
|
||||
local_debug_prepare_run_build_source
|
||||
mkdir -p "$(dirname "$RUN_BOOTSTRAP_BIN")"
|
||||
(
|
||||
cd "$RUN_BUILD_SOURCE_ROOT"
|
||||
env GOCACHE="$GOCACHE" go build -trimpath -o "$RUN_BOOTSTRAP_BIN" ./cmd/run
|
||||
)
|
||||
chmod +x "$RUN_BOOTSTRAP_BIN"
|
||||
}
|
||||
|
||||
local_debug_forbidden_pattern() {
|
||||
|
||||
+207
-47
@@ -10,28 +10,23 @@ API_URL="$PLATFORM_URL/api/v1"
|
||||
WORK_DIR="$LOCAL_DEBUG_ROOT/smoke"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
cat >"$WORK_DIR/run-build-config.env" <<EOF
|
||||
RUN_SOURCE_DIR=$RUN_SOURCE_DIR
|
||||
RUN_REPO_DIR=$RUN_REPO_DIR
|
||||
RUN_BUILD_BUCKET_ROOT=$RUN_BUILD_BUCKET_ROOT
|
||||
RUN_BUILD_SOURCE_ROOT=$RUN_BUILD_SOURCE_ROOT
|
||||
RUN_BOOTSTRAP_BIN=$RUN_BOOTSTRAP_BIN
|
||||
RUN_WORKSPACE_ROOT=$RUN_WORKSPACE_ROOT
|
||||
RUN_SPOOL_ROOT=$RUN_SPOOL_ROOT
|
||||
RUN_MAX_JOBS=$RUN_MAX_JOBS
|
||||
EOF
|
||||
|
||||
prepare_run_workspace() {
|
||||
local workspace_root="$RUN_WORKSPACE_ROOT"
|
||||
local true_binary="/usr/bin/true"
|
||||
if [[ ! -x "$true_binary" ]]; then
|
||||
printf 'required local true executable not found at %s\n' "$true_binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
for server in server-local-debug scum-alpha scum-beta; do
|
||||
local source_dir="$ROOT_DIR/plugins/examples/dev-game-plugin"
|
||||
if [[ "$server" != "server-local-debug" ]]; then
|
||||
source_dir="$ROOT_DIR/plugins/examples/scum-server-plugin"
|
||||
fi
|
||||
for scope in "$workspace_root/$server" "$workspace_root/instances/$server/run-local"; do
|
||||
mkdir -p "$scope/actions" "$scope/bin"
|
||||
cp "$source_dir/actions/install.json" "$scope/actions/install.json"
|
||||
cp "$source_dir/actions/start.json" "$scope/actions/start.json"
|
||||
cp "$source_dir/actions/stop.json" "$scope/actions/stop.json"
|
||||
cp "$true_binary" "$scope/bin/install-server"
|
||||
cp "$true_binary" "$scope/bin/game-server"
|
||||
chmod +x "$scope/bin/install-server" "$scope/bin/game-server"
|
||||
done
|
||||
done
|
||||
local_debug_prepare_run_lifecycle_templates
|
||||
local_debug_seed_server_lifecycle_workspace server-local-debug game.example run-local
|
||||
local_debug_seed_server_lifecycle_workspace scum-alpha game.scum run-local
|
||||
local_debug_seed_server_lifecycle_workspace scum-beta game.scum run-local
|
||||
local_debug_seed_server_lifecycle_workspace scum-dynamic game.scum run-local
|
||||
}
|
||||
|
||||
prepare_run_workspace
|
||||
@@ -64,7 +59,7 @@ wait_for_url() {
|
||||
}
|
||||
|
||||
start_self_hosted_stack() {
|
||||
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$PLATFORM_ARTIFACT_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$GOCACHE"
|
||||
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$PLATFORM_ARTIFACT_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT" "$GOCACHE"
|
||||
trap cleanup_self_started EXIT
|
||||
|
||||
printf 'self-starting platform for local debug smoke\n'
|
||||
@@ -89,9 +84,9 @@ start_self_hosted_stack() {
|
||||
wait_for_url platform "$PLATFORM_URL/healthz" 45
|
||||
|
||||
printf 'self-starting run worker for local debug smoke\n'
|
||||
ensure_local_run_repo
|
||||
local_debug_build_bootstrap_run
|
||||
(
|
||||
cd "$RUN_REPO_DIR"
|
||||
cd "$(dirname "$RUN_BOOTSTRAP_BIN")"
|
||||
exec env \
|
||||
GOCACHE="$GOCACHE" \
|
||||
RUN_MODE="$RUN_MODE" \
|
||||
@@ -101,12 +96,13 @@ start_self_hosted_stack() {
|
||||
RUN_VERSION="$RUN_VERSION" \
|
||||
RUN_REGISTRATION_TOKEN="$RUN_REGISTRATION_TOKEN" \
|
||||
RUN_WORKSPACE_ROOT="$RUN_WORKSPACE_ROOT" \
|
||||
RUN_BUILD_SOURCE_ROOT="$RUN_BUILD_SOURCE_ROOT" \
|
||||
RUN_SPOOL_ROOT="$RUN_SPOOL_ROOT" \
|
||||
RUN_MAX_JOBS="$RUN_MAX_JOBS" \
|
||||
RUN_HEARTBEAT_INTERVAL_MS="$RUN_HEARTBEAT_INTERVAL_MS" \
|
||||
RUN_POLL_INTERVAL_MS="$RUN_POLL_INTERVAL_MS" \
|
||||
RUN_RETRY_BACKOFF_MS="$RUN_RETRY_BACKOFF_MS" \
|
||||
go run ./cmd/run
|
||||
"$RUN_BOOTSTRAP_BIN"
|
||||
) >"$LOCAL_DEBUG_LOG_DIR/run.log" 2>&1 &
|
||||
SELF_STARTED_PIDS+=("$!")
|
||||
printf '%s' "$!" >"$LOCAL_DEBUG_PID_DIR/run.pid"
|
||||
@@ -178,6 +174,23 @@ register_plugin_manifest() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
create_server_workflow() {
|
||||
local label="$1"
|
||||
local server_id="$2"
|
||||
local request_file="$3"
|
||||
local response_file="$4"
|
||||
if json_post "$API_URL/server-instances/workflows/create" "$request_file" "$response_file" "${AUTH_HEADER[@]}"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -s "$response_file" ]] && response_code_is_duplicate "$response_file"; then
|
||||
json_get "$API_URL/server-instances/$server_id" "$response_file" "${AUTH_HEADER[@]}"
|
||||
return 0
|
||||
fi
|
||||
printf '%s server workflow creation failed; platform response:\n' "$label" >&2
|
||||
sed -n '1,160p' "$response_file" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_file_contains() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
@@ -217,22 +230,15 @@ const missing = [];
|
||||
const declaredPermissions = Array.isArray(plugin.declaredPermissions) ? plugin.declaredPermissions : [];
|
||||
const bridgeActions = Array.isArray(plugin.bridgeActions) ? plugin.bridgeActions : [];
|
||||
const lifecycleProfiles = plugin.runtimeProfiles?.lifecycleProfiles ?? [];
|
||||
const clientManagers = plugin.runtimeProfiles?.clientManagers ?? [];
|
||||
if (!declaredPermissions.includes("server.run.distribution")) {
|
||||
missing.push("server.run.distribution permission");
|
||||
}
|
||||
if (!declaredPermissions.includes("server.client-manager.manage")) {
|
||||
missing.push("server.client-manager.manage permission");
|
||||
}
|
||||
if (!bridgeActions.includes("run.distribution.request")) {
|
||||
missing.push("run.distribution.request bridge action");
|
||||
}
|
||||
if (!lifecycleProfiles.some((profile) => profile.key === "run-local")) {
|
||||
missing.push("run-local runtime profile");
|
||||
}
|
||||
if (!clientManagers.some((profile) => profile.key === "scum-client-manager")) {
|
||||
missing.push("scum-client-manager runtime profile");
|
||||
}
|
||||
if (!plugin.gameClientBridge?.commands?.length) {
|
||||
missing.push("game client bridge declarations");
|
||||
}
|
||||
@@ -299,6 +305,143 @@ if (artifact.id !== artifactId || artifact.state !== "available" || !/^sha256:/.
|
||||
NODE
|
||||
}
|
||||
|
||||
read_latest_run_download_content() {
|
||||
local server_id="$1"
|
||||
local download_file="$2"
|
||||
local payload_file="$3"
|
||||
local chunk_dir="$4"
|
||||
local request_file="$download_file.request.json"
|
||||
mkdir -p "$chunk_dir"
|
||||
printf '{}\n' >"$request_file"
|
||||
json_post "$API_URL/server-instances/$server_id/run/download" "$request_file" "$download_file" "${AUTH_HEADER[@]}"
|
||||
reject_forbidden_fragments "$download_file"
|
||||
|
||||
local artifact_id
|
||||
local download_url
|
||||
local total_size
|
||||
local checksum
|
||||
local chunk_size
|
||||
artifact_id="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.artifactId) process.exit(2); process.stdout.write(data.artifactId);' "$download_file")"
|
||||
download_url="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!data.downloadUrl) process.exit(2); process.stdout.write(data.downloadUrl);' "$download_file")"
|
||||
total_size="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!Number.isSafeInteger(data.sizeBytes) || data.sizeBytes <= 0) process.exit(2); process.stdout.write(String(data.sizeBytes));' "$download_file")"
|
||||
checksum="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!/^sha256:/.test(data.checksum || "")) process.exit(2); process.stdout.write(data.checksum);' "$download_file")"
|
||||
chunk_size="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const size=Number(data.chunkSizeBytes || 1048576); if (!Number.isSafeInteger(size) || size <= 0) process.exit(2); process.stdout.write(String(size));' "$download_file")"
|
||||
|
||||
: >"$payload_file"
|
||||
local offset=0
|
||||
local index=0
|
||||
while (( offset < total_size )); do
|
||||
local limit="$chunk_size"
|
||||
local remaining=$((total_size - offset))
|
||||
if (( remaining < limit )); then
|
||||
limit="$remaining"
|
||||
fi
|
||||
local headers_file="$chunk_dir/chunk-$index.headers"
|
||||
local chunk_file="$chunk_dir/chunk-$index.bin"
|
||||
curl -fsS -D "$headers_file" -H "Authorization: Bearer $SESSION_ID" "$PLATFORM_URL$download_url?offset=$offset&limit=$limit" -o "$chunk_file"
|
||||
reject_forbidden_fragments "$headers_file"
|
||||
cat "$chunk_file" >>"$payload_file"
|
||||
node - "$download_file" "$headers_file" "$chunk_file" "$offset" "$limit" <<'NODE'
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const reference = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const headerLines = fs.readFileSync(process.argv[3], "utf8").split(/\r?\n/);
|
||||
const body = fs.readFileSync(process.argv[4]);
|
||||
const offset = Number(process.argv[5]);
|
||||
const limit = Number(process.argv[6]);
|
||||
const headers = new Map();
|
||||
for (const line of headerLines) {
|
||||
const index = line.indexOf(":");
|
||||
if (index > 0) {
|
||||
headers.set(line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim());
|
||||
}
|
||||
}
|
||||
const bodyChecksum = `sha256:${crypto.createHash("sha256").update(body).digest("hex")}`;
|
||||
const fail = (message) => {
|
||||
console.error(message);
|
||||
console.error({ reference, offset, limit, headers: Object.fromEntries(headers), bodyBytes: body.length });
|
||||
process.exit(1);
|
||||
};
|
||||
if (headers.get("x-artifact-id") !== reference.artifactId) {
|
||||
fail("artifact content route returned the wrong artifact id");
|
||||
}
|
||||
if (headers.get("x-artifact-checksum") !== reference.checksum) {
|
||||
fail("artifact content route returned the wrong full checksum");
|
||||
}
|
||||
if (headers.get("x-artifact-content-checksum") !== bodyChecksum) {
|
||||
fail("artifact content route returned the wrong chunk checksum");
|
||||
}
|
||||
if (Number(headers.get("content-length")) !== body.length || body.length !== limit) {
|
||||
fail("artifact content chunk size did not match request");
|
||||
}
|
||||
if (offset > 0 || body.length !== reference.sizeBytes) {
|
||||
const range = headers.get("content-range") || "";
|
||||
if (!range.includes(`/${reference.sizeBytes}`)) {
|
||||
fail("partial artifact response did not include the expected total size");
|
||||
}
|
||||
}
|
||||
NODE
|
||||
offset=$((offset + limit))
|
||||
index=$((index + 1))
|
||||
done
|
||||
|
||||
node - "$download_file" "$payload_file" "$index" "$artifact_id" "$checksum" <<'NODE'
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const reference = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const payload = fs.readFileSync(process.argv[3]);
|
||||
const chunkCount = Number(process.argv[4]);
|
||||
const artifactId = process.argv[5];
|
||||
const checksum = process.argv[6];
|
||||
const actualChecksum = `sha256:${crypto.createHash("sha256").update(payload).digest("hex")}`;
|
||||
if (reference.artifactId !== artifactId || reference.checksum !== checksum || payload.length !== reference.sizeBytes || actualChecksum !== reference.checksum || chunkCount < 1) {
|
||||
console.error("downloaded artifact content did not match reference metadata");
|
||||
console.error({ reference, payloadBytes: payload.length, actualChecksum, chunkCount });
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
wait_for_lifecycle_install_success() {
|
||||
local server_id="$1"
|
||||
local output_file="$2"
|
||||
local state
|
||||
rm -f "$output_file"
|
||||
for _ in $(seq 1 30); do
|
||||
json_get "$API_URL/jobs?serverInstanceId=$server_id" "$output_file" "${AUTH_HEADER[@]}" || true
|
||||
if [[ ! -s "$output_file" ]]; then
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
state="$(node - "$output_file" <<'NODE'
|
||||
const fs = require("fs");
|
||||
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const job = (response.items || []).find((candidate) => candidate.capability === "process.install");
|
||||
if (!job) {
|
||||
process.stdout.write("missing");
|
||||
} else {
|
||||
process.stdout.write(job.state || "unknown");
|
||||
}
|
||||
NODE
|
||||
)"
|
||||
case "$state" in
|
||||
succeeded)
|
||||
return 0
|
||||
;;
|
||||
failed | cancelled)
|
||||
printf 'lifecycle install job for %s reached terminal failure\n' "$server_id" >&2
|
||||
sed -n '1,160p' "$output_file" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
sleep 1
|
||||
done
|
||||
|
||||
printf 'lifecycle install job for %s did not succeed before timeout\n' "$server_id" >&2
|
||||
sed -n '1,160p' "$output_file" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'checking platform health at %s\n' "$PLATFORM_URL"
|
||||
json_get "$PLATFORM_URL/healthz" "$WORK_DIR/health.json"
|
||||
require_file_contains "$WORK_DIR/health.json" '"status"[[:space:]]*:[[:space:]]*"ok"'
|
||||
@@ -389,7 +532,6 @@ const localRunCapabilities = [
|
||||
"dependencies.check",
|
||||
"dependencies.install",
|
||||
"logs.backfill",
|
||||
...source.runtimeProfiles.clientManagers.flatMap((manager) => manager.deployment?.requiredRunCapabilities ?? []),
|
||||
...source.capabilities.filter((capability) => capability.startsWith("remote."))
|
||||
];
|
||||
const localRuntimeProfiles = {
|
||||
@@ -404,8 +546,10 @@ const localRuntimeProfiles = {
|
||||
}
|
||||
}],
|
||||
transportProfiles: source.runtimeProfiles?.transportProfiles ?? [],
|
||||
clientManagers: source.runtimeProfiles?.clientManagers ?? []
|
||||
clientManagers: []
|
||||
};
|
||||
const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {}));
|
||||
delete localGameClientBridge.companion;
|
||||
const manifest = {
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
@@ -428,7 +572,7 @@ const manifest = {
|
||||
productionLifecycle: source.productionLifecycle,
|
||||
remoteAccess: source.remoteAccess,
|
||||
runtimeProfiles: localRuntimeProfiles,
|
||||
gameClientBridge: source.gameClientBridge
|
||||
gameClientBridge: localGameClientBridge
|
||||
};
|
||||
fs.writeFileSync(outputPath, JSON.stringify({
|
||||
manifestRef: "artifact://manifests/game.scum/0.1.0",
|
||||
@@ -487,6 +631,18 @@ cat >"$WORK_DIR/create-scum-beta.request.json" <<JSON
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$WORK_DIR/create-scum-dynamic.request.json" <<JSON
|
||||
{
|
||||
"id": "scum-dynamic",
|
||||
"pluginId": "game.scum",
|
||||
"runEndpointId": "$RUN_ENDPOINT_ID",
|
||||
"name": "SCUM Dynamic",
|
||||
"idempotencyKey": "local-debug-scum-dynamic-create",
|
||||
"profileKey": "run-local",
|
||||
"bindings": {}
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$WORK_DIR/scum-alpha-run-generate.request.json" <<JSON
|
||||
{
|
||||
"targetOs": "windows",
|
||||
@@ -496,26 +652,26 @@ cat >"$WORK_DIR/scum-alpha-run-generate.request.json" <<JSON
|
||||
JSON
|
||||
|
||||
printf 'creating server lifecycle workflow through platform API\n'
|
||||
if ! curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/create-server.request.json" "$API_URL/server-instances/workflows/create" >"$WORK_DIR/create-server.response.json"; then
|
||||
json_get "$API_URL/server-instances/server-local-debug" "$WORK_DIR/create-server.response.json" "${AUTH_HEADER[@]}"
|
||||
fi
|
||||
create_server_workflow "dev" "server-local-debug" "$WORK_DIR/create-server.request.json" "$WORK_DIR/create-server.response.json"
|
||||
reject_forbidden_fragments "$WORK_DIR/create-server.response.json"
|
||||
|
||||
printf 'creating SCUM server lifecycle workflows through platform API\n'
|
||||
if ! curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/create-scum-alpha.request.json" "$API_URL/server-instances/workflows/create" >"$WORK_DIR/create-scum-alpha.response.json"; then
|
||||
json_get "$API_URL/server-instances/scum-alpha" "$WORK_DIR/create-scum-alpha.response.json" "${AUTH_HEADER[@]}"
|
||||
fi
|
||||
if ! curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/create-scum-beta.request.json" "$API_URL/server-instances/workflows/create" >"$WORK_DIR/create-scum-beta.response.json"; then
|
||||
json_get "$API_URL/server-instances/scum-beta" "$WORK_DIR/create-scum-beta.response.json" "${AUTH_HEADER[@]}"
|
||||
fi
|
||||
create_server_workflow "SCUM alpha" "scum-alpha" "$WORK_DIR/create-scum-alpha.request.json" "$WORK_DIR/create-scum-alpha.response.json"
|
||||
create_server_workflow "SCUM beta" "scum-beta" "$WORK_DIR/create-scum-beta.request.json" "$WORK_DIR/create-scum-beta.response.json"
|
||||
create_server_workflow "SCUM dynamic" "scum-dynamic" "$WORK_DIR/create-scum-dynamic.request.json" "$WORK_DIR/create-scum-dynamic.response.json"
|
||||
reject_forbidden_fragments "$WORK_DIR/create-scum-alpha.response.json"
|
||||
reject_forbidden_fragments "$WORK_DIR/create-scum-beta.response.json"
|
||||
reject_forbidden_fragments "$WORK_DIR/create-scum-dynamic.response.json"
|
||||
require_file_contains "$WORK_DIR/create-scum-alpha.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
|
||||
require_file_contains "$WORK_DIR/create-scum-beta.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
|
||||
require_file_contains "$WORK_DIR/create-scum-dynamic.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
|
||||
|
||||
SERVER_ID="$(json_id "$WORK_DIR/create-server.response.json")"
|
||||
SCUM_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.response.json")"
|
||||
SCUM_BETA_ID="$(json_id "$WORK_DIR/create-scum-beta.response.json")"
|
||||
SCUM_DYNAMIC_ID="$(json_id "$WORK_DIR/create-scum-dynamic.response.json")"
|
||||
|
||||
wait_for_lifecycle_install_success "$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json"
|
||||
|
||||
printf 'checking SCUM runtime distribution action\n'
|
||||
json_get "$API_URL/server-instances/$SCUM_ALPHA_ID/runtime/actions" "$WORK_DIR/scum-alpha-runtime-actions.response.json" "${AUTH_HEADER[@]}"
|
||||
@@ -553,6 +709,8 @@ NODE
|
||||
wait_for_distribution_build "$WORK_DIR/scum-alpha-run-generate.response.json" "$WORK_DIR/scum-alpha-run-build-job.response.json" "$WORK_DIR/scum-alpha-run-build-artifact.response.json"
|
||||
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-job.response.json"
|
||||
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-build-artifact.response.json"
|
||||
read_latest_run_download_content "$SCUM_ALPHA_ID" "$WORK_DIR/scum-alpha-run-download.response.json" "$WORK_DIR/scum-alpha-run-download-content.bin" "$WORK_DIR/scum-alpha-run-download-chunks"
|
||||
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-download.response.json"
|
||||
else
|
||||
printf 'SCUM run package build is unavailable because Run did not advertise distribution.build; verified without fake success\n'
|
||||
fi
|
||||
@@ -562,6 +720,7 @@ json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json"
|
||||
json_get "$API_URL/jobs?serverInstanceId=$SERVER_ID" "$WORK_DIR/jobs.response.json" "${AUTH_HEADER[@]}"
|
||||
json_get "$API_URL/jobs?serverInstanceId=$SCUM_ALPHA_ID" "$WORK_DIR/scum-alpha-jobs.response.json" "${AUTH_HEADER[@]}"
|
||||
json_get "$API_URL/jobs?serverInstanceId=$SCUM_BETA_ID" "$WORK_DIR/scum-beta-jobs.response.json" "${AUTH_HEADER[@]}"
|
||||
json_get "$API_URL/jobs?serverInstanceId=$SCUM_DYNAMIC_ID" "$WORK_DIR/scum-dynamic-jobs.response.json" "${AUTH_HEADER[@]}"
|
||||
json_get "$API_URL/log-streams" "$WORK_DIR/log-streams.response.json" "${AUTH_HEADER[@]}"
|
||||
json_get "$API_URL/artifacts" "$WORK_DIR/artifacts.response.json" "${AUTH_HEADER[@]}"
|
||||
json_get "$API_URL/plugin-marketplace/plugins" "$WORK_DIR/marketplace.response.json" "${AUTH_HEADER[@]}"
|
||||
@@ -572,8 +731,9 @@ if [[ "$SCUM_BUILD_AVAILABLE" == "true" ]]; then
|
||||
fi
|
||||
require_file_contains "$WORK_DIR/scum-alpha-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"'
|
||||
require_file_contains "$WORK_DIR/scum-beta-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-beta"'
|
||||
require_file_contains "$WORK_DIR/scum-dynamic-jobs.response.json" '"state"[[:space:]]*:[[:space:]]*"succeeded"'
|
||||
|
||||
for file in "$WORK_DIR"/server-instances.response.json "$WORK_DIR"/jobs.response.json "$WORK_DIR"/scum-alpha-jobs.response.json "$WORK_DIR"/scum-beta-jobs.response.json "$WORK_DIR"/log-streams.response.json "$WORK_DIR"/artifacts.response.json "$WORK_DIR"/marketplace.response.json "$WORK_DIR"/scum-marketplace.response.json "$WORK_DIR"/scum-alpha-runtime-actions.response.json "$WORK_DIR"/scum-alpha-run-generate.response.json; do
|
||||
for file in "$WORK_DIR"/server-instances.response.json "$WORK_DIR"/jobs.response.json "$WORK_DIR"/scum-alpha-jobs.response.json "$WORK_DIR"/scum-beta-jobs.response.json "$WORK_DIR"/scum-dynamic-jobs.response.json "$WORK_DIR"/log-streams.response.json "$WORK_DIR"/artifacts.response.json "$WORK_DIR"/marketplace.response.json "$WORK_DIR"/scum-marketplace.response.json "$WORK_DIR"/scum-alpha-runtime-actions.response.json "$WORK_DIR"/scum-alpha-run-generate.response.json "$WORK_DIR"/scum-alpha-run-download.response.json; do
|
||||
if [[ -f "$file" ]]; then
|
||||
reject_forbidden_fragments "$file"
|
||||
fi
|
||||
@@ -600,8 +760,8 @@ cat >"$WORK_DIR/local-ui-checklist.md" <<EOF
|
||||
- Confirm the login path is API-backed and no local fallback banner or fallback workspace appears.
|
||||
- Visit 首页, 服务器管理, 插件市场, 用户管理, AI 提供商管理.
|
||||
- Open server-local-debug detail and inspect lifecycle history, plugin controls, logs, and artifact references.
|
||||
- Confirm 插件市场 can find SCUM Server / game.scum, then open scum-alpha and scum-beta from 服务器管理.
|
||||
- Confirm both SCUM servers are backed by game.scum and have separate lifecycle install jobs and operation history.
|
||||
- Confirm 插件市场 can find SCUM Server / game.scum, then open scum-alpha, scum-beta, and scum-dynamic from 服务器管理.
|
||||
- Confirm all SCUM servers are backed by game.scum and have separate lifecycle install jobs and operation history.
|
||||
- Search visible text for forbidden fragments: /Users/, /private/, unix://, tcp://, Bearer , sk-, password=, apiKeyRef, rawApiKey, run session tokens, direct run URLs, plugin-owned transport details.
|
||||
- Acceptance requires platform routes, logical IDs, job refs, log refs, artifact refs, and safe metadata only.
|
||||
EOF
|
||||
|
||||
@@ -5,8 +5,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=scripts/local-debug-env.sh
|
||||
source "$ROOT_DIR/scripts/local-debug-env.sh"
|
||||
|
||||
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT"
|
||||
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT"
|
||||
mkdir -p "$GOCACHE"
|
||||
local_debug_prepare_run_lifecycle_templates
|
||||
|
||||
managed_pid_running() {
|
||||
local pid_file="$1"
|
||||
@@ -133,7 +134,10 @@ wait_for_run_registration() {
|
||||
printf 'local debug root: %s\n' "$LOCAL_DEBUG_ROOT"
|
||||
printf 'platform: %s\n' "$(local_debug_platform_url)"
|
||||
printf 'platform_web: %s\n' "$(local_debug_web_url)"
|
||||
printf 'run repo: %s\n' "$RUN_REPO_DIR"
|
||||
printf 'run source: %s\n' "$RUN_SOURCE_DIR"
|
||||
printf 'run build bucket: %s\n' "$RUN_BUILD_BUCKET_ROOT"
|
||||
printf 'run build source snapshot: %s\n' "$RUN_BUILD_SOURCE_ROOT"
|
||||
printf 'run bootstrap binary: %s\n' "$RUN_BOOTSTRAP_BIN"
|
||||
printf 'platform log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform.log"
|
||||
printf 'run log: %s\n' "$LOCAL_DEBUG_LOG_DIR/run.log"
|
||||
printf 'platform_web log: %s\n' "$LOCAL_DEBUG_LOG_DIR/platform_web.log"
|
||||
@@ -156,8 +160,8 @@ start_service platform "$ROOT_DIR/platform" env \
|
||||
|
||||
wait_for_url platform "$(local_debug_platform_url)/healthz"
|
||||
|
||||
ensure_local_run_repo
|
||||
start_service run "$RUN_REPO_DIR" env \
|
||||
local_debug_build_bootstrap_run
|
||||
start_service run "$(dirname "$RUN_BOOTSTRAP_BIN")" env \
|
||||
GOCACHE="$GOCACHE" \
|
||||
RUN_MODE="$RUN_MODE" \
|
||||
RUN_PLATFORM_URL="$RUN_PLATFORM_URL" \
|
||||
@@ -166,12 +170,13 @@ start_service run "$RUN_REPO_DIR" env \
|
||||
RUN_VERSION="$RUN_VERSION" \
|
||||
RUN_REGISTRATION_TOKEN="$RUN_REGISTRATION_TOKEN" \
|
||||
RUN_WORKSPACE_ROOT="$RUN_WORKSPACE_ROOT" \
|
||||
RUN_BUILD_SOURCE_ROOT="$RUN_BUILD_SOURCE_ROOT" \
|
||||
RUN_SPOOL_ROOT="$RUN_SPOOL_ROOT" \
|
||||
RUN_MAX_JOBS="$RUN_MAX_JOBS" \
|
||||
RUN_HEARTBEAT_INTERVAL_MS="$RUN_HEARTBEAT_INTERVAL_MS" \
|
||||
RUN_POLL_INTERVAL_MS="$RUN_POLL_INTERVAL_MS" \
|
||||
RUN_RETRY_BACKOFF_MS="$RUN_RETRY_BACKOFF_MS" \
|
||||
go run ./cmd/run
|
||||
"$RUN_BOOTSTRAP_BIN"
|
||||
|
||||
wait_for_run_registration
|
||||
|
||||
|
||||
Reference in New Issue
Block a user