feat: 自动更新

This commit is contained in:
npc0-hue
2026-07-15 19:43:06 +08:00
parent f64eb0831f
commit f3b14b7945
54 changed files with 3207 additions and 589 deletions
+4 -12
View File
@@ -10,12 +10,6 @@ The platform's required first-party areas are 首页、服务器管理、插件
The platform_web visual direction is a unified magical-girl crystal-moonlight game operations console. Preserve the style rules in `platform_web/AGENTS.md` and `platform_web/theme/README.md`; do not replace it with generic opaque SaaS cards or unrelated visual systems without a future OpenSpec change. Global magical ultimate effects belong in `platform_web/components/MagicalParticleLayer.tsx`, not in page-local fixed decoration spans or one-off backdrop CSS. The platform_web visual direction is a unified magical-girl crystal-moonlight game operations console. Preserve the style rules in `platform_web/AGENTS.md` and `platform_web/theme/README.md`; do not replace it with generic opaque SaaS cards or unrelated visual systems without a future OpenSpec change. Global magical ultimate effects belong in `platform_web/components/MagicalParticleLayer.tsx`, not in page-local fixed decoration spans or one-off backdrop CSS.
## Frontend Design and Browser Tools
For frontend work, agents are allowed and encouraged to use [@浏览器](plugin://browser@openai-bundled) to automatically open local frontend pages, inspect UI behavior, debug visual issues, and perform browser walkthrough verification.
For frontend design work, agents are allowed and encouraged to use [$design-taste-frontend](/Users/tasia/.agents/skills/design-taste-frontend/SKILL.md) as a design review and implementation aid. Apply it in support of this repository's existing platform_web visual direction and local theme rules; it must not override the magical-girl crystal-moonlight game operations console style without a future OpenSpec change.
## Project Roots ## Project Roots
- `platform/` contains backend platform code. - `platform/` contains backend platform code.
@@ -28,10 +22,10 @@ Do not place implementation code outside the matching root. Shared contracts mus
## OpenSpec Rules ## OpenSpec Rules
- Every non-trivial change must start with an OpenSpec change under `openspec/changes/`. - Use judgment before creating an OpenSpec change. Create one when the work changes behavior, architecture, public/API contracts, validation rules, persistence, security boundaries, cross-root workflows, or the required product/visual direction.
- Update proposal/design/specs/tasks before implementation when behavior, architecture, or validation rules change. - OpenSpec is not required for small scoped bug fixes, copy/documentation updates, tests, local refactors without behavior change, or styling/label fixes that preserve the existing visual system. For those, reason through the steps, make the edit directly, and report the verification performed.
- Do not mark tasks complete until their verification evidence exists. - If an OpenSpec change is created, update proposal/design/specs/tasks before implementation when behavior, architecture, or validation rules change.
- Run `openspec validate <change> --strict` before completion. - If an OpenSpec change is created, do not mark its tasks complete until verification evidence exists, and run `openspec validate <change> --strict` before completion.
## Structure Rules ## Structure Rules
@@ -88,5 +82,3 @@ scripts/check-structure.sh
``` ```
If you add or change a structural rule, update `scripts/check-structure.sh` in the same change. If you add or change a structural rule, update `scripts/check-structure.sh` in the same change.
If work touches frontend pages, use a browser walkthrough before claiming the UI is accepted.
+11
View File
@@ -15,6 +15,17 @@ The local debug workspace runs the real platform API, run worker, platform_web c
The workflow does not require Docker-only infrastructure, external cloud services, real game binaries, raw credentials, raw AI keys, direct run sockets, or browser/plugin direct access to run. The workflow does not require Docker-only infrastructure, external cloud services, real game binaries, raw credentials, raw AI keys, direct run sockets, or browser/plugin direct access to run.
## Port Discipline
Use the managed local debug scripts for browser-facing verification. Do not start extra ad hoc platform or platform_web processes on random ports when the standard stack is already available.
- Default platform API: `http://127.0.0.1:18080`.
- Default platform_web: `http://127.0.0.1:5173`.
- Default local debug root: `.local-debug/`.
- Restart with `scripts/local-debug-stop.sh` followed by `scripts/local-debug-start.sh`.
- Use `LOCAL_DEBUG_PLATFORM_PORT`, `LOCAL_DEBUG_WEB_PORT`, and `LOCAL_DEBUG_ROOT` only when a task explicitly needs an isolated stack.
- When debugging an already-running local page, prefer the current `5173`/`18080` stack and inspect `.local-debug/logs/` before starting anything else.
## Start ## Start
```bash ```bash
@@ -31,6 +31,10 @@ Platform stores generated run and client-manager packages as artifacts with a di
Alternative considered: ship one global run binary and ask users to hand-edit config files. Rejected because it causes copy/paste key exposure, weak auditability, and poor operator experience. Alternative considered: ship one global run binary and ask users to hand-edit config files. Rejected because it causes copy/paste key exposure, weak auditability, and poor operator experience.
Distribution generation is asynchronous. Platform creates a bounded `distribution.build` job on the assigned run endpoint and returns a `building` distribution with the real job ID. The run worker obtains the secret-bearing build input only through its authenticated leased-job channel, builds in an isolated workspace, and uploads the resulting archive through the artifact channel. Platform marks a distribution `available` only after the job succeeds and the referenced artifact is present and available. A JSON build plan, generated config, or synthetic build log is never a downloadable distribution artifact.
Run packages are built from the trusted run worker checkout. Plugin-declared client managers are checked out from the approved HTTPS Git repository and revision carried by the build job. Build execution uses a fixed build-system adapter and target tuple; repository content cannot supply arbitrary platform-side commands.
### Decision 2: Run and client-manager credentials are separate singleton keys ### Decision 2: Run and client-manager credentials are separate singleton keys
Each server/component has exactly one active run key and, when needed, exactly one active client-manager key. Run and client-manager keys remain different secrets, but platform does not keep multiple simultaneously valid keys for the same component. Resetting a key replaces the encrypted database value, increments the key generation, invalidates every older run or client package for that component, and requires regenerating and redeploying the affected package. Each server/component has exactly one active run key and, when needed, exactly one active client-manager key. Run and client-manager keys remain different secrets, but platform does not keep multiple simultaneously valid keys for the same component. Resetting a key replaces the encrypted database value, increments the key generation, invalidates every older run or client package for that component, and requires regenerating and redeploying the affected package.
@@ -5,7 +5,11 @@ The platform SHALL let an authorized operator generate a run package for a selec
#### Scenario: Operator generates run from server actions #### Scenario: Operator generates run from server actions
- **WHEN** an authorized operator selects generate executor for a server instance and chooses a supported OS/architecture - **WHEN** an authorized operator selects generate executor for a server instance and chooses a supported OS/architecture
- **THEN** platform MUST create or reuse the server's current encrypted run key, write that key into the generated package config, create a distribution record with key generation, build/download artifact, checksum metadata, and audit event, and MUST NOT return the raw key in the API response - **THEN** platform MUST create or reuse the server's current encrypted run key, queue a real run-worker build job, return a building distribution with its job ID, compile the target executable, package it with the generated config, publish the completed binary archive and checksum through the artifact channel, record an audit event, and MUST NOT return the raw key in the API response
#### Scenario: Run generation has not completed
- **WHEN** the run-worker build job is queued, running, failed, or cancelled
- **THEN** platform MUST keep the distribution unavailable for download, expose the real job state and progress, and MUST NOT substitute generated configuration JSON or a synthetic build log as the downloadable run package
#### Scenario: Operator downloads generated run package #### Scenario: Operator downloads generated run package
- **WHEN** an authorized operator downloads a generated run artifact - **WHEN** an authorized operator downloads a generated run artifact
@@ -75,7 +79,11 @@ The platform SHALL support plugin-declared client-manager build profiles for com
#### Scenario: SCUM-style client manager is generated #### Scenario: SCUM-style client manager is generated
- **WHEN** a plugin declares a client-manager build profile with repository, revision policy, supported target platform, build system, config template, and output artifact paths - **WHEN** a plugin declares a client-manager build profile with repository, revision policy, supported target platform, build system, config template, and output artifact paths
- **THEN** platform MUST create a build job that checks out the approved source, injects redacted configuration from secret refs, produces a downloadable artifact, and redacts secrets from build logs - **THEN** platform MUST create a run-worker build job that checks out the approved source and revision, injects configuration obtained through the authenticated job-input channel, compiles the target executable, uploads the downloadable artifact through the artifact channel, and redacts secrets and workspace paths from progress and build results
#### Scenario: Client-manager build is still running
- **WHEN** the source checkout, environment check, dependency download, compile, or artifact upload stage is incomplete
- **THEN** platform_web MUST display the corresponding real job progress and MUST NOT mark later stages complete on a local timer
#### Scenario: Unsupported client-manager target is requested #### Scenario: Unsupported client-manager target is requested
- **WHEN** an operator requests a client-manager build for an OS/architecture not declared by the plugin profile - **WHEN** an operator requests a client-manager build for an OS/architecture not declared by the plugin profile
@@ -53,6 +53,14 @@
- [x] 7.4 Complete a browser walkthrough for touched server-list and server-detail workflows before marking UI acceptance complete. - [x] 7.4 Complete a browser walkthrough for touched server-list and server-detail workflows before marking UI acceptance complete.
- [x] 7.5 Record verification evidence in this task file before completion. - [x] 7.5 Record verification evidence in this task file before completion.
## 8. Real Distribution Build Repair
- [ ] 8.1 Replace synchronous synthetic run/client artifacts with queued `distribution.build` jobs, building distribution records, authenticated build-input retrieval, and terminal job projection.
- [ ] 8.2 Implement the independent run worker build adapter for trusted run source and approved HTTPS client-manager repositories, including fixed Go builds, isolated workspaces, config packaging, checksums, and chunked artifact upload.
- [ ] 8.3 Drive the platform_web generation dialog from real job progress and terminal state instead of timer-completed stages.
- [ ] 8.4 Add regression coverage proving generation queues a backend job, does not publish JSON plans as artifacts, publishes only uploaded build output, and reports actual progress/failure.
- [ ] 8.5 Run focused platform, run, frontend, OpenSpec, and structure verification and record the evidence below.
## Verification Evidence ## Verification Evidence
- `cd plugins && npm run validate:manifest`: passed. First sandbox attempt failed with `listen EPERM` on the local `tsx` IPC pipe, then the same command passed with approved escalation. - `cd plugins && npm run validate:manifest`: passed. First sandbox attempt failed with `listen EPERM` on the local `tsx` IPC pipe, then the same command passed with approved escalation.
@@ -0,0 +1,17 @@
## Why
Profile settings correctly render an uploaded custom background, but the page header metric and background preset controls still label the built-in preset as the current background. This makes users think the uploaded background was ignored when the built-in preset is actually only the fallback after upload removal.
## What Changes
- Show an uploaded custom background as the active background in profile settings summary metrics.
- Treat the selected built-in background preset as a fallback while a custom background is active.
- Label the fallback preset explicitly and avoid marking it as the pressed/active visible background.
- Keep uploaded background precedence and existing theme visual direction unchanged.
## Impact
- Affected root: `platform_web/`.
- Expected files: `platform_web/pages/ProfileSettingsPage.tsx`, `platform_web/theme/base.css`, and focused page rendering tests.
- No platform API, persistence schema, authentication, plugin, run, or server management behavior changes.
- Verification: focused frontend tests, `scripts/check-structure.sh`, and `openspec validate fix-custom-background-status --strict`.
@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: Profile settings distinguish active custom backgrounds from fallback presets
The platform_web profile settings page SHALL report an uploaded custom background as the active workspace background while preserving the selected built-in preset only as the fallback after upload removal.
#### Scenario: Uploaded background is active
- **WHEN** a user has configured an uploaded custom background
- **AND** a built-in background preset is also selected
- **THEN** the profile settings summary MUST label the active background as custom rather than naming the built-in preset as current
- **AND** the selected built-in preset MUST be visibly identified as a fallback that appears after the uploaded background is removed
- **AND** the uploaded background MUST remain the workspace desktop
#### Scenario: Uploaded background is removed
- **WHEN** a user removes the uploaded custom background
- **THEN** the previously selected built-in preset becomes the visible active background again
- **AND** the profile settings summary MAY name that built-in preset as current
@@ -0,0 +1,22 @@
## 1. Custom Background Status
- [x] 1.1 Show uploaded custom backgrounds as the active background in the profile settings header metric.
- [x] 1.2 Render the selected built-in preset as a clearly labeled fallback while an uploaded background is active.
- [x] 1.3 Preserve fallback preset selection so removing the uploaded background restores the selected built-in desktop.
## 2. Verification
- [x] 2.1 Add focused rendering coverage for uploaded-background labeling.
- [x] 2.2 Run focused frontend tests.
- [x] 2.3 Run `scripts/check-structure.sh`.
- [x] 2.4 Run `openspec validate fix-custom-background-status --strict`.
## Evidence
- `npm test -- pages/ConsolePages.test.tsx` passed 10 tests.
- `npm test` passed 13 files / 68 tests.
- `npm run typecheck` passed.
- `npm run build` passed.
- `scripts/check-structure.sh` passed.
- `openspec validate fix-custom-background-status --strict` passed; the command printed a PostHog network flush warning after validation because the sandbox cannot resolve `edge.openspec.dev`.
- Runtime UI check on `http://127.0.0.1:5173/#/profile` confirmed `data-custom-background="true"`, summary `背景 自定义背景`, fallback chip `机甲格纳库 备用`, and theme switch black mecha -> magical-girl preserved the uploaded background.
@@ -0,0 +1,16 @@
## Why
The local debug SCUM fixture can be registered with a reduced manifest that omits `server.run.distribution`. That makes `scum-alpha` visible and manageable, but `POST /api/v1/server-instances/scum-alpha/run/generate` is denied even though the first-party SCUM manifest declares run distribution support.
## What Changes
- Register the local debug SCUM plugin with the run distribution, dependency, client-manager, bridge, and remote-access declarations from the first-party manifest.
- Extend local debug smoke proof to verify `generate-run` is available for `scum-alpha`.
- Extend local debug smoke proof to generate a Windows AMD64 run distribution for `scum-alpha`.
- Make deterministic run generation recover from an already-created package artifact instead of surfacing `duplicate_resource` to the UI.
- Make repeated run self-update dispatches return the existing update job when the artifact/checksum/idempotency key match.
## Impact
- Affected specs: `scum-run-distribution-smoke`
- Affected code: `scripts/local-debug-smoke.sh`, `platform/service/distributions.go`
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: SCUM local debug run distribution proof
The local debug smoke fixture SHALL register the first-party SCUM plugin with enough safe platform metadata for SCUM run distribution APIs to be exercised.
#### Scenario: SCUM run generation is available
- **WHEN** local debug smoke registers `game.scum` and creates `scum-alpha`
- **THEN** `GET /api/v1/server-instances/scum-alpha/runtime/actions` MUST report `generate-run` as available
#### Scenario: SCUM run package is generated
- **WHEN** local debug smoke calls `POST /api/v1/server-instances/scum-alpha/run/generate` for Windows AMD64
- **THEN** the response MUST include an artifact ID and checksum without exposing raw keys, host paths, direct sockets, bearer credentials, or plugin-owned transport details
#### Scenario: SCUM run package generation is idempotent after a partial artifact write
- **WHEN** `POST /api/v1/server-instances/scum-alpha/run/generate` is retried with the same idempotency key after the deterministic artifact already exists but the run distribution row is missing
- **THEN** the platform MUST reuse the matching artifact, create or return the run distribution, and MUST NOT return `duplicate_resource`
#### Scenario: SCUM run self-update dispatch is idempotent
- **WHEN** `POST /api/v1/server-instances/scum-alpha/run/update` is retried with the same artifact, checksum, and idempotency key
- **THEN** the platform MUST return the existing update job and MUST NOT return `duplicate_resource`
@@ -0,0 +1,30 @@
## 1. Local Debug SCUM Runtime Proof
- [x] 1.1 Preserve SCUM manifest run distribution declarations in local debug registration.
- [x] 1.2 Add smoke assertions that `scum-alpha` exposes `generate-run` as available.
- [x] 1.3 Add smoke proof that `scum-alpha` can generate a Windows AMD64 run distribution.
- [x] 1.4 Recover run generation retries when the deterministic artifact exists before the distribution row.
- [x] 1.5 Recover repeated run self-update dispatches for the same artifact/checksum/idempotency key.
## 2. Verification
- [x] 2.1 Run `bash -n scripts/local-debug-smoke.sh`.
- [x] 2.2 Run `scripts/check-structure.sh`.
- [x] 2.3 Run `openspec validate fix-scum-run-distribution-smoke --strict`.
- [x] 2.4 Run focused local debug smoke proof for the fixed SCUM run distribution path.
- [x] 2.5 Run focused backend distribution retry tests.
- [x] 2.6 Run focused run update idempotency regression tests.
## Evidence
- `bash -n scripts/local-debug-smoke.sh` passed.
- `scripts/check-structure.sh` passed with `structure check passed`.
- `openspec validate fix-scum-run-distribution-smoke --strict` passed; OpenSpec telemetry flush reported `ENOTFOUND edge.openspec.dev`, which did not affect validation.
- Isolated local debug smoke passed with `LOCAL_DEBUG_SELF_START=true LOCAL_DEBUG_PLATFORM_PORT=18283 LOCAL_DEBUG_WEB_PORT=5196 LOCAL_DEBUG_ROOT=/private/tmp/browser-scum-run-distribution-smoke-4 scripts/local-debug-smoke.sh`.
- Smoke evidence directory: `/private/tmp/browser-scum-run-distribution-smoke-4/smoke`.
- The passing smoke verified `scum-alpha` exposes `generate-run` as available and generated a Windows AMD64 run package without forbidden local-debug fragments.
- `go test ./service -run 'TestCoreService(GeneratesRunDistributionWithEncryptedSingletonKey|RunDistributionRetryReusesPartialArtifact|BuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperations)' -count=1` passed from `platform/`.
- `go test ./api ./service -run 'Test.*(Run|Distribution|Artifact|ClientManager|Runtime)' -count=1` passed from `platform/`.
- `go test ./service -run 'TestCoreService(RunDistributionRetryReusesPartialArtifact|PushRunUpdateReusesExistingUpdateJob|BuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperations)' -count=1` passed from `platform/`.
- Rerun `scripts/check-structure.sh` passed with `structure check passed`.
- Rerun `openspec validate fix-scum-run-distribution-smoke --strict` passed; OpenSpec telemetry flush reported `ENOTFOUND edge.openspec.dev`, which did not affect validation.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,18 @@
## Why
The server management card action menu currently opens inside the card as a large vertical command stack, which obscures operational data and makes the card feel broken. The same card stat tiles render label/value text over busy translucent materials, so placeholder values such as `--` and labels like 玩家, TPS, 延迟, and 任务 lose contrast against uploaded or magical-girl backgrounds.
## What Changes
- Replace the in-card runtime action stack with a compact anchored overlay that does not resize or reflow server cards.
- Keep runtime actions platform-mediated and unchanged while closing the overlay on outside click, Escape, and action selection.
- After an operator selects a runtime action, show a modal task flow with stage progress for code pull, environment install/check, dependency download, compile/build, packaging, queued jobs, and final success/failure.
- Increase server card stat readability with stronger token-driven material, label contrast, value contrast, and text shadow that works across black mecha, magical-girl, and uploaded backgrounds.
- Remove misleading whole-card pointer affordance so only actual controls look clickable.
## Impact
- Affected root: `platform_web/`.
- Expected files: `platform_web/pages/ServersPage.tsx`, `platform_web/pages/ServerDetailPage.tsx`, `platform_web/components/RuntimeTaskProgress.tsx`, `platform_web/theme/base.css`, and focused rendering/style contract tests if useful.
- No platform, run, plugin, lifecycle, authorization, credential, or API behavior changes.
- Verification: focused frontend tests/typecheck, `scripts/check-structure.sh`, `openspec validate fix-server-card-action-menu --strict`, and browser walkthrough of 服务器管理.
@@ -0,0 +1,47 @@
## ADDED Requirements
### Requirement: Server cards use compact runtime action overlays
The platform_web server management page SHALL expose server-card runtime actions through a compact anchored overlay that does not resize, stretch, or reflow the server card.
#### Scenario: Operator opens runtime actions from a server card
- **WHEN** an operator selects the 运行操作 trigger on a server card
- **THEN** runtime actions MUST appear in a compact overlay anchored near the trigger instead of as a tall in-card vertical command stack
- **AND** the server card layout, neighboring cards, metrics, progress bars, title, and status badge MUST remain structurally stable
#### Scenario: Operator dismisses runtime actions
- **WHEN** the runtime action overlay is open
- **THEN** outside click, Escape, and selecting an action MUST dismiss the overlay
- **AND** the action dispatch MUST continue to use the existing platform-mediated runtime APIs
### Requirement: Runtime actions show task progress dialogs
The platform_web server management and server detail runtime action surfaces SHALL show a modal task flow after an operator selects a runtime action so the operator can see meaningful progress instead of a silent click-and-finish interaction.
#### Scenario: Operator generates a run package
- **WHEN** an operator selects 生成 run for a server
- **THEN** platform_web MUST open a modal task dialog that shows stage progress for code pull, environment install/check, dependency download, compile/build, package finalization, and terminal success or failure
- **AND** the dialog MUST include a readable progress meter, current stage, recent task log lines, and final artifact/job summary when the platform API returns
#### Scenario: Operator starts a queued runtime maintenance action
- **WHEN** an operator selects dependency check, dependency install, push update, live logs, or historical logs from the runtime action surfaces
- **THEN** platform_web MUST show a modal task dialog with action-specific staged progress and final queued job or navigation status
- **AND** the dialog MUST keep sensitive runtime credentials, raw host paths, direct sockets, and secret material hidden
### Requirement: Server card metrics remain readable over themed backgrounds
Server card metric tiles SHALL keep labels and values readable across black mecha, magical-girl, and uploaded-background states.
#### Scenario: Metrics are unavailable or pending
- **WHEN** 玩家, TPS, 延迟, or 任务 values render as pending or unavailable placeholders such as `…` or `--`
- **THEN** both the label and value text MUST remain legible over the card background and selected workspace background
#### Scenario: Custom background is active
- **WHEN** an uploaded background is active behind server cards
- **THEN** metric tiles MUST use stronger surface material, borders, and text contrast so operational data is not visually swallowed by the background image
@@ -0,0 +1,31 @@
## 1. Server Card Interaction Fix
- [x] 1.1 Replace the `<details>` runtime action stack with a compact overlay that is anchored to the trigger and does not reflow the card.
- [x] 1.2 Close the overlay on outside click, Escape, and action selection.
- [x] 1.3 Keep runtime action dispatch semantics unchanged.
- [x] 1.4 Show a runtime task progress dialog after action selection with staged progress, logs, and terminal success/failure state.
- [x] 1.5 Reuse the same progress dialog from server detail runtime distribution actions.
## 2. Server Card Readability Fix
- [x] 2.1 Strengthen `.server-card-stat` label/value contrast for 玩家, TPS, 延迟, 任务, and placeholder values.
- [x] 2.2 Preserve the shared black-mecha / magical-girl console visual system and uploaded-background readability.
- [x] 2.3 Remove misleading whole-card click affordance while keeping explicit detail/menu controls.
## 3. Verification
- [x] 3.1 Run focused frontend tests and typecheck.
- [x] 3.2 Run `scripts/check-structure.sh`.
- [x] 3.3 Run `openspec validate fix-server-card-action-menu --strict`.
- [ ] 3.4 Browser-walkthrough 服务器管理 and verify the menu is compact, card stats are readable, action selection opens the progress dialog, and no metrics/progress/status are covered.
## Evidence
- `npm run typecheck` passed.
- `npm test -- ConsolePages.test.tsx ServerDetailPage.test.tsx base-css.test.js` passed with 22 tests.
- `npm test` passed with 68 tests.
- `npm run build` passed.
- `go test ./api ./service -run 'Test.*(Run|Distribution|Artifact|ClientManager|Runtime)' -count=1` passed from `platform/`, proving backend run distribution, artifact download, update, and client-manager paths are implemented.
- `scripts/check-structure.sh` passed with `structure check passed`.
- `openspec validate fix-server-card-action-menu --strict` passed. OpenSpec telemetry flush failed with `ENOTFOUND edge.openspec.dev`, which did not affect validation.
- Browser walkthrough is still pending because the Browser plugin reported no available browser backends (`agent.browsers.list()` returned `[]`).
@@ -0,0 +1,19 @@
## Why
AI provider setup currently exposes implementation fields such as provider ID, Base URL, models, timeout, and redaction policy as the primary workflow. This is too noisy for normal provider onboarding and makes operators fill fields that the platform can derive from a provider preset. The magical-girl theme also becomes visually harsh when a user-uploaded background is active because the custom-background overrides reintroduce high-saturation pink/gold overlays on already busy imagery.
## What Changes
- Simplify AI provider onboarding so the normal path is provider kind plus platform secret reference, with provider ID generated automatically.
- Move Base URL, model list, default model, relay mode, timeout, and redaction policy into an advanced section with provider-specific defaults.
- Preserve the platform boundary: the frontend manages secret references and does not persist or display raw API keys.
- Require AI provider management API routes to use platform administrator authentication.
- Reduce magical-girl custom-background surface saturation, frame accessory opacity, and pink/gold glow while keeping the crystal-moonlight visual direction.
- Document the local debug port discipline: use `scripts/local-debug-start.sh` and the documented `LOCAL_DEBUG_*` overrides instead of starting ad hoc ports.
## Impact
- Affected roots: `platform/`, `platform_web/`, `docs/`, `openspec/`.
- Expected files: AI provider API handlers/tests, AI provider frontend contracts/schema/page/tests, shared theme CSS/tests/readme, local-debug docs.
- No billing, cloud host sales, provider marketplace, direct run socket, plugin credential, or unrelated SaaS behavior changes.
- Verification: focused backend/frontend tests, `scripts/check-structure.sh`, `openspec validate polish-ai-provider-onboarding --strict`, and browser walkthrough against the existing local debug stack.
@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: AI provider management preserves secret boundaries
AI provider management SHALL reject raw key material in request fields and SHALL never expose raw API keys in API responses or frontend-visible types.
#### Scenario: Raw key is submitted during update
- **WHEN** a create or update request includes raw key material instead of a secret reference in `apiKeyRef`
- **THEN** the platform MUST reject the request with a validation error and MUST NOT persist the provider
#### Scenario: Provider is returned to UI
- **WHEN** the backend or frontend API client returns provider data
- **THEN** the response/type MUST include `apiKeyRef` only and MUST NOT include `apiKey`, `rawApiKey`, or equivalent raw credential fields
#### Scenario: Provider management route is accessed without platform admin
- **WHEN** a client creates, lists, reads, updates, tests, changes status, or lists models for AI providers without a platform administrator bearer session
- **THEN** the platform MUST reject the request with a stable JSON authorization error
### Requirement: AI provider console page is functional
The management console SHALL provide a functional operational view for configured providers while keeping normal setup focused on provider kind and platform-owned secret references.
#### Scenario: Operator opens AI provider page
- **WHEN** the AI provider page renders
- **THEN** it MUST show provider counts, status distribution, configured model counts, and a provider table
#### Scenario: Operator creates provider from normal form
- **WHEN** an operator creates a provider through the normal form
- **THEN** the page MUST generate the provider ID and apply provider-specific defaults for Base URL, model list, relay mode, timeout, and redaction policy before submitting named API requests
#### Scenario: Operator edits advanced provider metadata
- **WHEN** an operator opens advanced settings
- **THEN** the page MAY allow editing Base URL, model list, default model, relay mode, timeout, and redaction policy without requiring the operator to manually edit the provider ID
#### Scenario: Operator uses provider actions
- **WHEN** an operator triggers enable/disable, test, or model-list actions
- **THEN** the page MUST call the matching API client methods and display the redacted result state
@@ -0,0 +1,12 @@
## MODIFIED Requirements
### Requirement: Uploaded backgrounds remain readable and restrained
The platform_web theme system SHALL preserve uploaded background visibility while keeping operational surfaces readable and avoiding harsh high-saturation overlays.
#### Scenario: Magical-girl theme uses a custom background
- **WHEN** `data-custom-background="true"` and `data-theme-palette="magical-girl"` are active
- **THEN** shared operational surfaces MUST use restrained translucent materials, muted frame accessories, and reduced glow so the background does not become visually harsh
#### Scenario: Custom background theme styling changes
- **WHEN** custom-background shared CSS is modified
- **THEN** CSS contract tests or theme documentation MUST cover the intended restraint so future changes do not reintroduce excessive pink/gold gradients
@@ -0,0 +1,39 @@
## 1. AI Provider Setup
- [x] 1.1 Hide provider ID from the primary form and generate it deterministically from provider kind/name.
- [x] 1.2 Keep the primary workflow to provider kind plus platform secret reference, using official-provider defaults for Base URL, models, relay mode, timeout, and redaction policy.
- [x] 1.3 Move lower-frequency provider metadata into an advanced section.
- [x] 1.4 Preserve frontend and backend raw-key redaction boundaries.
## 2. API Authorization
- [x] 2.1 Require platform administrator authentication for AI provider create/list/detail/update/status/test/models routes.
- [x] 2.2 Add or update backend tests for authorized management and unauthorized rejection.
## 3. Custom Background Theme Polish
- [x] 3.1 Tone down magical-girl custom-background panel gradients, frame ornaments, and glow.
- [x] 3.2 Add CSS contract coverage so custom-background magical-girl overrides stay muted.
- [x] 3.3 Update theme documentation to explain custom-background restraint.
## 4. Local Debug Documentation
- [x] 4.1 Document that agents should use `scripts/local-debug-start.sh` and existing default ports unless explicit `LOCAL_DEBUG_*` overrides are provided.
- [x] 4.2 Document restart discipline through `scripts/local-debug-stop.sh` then `scripts/local-debug-start.sh`.
## 5. Verification
- [x] 5.1 Run focused backend AI provider API tests.
- [x] 5.2 Run focused frontend AI provider/theme tests plus typecheck/build if touched code requires it.
- [x] 5.3 Run `scripts/check-structure.sh`.
- [x] 5.4 Run `openspec validate polish-ai-provider-onboarding --strict`.
- [x] 5.5 Browser-walkthrough `http://127.0.0.1:5173/#/aiProviders` using the existing local debug stack; cover magical-girl custom-background via CSS contract test.
## Evidence
- `npm test -- AiProvidersPage.test.tsx aiProviders.test.ts base-css.test.js` passed.
- `go test ./api -run 'TestAIProvider|TestAIInvocation|TestPluginBridgeExecute|TestCoreAPI(CreateListDetailWorkflows|ErrorResponses)' -count=1` passed.
- `npm run typecheck` and `npm run build` passed in `platform_web`.
- `scripts/check-structure.sh` passed.
- `openspec validate polish-ai-provider-onboarding --strict` returned valid; PostHog telemetry flush failed due DNS after validation success.
- Browser walkthrough used existing `http://127.0.0.1:5173/#/aiProviders`: logged in with local debug account, opened 新增提供商, confirmed no editable ID input, generated ID copy, and no visible raw key copy. OpenAI defaults `gpt-5.6-terra, gpt-5.6-luna` are covered by `schemas/aiProviders.test.ts`; magical-girl custom-background restraint is covered by `theme/base-css.test.js`.
+35
View File
@@ -74,6 +74,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/run/jobs/ack", h.runJobAck) mux.HandleFunc("/api/v1/run/jobs/ack", h.runJobAck)
mux.HandleFunc("/api/v1/run/jobs/progress", h.runJobProgress) mux.HandleFunc("/api/v1/run/jobs/progress", h.runJobProgress)
mux.HandleFunc("/api/v1/run/jobs/result", h.runJobResult) mux.HandleFunc("/api/v1/run/jobs/result", h.runJobResult)
mux.HandleFunc("/api/v1/run/jobs/build-input", h.runJobBuildInput)
mux.HandleFunc("/api/v1/run/jobs/cancel", h.runJobCancelPoll) mux.HandleFunc("/api/v1/run/jobs/cancel", h.runJobCancelPoll)
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.runJobReconcile) mux.HandleFunc("/api/v1/run/jobs/reconcile", h.runJobReconcile)
mux.HandleFunc("/api/v1/run/logs/batches", h.runLogBatchIngest) mux.HandleFunc("/api/v1/run/logs/batches", h.runLogBatchIngest)
@@ -462,6 +463,9 @@ func (h *coreHandlers) requirePlatformAdmin(w http.ResponseWriter, r *http.Reque
// @Router /api/v1/ai-providers [get] // @Router /api/v1/ai-providers [get]
// @Router /api/v1/ai-providers [post] // @Router /api/v1/ai-providers [post]
func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePlatformAdmin(w, r); !ok {
return
}
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
providers, err := h.core.ListAIProviders(domain.AIProviderFilter{ providers, err := h.core.ListAIProviders(domain.AIProviderFilter{
@@ -504,6 +508,9 @@ func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
// @Router /api/v1/ai-providers/{id} [get] // @Router /api/v1/ai-providers/{id} [get]
// @Router /api/v1/ai-providers/{id} [put] // @Router /api/v1/ai-providers/{id} [put]
func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePlatformAdmin(w, r); !ok {
return
}
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
provider, err := h.core.GetAIProvider(r.PathValue("id")) provider, err := h.core.GetAIProvider(r.PathValue("id"))
@@ -548,6 +555,9 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request)
// @Failure 405 {object} dto.ErrorResponse // @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/ai-providers/{id}/status [post] // @Router /api/v1/ai-providers/{id}/status [post]
func (h *coreHandlers) aiProviderStatus(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) aiProviderStatus(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePlatformAdmin(w, r); !ok {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, http.MethodPost)
return return
@@ -576,6 +586,9 @@ func (h *coreHandlers) aiProviderStatus(w http.ResponseWriter, r *http.Request)
// @Failure 405 {object} dto.ErrorResponse // @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/ai-providers/{id}/test [post] // @Router /api/v1/ai-providers/{id}/test [post]
func (h *coreHandlers) aiProviderTest(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) aiProviderTest(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePlatformAdmin(w, r); !ok {
return
}
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, http.MethodPost)
return return
@@ -599,6 +612,9 @@ func (h *coreHandlers) aiProviderTest(w http.ResponseWriter, r *http.Request) {
// @Failure 405 {object} dto.ErrorResponse // @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/ai-providers/{id}/models [get] // @Router /api/v1/ai-providers/{id}/models [get]
func (h *coreHandlers) aiProviderModels(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) aiProviderModels(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePlatformAdmin(w, r); !ok {
return
}
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
return return
@@ -1383,6 +1399,25 @@ func (h *coreHandlers) runJobResult(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, dto.RunJobResultFromDomain(result)) writeJSON(w, http.StatusOK, dto.RunJobResultFromDomain(result))
} }
// runJobBuildInput returns secret-bearing build input only to the active leased run worker.
func (h *coreHandlers) runJobBuildInput(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.DistributionBuildInputRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.GetDistributionBuildInput(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.DistributionBuildInputFromDomain(result))
}
// runJobCancelPoll godoc // runJobCancelPoll godoc
// @Summary Poll run job cancellation // @Summary Poll run job cancellation
// @Description Lets a registered run endpoint poll for cancellation requests on active leased jobs. // @Description Lets a registered run endpoint poll for cancellation requests on active leased jobs.
+67 -39
View File
@@ -35,12 +35,12 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
users := getJSONWithAuth[dto.UserListResponse](t, router, "/api/v1/users?status=active", adminSession) users := getJSONWithAuth[dto.UserListResponse](t, router, "/api/v1/users?status=active", adminSession)
assertListCount(t, users.Count, 2) assertListCount(t, users.Count, 2)
providerResponse := postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) providerResponse := createAIProviderFixture(t, router, adminSession)
if providerResponse.APIKeyRef != "secret://providers/openai" { if providerResponse.APIKeyRef != "secret://providers/openai" {
t.Fatalf("expected AI provider key reference, got %+v", providerResponse) t.Fatalf("expected AI provider key reference, got %+v", providerResponse)
} }
getJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai") getJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers/ai.openai", adminSession)
providers := getJSON[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active") providers := getJSONWithAuth[dto.AIProviderListResponse](t, router, "/api/v1/ai-providers?kind=openai&status=active", adminSession)
assertListCount(t, providers.Count, 1) assertListCount(t, providers.Count, 1)
pluginResponse := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) pluginResponse := postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
@@ -305,28 +305,18 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
} }
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession) runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
if runDistribution.ArtifactID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" { if runDistribution.ArtifactID == "" || runDistribution.BuildJobID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" || runDistribution.Status != string(domain.DistributionStatusBuilding) {
t.Fatalf("unexpected run distribution: %+v", runDistribution) t.Fatalf("unexpected run distribution: %+v", runDistribution)
} }
runDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession) runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
if runDownload.ArtifactID != runDistribution.ArtifactID || runDownload.DownloadURL == "" { assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
t.Fatalf("unexpected run download: %+v", runDownload)
}
updateRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/update", dto.RunUpdateRequest{ArtifactID: runDistribution.ArtifactID, Checksum: runDistribution.Checksum, IdempotencyKey: "api-run-update"}, adminSession)
assertStatus(t, updateRecorder, http.StatusAccepted)
update := decodeBody[dto.RunUpdateJobResponse](t, updateRecorder)
if update.JobID == "" || update.ArtifactID != runDistribution.ArtifactID || update.Status != string(domain.DistributionJobStatusQueued) {
t.Fatalf("unexpected run update job: %+v", update)
}
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession) clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef { if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
t.Fatalf("unexpected client distribution: %+v", clientDistribution) t.Fatalf("unexpected client distribution: %+v", clientDistribution)
} }
clientDownload := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession) clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
if clientDownload.ArtifactID != clientDistribution.ArtifactID { assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
t.Fatalf("unexpected client download: %+v", clientDownload)
}
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession) dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession)
assertStatus(t, dependencyCheckRecorder, http.StatusAccepted) assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
@@ -363,7 +353,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
t.Fatalf("unexpected client key reset: %+v", clientReset) t.Fatalf("unexpected client key reset: %+v", clientReset)
} }
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runDownload), mustJSON(t, clientDownload), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} { for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} {
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} { for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(body, forbidden) { if strings.Contains(body, forbidden) {
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body) t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
@@ -376,7 +366,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
for _, audit := range audits.Items { for _, audit := range audits.Items {
auditActions[audit.Action] = true auditActions[audit.Action] = true
} }
for _, action := range []string{"run.generate", "run.download", "run.update", "client-manager.build", "client-manager.download", "dependency.install", "logs.backfill", "runtime-key.reset"} { for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "logs.backfill", "runtime-key.reset"} {
if !auditActions[action] { if !auditActions[action] {
t.Fatalf("expected audit action %q in %+v", action, audits.Items) t.Fatalf("expected audit action %q in %+v", action, audits.Items)
} }
@@ -412,9 +402,9 @@ func TestCoreAPIErrorResponses(t *testing.T) {
rawKey := validAIProviderRequest() rawKey := validAIProviderRequest()
rawKey.ID = "ai.raw" rawKey.ID = "ai.raw"
rawKey.APIKeyRef = "sk-raw-secret" rawKey.APIKeyRef = "sk-raw-secret"
providerFailure := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey) providerFailure := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", rawKey, adminSession)
assertErrorResponse(t, providerFailure, http.StatusBadRequest, errorCodeValidation) assertErrorResponse(t, providerFailure, http.StatusBadRequest, errorCodeValidation)
missingProvider := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "") missingProvider := requestWithAuth(t, router, http.MethodGet, "/api/v1/ai-providers/ai.raw", "", adminSession)
assertErrorResponse(t, missingProvider, http.StatusNotFound, errorCodeNotFound) assertErrorResponse(t, missingProvider, http.StatusNotFound, errorCodeNotFound)
methodFailure := requestWithAuth(t, router, http.MethodDelete, "/api/v1/users", "", adminSession) methodFailure := requestWithAuth(t, router, http.MethodDelete, "/api/v1/users", "", adminSession)
@@ -791,7 +781,8 @@ func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) { func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
router := newTestRouter() router := newTestRouter()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest()) adminSession := createAdminSession(t, router)
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
assertStatus(t, recorder, http.StatusCreated) assertStatus(t, recorder, http.StatusCreated)
var body map[string]any var body map[string]any
@@ -811,38 +802,39 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
func TestAIProviderManagementAPI(t *testing.T) { func TestAIProviderManagementAPI(t *testing.T) {
router := newTestRouter() router := newTestRouter()
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) adminSession := createAdminSession(t, router)
createAIProviderFixture(t, router, adminSession)
update := validAIProviderUpdateRequest() update := validAIProviderUpdateRequest()
updatedRecorder := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update) updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
assertStatus(t, updatedRecorder, http.StatusOK) assertStatus(t, updatedRecorder, http.StatusOK)
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder) updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
if updated.Name != "OpenAI Relay" || updated.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive { if updated.Name != "OpenAI Relay" || updated.APIKeyRef != "vault://providers/openai" || updated.Status != domain.AIProviderStatusActive {
t.Fatalf("unexpected updated provider: %+v", updated) t.Fatalf("unexpected updated provider: %+v", updated)
} }
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}) statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
assertStatus(t, statusRecorder, http.StatusOK) assertStatus(t, statusRecorder, http.StatusOK)
disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder) disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder)
if disabled.Status != domain.AIProviderStatusDisabled { if disabled.Status != domain.AIProviderStatusDisabled {
t.Fatalf("expected disabled provider, got %+v", disabled) t.Fatalf("expected disabled provider, got %+v", disabled)
} }
testRecorder := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "") testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
assertStatus(t, testRecorder, http.StatusOK) assertStatus(t, testRecorder, http.StatusOK)
testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder) testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder)
if testResult.Success || testResult.Mode != "metadata" { if testResult.Success || testResult.Mode != "metadata" {
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult) t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
} }
models := getJSON[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models") models := getJSONWithAuth[dto.AIProviderModelsResponse](t, router, "/api/v1/ai-providers/ai.openai/models", adminSession)
if models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 2 { if models.DefaultModel != "gpt-4.1-mini" || len(models.Models) != 2 {
t.Fatalf("unexpected models response: %+v", models) t.Fatalf("unexpected models response: %+v", models)
} }
statusRecorder = performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive}) statusRecorder = requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusActive}, adminSession)
assertStatus(t, statusRecorder, http.StatusOK) assertStatus(t, statusRecorder, http.StatusOK)
testRecorder = performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "") testRecorder = requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
assertStatus(t, testRecorder, http.StatusOK) assertStatus(t, testRecorder, http.StatusOK)
testResult = decodeBody[dto.AIProviderTestResponse](t, testRecorder) testResult = decodeBody[dto.AIProviderTestResponse](t, testRecorder)
if !testResult.Success { if !testResult.Success {
@@ -852,30 +844,60 @@ func TestAIProviderManagementAPI(t *testing.T) {
func TestAIProviderManagementAPIErrors(t *testing.T) { func TestAIProviderManagementAPIErrors(t *testing.T) {
router := newTestRouter() router := newTestRouter()
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) adminSession := createAdminSession(t, router)
createAIProviderFixture(t, router, adminSession)
rawUpdate := validAIProviderUpdateRequest() rawUpdate := validAIProviderUpdateRequest()
rawUpdate.APIKeyRef = "sk-raw-secret" rawUpdate.APIKeyRef = "sk-raw-secret"
rawFailure := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate) rawFailure := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", rawUpdate, adminSession)
assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation) assertErrorResponse(t, rawFailure, http.StatusBadRequest, errorCodeValidation)
invalidStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError}) invalidStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusError}, adminSession)
assertErrorResponse(t, invalidStatus, http.StatusBadRequest, errorCodeValidation) assertErrorResponse(t, invalidStatus, http.StatusBadRequest, errorCodeValidation)
missingUpdate := performJSON(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest()) missingUpdate := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/missing", validAIProviderUpdateRequest(), adminSession)
assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound) assertErrorResponse(t, missingUpdate, http.StatusNotFound, errorCodeNotFound)
missingStatus := performJSON(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}) missingStatus := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
assertErrorResponse(t, missingStatus, http.StatusNotFound, errorCodeNotFound) assertErrorResponse(t, missingStatus, http.StatusNotFound, errorCodeNotFound)
missingTest := performRaw(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "") missingTest := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/missing/test", "", adminSession)
assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound) assertErrorResponse(t, missingTest, http.StatusNotFound, errorCodeNotFound)
missingModels := performRaw(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "") missingModels := requestWithAuth(t, router, http.MethodGet, "/api/v1/ai-providers/missing/models", "", adminSession)
assertErrorResponse(t, missingModels, http.StatusNotFound, errorCodeNotFound) assertErrorResponse(t, missingModels, http.StatusNotFound, errorCodeNotFound)
} }
func TestAIProviderManagementRequiresPlatformAdmin(t *testing.T) {
router := newTestRouter()
for _, request := range []struct {
name string
method string
path string
body any
}{
{name: "list", method: http.MethodGet, path: "/api/v1/ai-providers"},
{name: "create", method: http.MethodPost, path: "/api/v1/ai-providers", body: validAIProviderRequest()},
{name: "detail", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai"},
{name: "update", method: http.MethodPut, path: "/api/v1/ai-providers/ai.openai", body: validAIProviderUpdateRequest()},
{name: "status", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/status", body: dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}},
{name: "test", method: http.MethodPost, path: "/api/v1/ai-providers/ai.openai/test"},
{name: "models", method: http.MethodGet, path: "/api/v1/ai-providers/ai.openai/models"},
} {
t.Run(request.name, func(t *testing.T) {
var recorder *httptest.ResponseRecorder
if request.body == nil {
recorder = performRaw(t, router, request.method, request.path, "")
} else {
recorder = performJSON(t, router, request.method, request.path, request.body)
}
assertErrorResponse(t, recorder, http.StatusUnauthorized, errorCodeUnauthorized)
})
}
}
func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) { func TestAIInvocationAPIIsMediatedAndSafe(t *testing.T) {
router := newTestRouter() router := newTestRouter()
adminSession := createAdminSession(t, router) adminSession := createAdminSession(t, router)
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) createAIProviderFixture(t, router, adminSession)
registration := validGamePluginManifestRegistrationRequest() registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"} registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.logs.read", "ai.invoke"}
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)} registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionAIInvoke)}
@@ -1090,7 +1112,7 @@ func TestPluginBridgeExecuteAPI(t *testing.T) {
Password: "secret-password", Password: "secret-password",
}, adminSession) }, adminSession)
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "bridge-owner@example.test", Password: "secret-password"}).SessionID ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "bridge-owner@example.test", Password: "secret-password"}).SessionID
postJSON[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest()) createAIProviderFixture(t, router, adminSession)
registration := validGamePluginManifestRegistrationRequest() registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionJobsDispatch)) registration.Manifest.Bridge.Actions = append(registration.Manifest.Bridge.Actions, string(domain.PluginBridgeActionJobsDispatch))
@@ -1404,6 +1426,11 @@ func createAdminSession(t *testing.T, router http.Handler) string {
return session.SessionID return session.SessionID
} }
func createAIProviderFixture(t *testing.T, router http.Handler, adminSession string) dto.AIProviderResponse {
t.Helper()
return postJSONWithAuth[dto.AIProviderResponse](t, router, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
}
func decodeBody[T any](t *testing.T, recorder *httptest.ResponseRecorder) T { func decodeBody[T any](t *testing.T, recorder *httptest.ResponseRecorder) T {
t.Helper() t.Helper()
var body T var body T
@@ -1494,6 +1521,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
endpoint := validRunEndpointRequest() endpoint := validRunEndpointRequest()
endpoint.ID = "run-runtime" endpoint.ID = "run-runtime"
endpoint.Capabilities = append(endpoint.Capabilities, endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityDistributionBuild,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDependenciesInstall,
+27
View File
@@ -90,6 +90,33 @@ type RunJobResultResult struct {
ServerTime time.Time ServerTime time.Time
} }
type DistributionBuildInputRequest struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
}
type DistributionBuildInput struct {
JobID string
ComponentKind DistributionComponentKind
ServerInstanceID string
PluginID string
RunEndpointID string
ProfileKey string
TargetOS string
TargetArch string
PackageFormat string
RepositoryURL string
SourceRevision string
ArtifactID string
OutputFilename string
SecretRef string
KeyGeneration int
AuthKey string
}
type RunJobCancelRequest struct { type RunJobCancelRequest struct {
JobID string JobID string
Reason string Reason string
+2
View File
@@ -580,6 +580,7 @@ const (
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
JobCapabilityRunSelfUpdate = "run.self-update" JobCapabilityRunSelfUpdate = "run.self-update"
JobCapabilityDistributionBuild = "distribution.build"
JobCapabilityDependenciesCheck = "dependencies.check" JobCapabilityDependenciesCheck = "dependencies.check"
JobCapabilityDependenciesInstall = "dependencies.install" JobCapabilityDependenciesInstall = "dependencies.install"
JobCapabilityLogsBackfill = "logs.backfill" JobCapabilityLogsBackfill = "logs.backfill"
@@ -663,6 +664,7 @@ type RunDistribution struct {
TargetOS string TargetOS string
TargetArch string TargetArch string
PackageFormat string PackageFormat string
BuildJobID string
ArtifactID string ArtifactID string
Checksum string Checksum string
KeyGeneration int KeyGeneration int
+2
View File
@@ -88,6 +88,7 @@ type RunDistributionResponse struct {
TargetOS string `json:"targetOs"` TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"` TargetArch string `json:"targetArch"`
PackageFormat string `json:"packageFormat"` PackageFormat string `json:"packageFormat"`
BuildJobID string `json:"buildJobId"`
ArtifactID string `json:"artifactId"` ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum"` Checksum string `json:"checksum"`
KeyGeneration int `json:"keyGeneration"` KeyGeneration int `json:"keyGeneration"`
@@ -262,6 +263,7 @@ func RunDistributionFromDomain(distribution domain.RunDistribution) RunDistribut
TargetOS: distribution.TargetOS, TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch, TargetArch: distribution.TargetArch,
PackageFormat: distribution.PackageFormat, PackageFormat: distribution.PackageFormat,
BuildJobID: distribution.BuildJobID,
ArtifactID: distribution.ArtifactID, ArtifactID: distribution.ArtifactID,
Checksum: distribution.Checksum, Checksum: distribution.Checksum,
KeyGeneration: distribution.KeyGeneration, KeyGeneration: distribution.KeyGeneration,
+58
View File
@@ -89,6 +89,33 @@ type RunJobResultResponse struct {
ServerTime time.Time `json:"serverTime"` ServerTime time.Time `json:"serverTime"`
} }
type DistributionBuildInputRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
}
type DistributionBuildInputResponse struct {
JobID string `json:"jobId"`
ComponentKind string `json:"componentKind"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
RunEndpointID string `json:"runEndpointId"`
ProfileKey string `json:"profileKey,omitempty"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
PackageFormat string `json:"packageFormat"`
RepositoryURL string `json:"repositoryUrl,omitempty"`
SourceRevision string `json:"sourceRevision,omitempty"`
ArtifactID string `json:"artifactId"`
OutputFilename string `json:"outputFilename"`
SecretRef string `json:"secretRef"`
KeyGeneration int `json:"keyGeneration"`
AuthKey string `json:"authKey"`
}
type RunJobCancelRequestBody struct { type RunJobCancelRequestBody struct {
JobID string `json:"jobId"` JobID string `json:"jobId"`
Reason string `json:"reason"` Reason string `json:"reason"`
@@ -179,6 +206,16 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
} }
} }
func (request DistributionBuildInputRequest) ToDomain() domain.DistributionBuildInputRequest {
return domain.DistributionBuildInputRequest{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
JobID: request.JobID,
LeaseToken: request.LeaseToken,
Attempt: request.Attempt,
}
}
func (request RunJobCancelRequestBody) ToDomain() domain.RunJobCancelRequest { func (request RunJobCancelRequestBody) ToDomain() domain.RunJobCancelRequest {
return domain.RunJobCancelRequest{ return domain.RunJobCancelRequest{
JobID: request.JobID, JobID: request.JobID,
@@ -239,6 +276,27 @@ func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultRespon
} }
} }
func DistributionBuildInputFromDomain(input domain.DistributionBuildInput) DistributionBuildInputResponse {
return DistributionBuildInputResponse{
JobID: input.JobID,
ComponentKind: string(input.ComponentKind),
ServerInstanceID: input.ServerInstanceID,
PluginID: input.PluginID,
RunEndpointID: input.RunEndpointID,
ProfileKey: input.ProfileKey,
TargetOS: input.TargetOS,
TargetArch: input.TargetArch,
PackageFormat: input.PackageFormat,
RepositoryURL: input.RepositoryURL,
SourceRevision: input.SourceRevision,
ArtifactID: input.ArtifactID,
OutputFilename: input.OutputFilename,
SecretRef: input.SecretRef,
KeyGeneration: input.KeyGeneration,
AuthKey: input.AuthKey,
}
}
func RunJobCancelRequestFromDomain(result domain.RunJobCancelRequestResult) RunJobCancelRequestResponse { func RunJobCancelRequestFromDomain(result domain.RunJobCancelRequestResult) RunJobCancelRequestResponse {
return RunJobCancelRequestResponse{ return RunJobCancelRequestResponse{
Accepted: result.Accepted, Accepted: result.Accepted,
+1
View File
@@ -47,6 +47,7 @@ type RunDistribution struct {
TargetOS string `json:"targetOs" db:"target_os"` TargetOS string `json:"targetOs" db:"target_os"`
TargetArch string `json:"targetArch" db:"target_arch"` TargetArch string `json:"targetArch" db:"target_arch"`
PackageFormat string `json:"packageFormat" db:"package_format"` PackageFormat string `json:"packageFormat" db:"package_format"`
BuildJobID string `json:"buildJobId" db:"build_job_id"`
ArtifactID string `json:"artifactId" db:"artifact_id"` ArtifactID string `json:"artifactId" db:"artifact_id"`
Checksum string `json:"checksum" db:"checksum"` Checksum string `json:"checksum" db:"checksum"`
KeyGeneration int `json:"keyGeneration" db:"key_generation"` KeyGeneration int `json:"keyGeneration" db:"key_generation"`
+2 -2
View File
@@ -27,7 +27,7 @@ func TestCoreServiceRegistersNewRunControlSession(t *testing.T) {
if endpoint.Status != domain.RunEndpointStatusOnline || !endpoint.LastHeartbeatAt.Equal(fixedTime) { if endpoint.Status != domain.RunEndpointStatusOnline || !endpoint.LastHeartbeatAt.Equal(fixedTime) {
t.Fatalf("expected online endpoint with heartbeat time, got %+v", endpoint) t.Fatalf("expected online endpoint with heartbeat time, got %+v", endpoint)
} }
if len(endpoint.Capabilities) != 2 || endpoint.Capacity.MaxJobs != 4 { if len(endpoint.Capabilities) != 3 || endpoint.Capacity.MaxJobs != 4 {
t.Fatalf("expected capabilities and capacity, got %+v", endpoint) t.Fatalf("expected capabilities and capacity, got %+v", endpoint)
} }
} }
@@ -224,7 +224,7 @@ func validRunControlHello() domain.RunControlHello {
Status: domain.RunEndpointStatusOnline, Status: domain.RunEndpointStatusOnline,
Platform: "darwin/arm64", Platform: "darwin/arm64",
CapabilityReport: domain.RunCapabilityReport{ CapabilityReport: domain.RunCapabilityReport{
Capabilities: []string{"control.hello", "control.heartbeat"}, Capabilities: []string{"control.hello", "control.heartbeat", domain.JobCapabilityDistributionBuild},
Fingerprint: "cap-v1", Fingerprint: "cap-v1",
}, },
Capacity: domain.RunCapacity{MaxJobs: 4}, Capacity: domain.RunCapacity{MaxJobs: 4},
+229
View File
@@ -0,0 +1,229 @@
package service
import (
"errors"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) GetDistributionBuildInput(request domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error) {
if err := validator.ValidateDistributionBuildInputRequest(request); err != nil {
return domain.DistributionBuildInput{}, err
}
if err := svc.validateRunSession(request.RunEndpointID, request.SessionToken); err != nil {
return domain.DistributionBuildInput{}, err
}
svc.jobMu.Lock()
job, _, err := svc.activeLeasedJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
svc.jobMu.Unlock()
if err != nil {
return domain.DistributionBuildInput{}, err
}
if job.Capability != domain.JobCapabilityDistributionBuild {
return domain.DistributionBuildInput{}, validationError("job is not a distribution build")
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
return domain.DistributionBuildInput{}, validationError("distribution build job is not active")
}
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.DistributionBuildInput{}, err
}
for _, distribution := range runDistributions {
if distribution.BuildJobID != job.ID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentRun, "")
if err != nil {
return domain.DistributionBuildInput{}, err
}
if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("run build key generation is no longer current")
}
plainKey, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentRun,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: distribution.RunEndpointID,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
PackageFormat: distribution.PackageFormat,
ArtifactID: distribution.ArtifactID,
OutputFilename: executableFilename("run", distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
}, nil
}
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return domain.DistributionBuildInput{}, err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID != job.ID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
if key.Generation != distribution.KeyGeneration {
return domain.DistributionBuildInput{}, validationError("client-manager build key generation is no longer current")
}
plainKey, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
return domain.DistributionBuildInput{}, err
}
return domain.DistributionBuildInput{
JobID: job.ID,
ComponentKind: domain.DistributionComponentClientManager,
ServerInstanceID: distribution.ServerInstanceID,
PluginID: distribution.PluginID,
RunEndpointID: job.RunEndpointID,
ProfileKey: distribution.ProfileKey,
TargetOS: distribution.TargetOS,
TargetArch: distribution.TargetArch,
PackageFormat: packageFormatForTarget(distribution.TargetOS),
RepositoryURL: distribution.RepositoryURL,
SourceRevision: distribution.SourceRevision,
ArtifactID: distribution.ArtifactID,
OutputFilename: clientManagerOutputName(distribution.ProfileKey, distribution.TargetOS),
SecretRef: distribution.SecretRef,
KeyGeneration: distribution.KeyGeneration,
AuthKey: plainKey,
}, nil
}
return domain.DistributionBuildInput{}, repo.ErrNotFound
}
func (svc *CoreService) projectDistributionBuildProgress(job domain.Job, stamp time.Time) error {
if job.Capability != domain.JobCapabilityDistributionBuild {
return nil
}
builds, err := svc.store.ClientManagerBuildJobs().List(domain.ClientManagerBuildJobFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, build := range builds {
if build.ID != job.ID || build.Status != domain.DistributionJobStatusQueued {
continue
}
build.Status = domain.DistributionJobStatusRunning
build.UpdatedAt = stamp
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
return err
}
return svc.store.ClientManagerBuildJobs().Update(build)
}
return nil
}
func (svc *CoreService) projectDistributionBuildResult(job domain.Job, stamp time.Time) error {
if job.Capability != domain.JobCapabilityDistributionBuild {
return nil
}
status := domain.DistributionStatusFailed
buildStatus := domain.DistributionJobStatusFailed
var artifact domain.Artifact
if job.State == domain.JobStateSucceeded {
artifactID := strings.TrimPrefix(job.ResultRef, "artifact://")
if artifactID == "" || artifactID == job.ResultRef {
return validationError("distribution build result must reference an artifact")
}
var err error
artifact, err = svc.store.Artifacts().Get(artifactID)
if err != nil {
return err
}
if artifact.State != domain.ArtifactStateAvailable || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != job.ID {
return validationError("distribution build artifact is unavailable or outside the job scope")
}
status = domain.DistributionStatusAvailable
buildStatus = domain.DistributionJobStatusSucceeded
}
runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, distribution := range runDistributions {
if distribution.BuildJobID != job.ID {
continue
}
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
return validationError("distribution build returned an unexpected artifact")
}
distribution.Status = status
if artifact.ID != "" {
distribution.Checksum = artifact.Checksum
}
distribution.UpdatedAt = stamp
if err := validator.ValidateRunDistribution(distribution); err != nil {
return err
}
return svc.store.RunDistributions().Update(distribution)
}
clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{ServerInstanceID: job.ServerInstanceID})
if err != nil {
return err
}
for _, distribution := range clientDistributions {
if distribution.BuildJobID != job.ID {
continue
}
if job.State == domain.JobStateSucceeded && artifact.ID != distribution.ArtifactID {
return validationError("client-manager build returned an unexpected artifact")
}
distribution.Status = status
if artifact.ID != "" {
distribution.Checksum = artifact.Checksum
}
distribution.UpdatedAt = stamp
if err := validator.ValidateClientManagerDistribution(distribution); err != nil {
return err
}
if err := svc.store.ClientManagerDistributions().Update(distribution); err != nil {
return err
}
build, err := svc.store.ClientManagerBuildJobs().Get(job.ID)
if err != nil && !errors.Is(err, repo.ErrNotFound) {
return err
}
if err == nil {
build.Status = buildStatus
if artifact.ID != "" {
build.ArtifactID = artifact.ID
build.Checksum = artifact.Checksum
}
build.UpdatedAt = stamp
if err := validator.ValidateClientManagerBuildJob(build); err != nil {
return err
}
if err := svc.store.ClientManagerBuildJobs().Update(build); err != nil {
return err
}
}
return nil
}
return repo.ErrNotFound
}
func executableFilename(base string, targetOS string) string {
if targetOS == "windows" {
return base + ".exe"
}
return base
}
+182 -83
View File
@@ -8,7 +8,6 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -78,6 +77,13 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil { if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "run.generate.denied"); err != nil {
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.RunDistribution{}, err
}
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
return domain.RunDistribution{}, err
}
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "") key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
if err != nil { if err != nil {
@@ -90,26 +96,9 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
config := generatedPackageConfig{ _ = plainKey
Kind: string(domain.DistributionComponentRun), artifactID := artifactIDForDistribution(distributionID + "-binary")
ServerInstanceID: instance.ID, buildJobID := jobIDFromParts("job-distribution-build", instance.ID, distributionID)
PluginID: plugin.ID,
RunEndpointID: instance.RunEndpointID,
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
SecretRef: key.SecretRef,
KeyGeneration: key.Generation,
AuthKey: plainKey,
}
payload, err := json.MarshalIndent(config, "", " ")
if err != nil {
return domain.RunDistribution{}, err
}
artifactID := artifactIDForDistribution(distributionID)
artifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
if err != nil {
return domain.RunDistribution{}, err
}
stamp := svc.now() stamp := svc.now()
distribution := domain.RunDistribution{ distribution := domain.RunDistribution{
ID: distributionID, ID: distributionID,
@@ -119,11 +108,11 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
TargetOS: request.TargetOS, TargetOS: request.TargetOS,
TargetArch: request.TargetArch, TargetArch: request.TargetArch,
PackageFormat: packageFormatForTarget(request.TargetOS), PackageFormat: packageFormatForTarget(request.TargetOS),
ArtifactID: artifact.ID, BuildJobID: buildJobID,
Checksum: artifact.Checksum, ArtifactID: artifactID,
KeyGeneration: key.Generation, KeyGeneration: key.Generation,
SecretRef: key.SecretRef, SecretRef: key.SecretRef,
Status: domain.DistributionStatusAvailable, Status: domain.DistributionStatusBuilding,
CreatedAt: stamp, CreatedAt: stamp,
UpdatedAt: stamp, UpdatedAt: stamp,
} }
@@ -131,9 +120,35 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
if err := svc.store.RunDistributions().Create(distribution); err != nil { if err := svc.store.RunDistributions().Create(distribution); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
existing, getErr := svc.store.RunDistributions().Get(distribution.ID)
if getErr != nil {
return domain.RunDistribution{}, getErr
}
return domain.CopyRunDistribution(existing), nil
}
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultSuccess, "generated run package with redacted runtime key ref"); err != nil { job, err := svc.CreateJob(domain.Job{
ID: buildJobID,
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/run",
InputRef: "input://distribution-build/" + distribution.ID,
IdempotencyKey: "distribution-build:" + distribution.ID,
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
})
if err != nil {
distribution.Status = domain.DistributionStatusFailed
distribution.UpdatedAt = svc.now()
_ = svc.store.RunDistributions().Update(distribution)
return domain.RunDistribution{}, err
}
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job")
}
if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job with redacted runtime key ref"); err != nil {
return domain.RunDistribution{}, err return domain.RunDistribution{}, err
} }
return domain.CopyRunDistribution(distribution), nil return domain.CopyRunDistribution(distribution), nil
@@ -172,6 +187,13 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil { if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil {
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
if err := validateRunnableEndpoint(endpoint, domain.JobCapabilityDistributionBuild); err != nil {
return domain.ClientManagerDistribution{}, err
}
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey) key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentClientManager, request.ProfileKey)
if err != nil { if err != nil {
@@ -184,52 +206,12 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
config := generatedPackageConfig{ _ = plainKey
Kind: string(domain.DistributionComponentClientManager), artifactID := artifactIDForDistribution(clientDistributionID + "-binary")
ServerInstanceID: instance.ID,
PluginID: plugin.ID,
ProfileKey: request.ProfileKey,
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
SecretRef: key.SecretRef,
KeyGeneration: key.Generation,
AuthKey: plainKey,
}
checkout := clientManagerCheckoutPlan{
RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision,
CheckoutRef: clientManagerCheckoutRef(request.RepositoryURL, request.SourceRevision),
TargetOS: request.TargetOS,
TargetArch: request.TargetArch,
}
outputArtifacts := []string{clientManagerOutputName(request.ProfileKey, request.TargetOS)}
buildLogPayload := []byte(clientManagerBuildLog(checkout, config, outputArtifacts))
buildLogArtifactID := artifactIDForDistribution(clientDistributionID + "-build-log")
buildLogArtifact, err := svc.createPlatformArtifactPayload(buildLogArtifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, buildLogPayload)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
packagePayload := generatedClientManagerPackage{
Kind: "client-manager-package",
Checkout: checkout,
Config: config,
OutputArtifacts: outputArtifacts,
BuildLogRef: "artifact://" + buildLogArtifact.ID,
KeyFingerprint: fingerprintForString(plainKey),
}
payload, err := json.MarshalIndent(packagePayload, "", " ")
if err != nil {
return domain.ClientManagerDistribution{}, err
}
artifactID := artifactIDForDistribution(clientDistributionID)
artifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
if err != nil {
return domain.ClientManagerDistribution{}, err
}
stamp := svc.now() stamp := svc.now()
buildJobID := jobIDFromParts("job-distribution-build", instance.ID, clientDistributionID)
buildJob := domain.ClientManagerBuildJob{ buildJob := domain.ClientManagerBuildJob{
ID: distributionID("client-manager-build", instance.ID, request.ProfileKey, request.TargetOS, request.TargetArch, key.Generation, request.IdempotencyKey), ID: buildJobID,
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
PluginID: plugin.ID, PluginID: plugin.ID,
ProfileKey: request.ProfileKey, ProfileKey: request.ProfileKey,
@@ -237,11 +219,9 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
TargetArch: request.TargetArch, TargetArch: request.TargetArch,
RepositoryURL: request.RepositoryURL, RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision, SourceRevision: request.SourceRevision,
ArtifactID: artifact.ID, ArtifactID: artifactID,
Checksum: artifact.Checksum,
KeyGeneration: key.Generation, KeyGeneration: key.Generation,
LogsRef: "artifact://" + buildLogArtifact.ID, Status: domain.DistributionJobStatusQueued,
Status: domain.DistributionJobStatusSucceeded,
CreatedAt: stamp, CreatedAt: stamp,
UpdatedAt: stamp, UpdatedAt: stamp,
} }
@@ -249,7 +229,17 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil { if err := svc.store.ClientManagerBuildJobs().Create(buildJob); err != nil {
return domain.ClientManagerDistribution{}, err if !errors.Is(err, repo.ErrDuplicate) {
return domain.ClientManagerDistribution{}, err
}
existing, getErr := svc.store.ClientManagerBuildJobs().Get(buildJob.ID)
if getErr != nil {
return domain.ClientManagerDistribution{}, getErr
}
if !sameClientManagerBuildJobArtifacts(existing, buildJob) {
return domain.ClientManagerDistribution{}, validationError("client-manager build job already exists with different artifacts")
}
buildJob = existing
} }
distribution := domain.ClientManagerDistribution{ distribution := domain.ClientManagerDistribution{
ID: clientDistributionID, ID: clientDistributionID,
@@ -261,11 +251,10 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
RepositoryURL: request.RepositoryURL, RepositoryURL: request.RepositoryURL,
SourceRevision: request.SourceRevision, SourceRevision: request.SourceRevision,
BuildJobID: buildJob.ID, BuildJobID: buildJob.ID,
ArtifactID: artifact.ID, ArtifactID: artifactID,
Checksum: artifact.Checksum,
KeyGeneration: key.Generation, KeyGeneration: key.Generation,
SecretRef: key.SecretRef, SecretRef: key.SecretRef,
Status: domain.DistributionStatusAvailable, Status: domain.DistributionStatusBuilding,
CreatedAt: stamp, CreatedAt: stamp,
UpdatedAt: stamp, UpdatedAt: stamp,
} }
@@ -273,9 +262,38 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil { if err := svc.store.ClientManagerDistributions().Create(distribution); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
existing, getErr := svc.store.ClientManagerDistributions().Get(distribution.ID)
if getErr != nil {
return domain.ClientManagerDistribution{}, getErr
}
return domain.CopyClientManagerDistribution(existing), nil
}
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultSuccess, "generated client-manager artifact with redacted runtime key ref"); err != nil { job, err := svc.CreateJob(domain.Job{
ID: buildJobID,
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: domain.JobCapabilityDistributionBuild,
TargetKey: "distribution/client-manager/" + request.ProfileKey,
InputRef: "input://distribution-build/" + distribution.ID,
IdempotencyKey: "distribution-build:" + distribution.ID,
Progress: domain.JobProgress{Percent: 0, Message: "build queued"},
})
if err != nil {
buildJob.Status = domain.DistributionJobStatusFailed
buildJob.UpdatedAt = svc.now()
distribution.Status = domain.DistributionStatusFailed
distribution.UpdatedAt = buildJob.UpdatedAt
_ = svc.store.ClientManagerBuildJobs().Update(buildJob)
_ = svc.store.ClientManagerDistributions().Update(distribution)
return domain.ClientManagerDistribution{}, err
}
if job.ID != buildJobID || job.Capability != domain.JobCapabilityDistributionBuild {
return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job")
}
if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build with redacted runtime key ref"); err != nil {
return domain.ClientManagerDistribution{}, err return domain.ClientManagerDistribution{}, err
} }
return domain.CopyClientManagerDistribution(distribution), nil return domain.CopyClientManagerDistribution(distribution), nil
@@ -458,11 +476,11 @@ func (svc *CoreService) GetServerRuntimeActionsForSession(sessionID string, serv
RunEndpointID: endpoint.ID, RunEndpointID: endpoint.ID,
RunStatus: endpoint.Status, RunStatus: endpoint.Status,
Actions: []domain.ServerRuntimeAction{ Actions: []domain.ServerRuntimeAction{
runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared", bindingReason)), runtimeAction("generate-run", "Generate run", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"), runtimeAction("download-run", "Download run", hasAvailableRunPackage, "run package has not been generated"),
runtimeAction("push-run-update", "Push run update", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason)), runtimeAction("push-run-update", "Push run update", pluginDeclares(plugin, "server.run.distribution") && endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.run.distribution") || !endpointSupports(endpoint, domain.JobCapabilityRunSelfUpdate), "run endpoint cannot self-update", bindingReason)),
runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"), runtimeAction("reset-run-key", "Reset run key", pluginDeclares(plugin, "server.run.distribution"), "plugin permission is not declared"),
runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared", bindingReason)), runtimeAction("generate-client-manager", "Generate client manager", pluginDeclares(plugin, "server.client-manager.manage") && endpointSupports(endpoint, domain.JobCapabilityDistributionBuild) && bindingsComplete, fallbackReason(!pluginDeclares(plugin, "server.client-manager.manage") || !endpointSupports(endpoint, domain.JobCapabilityDistributionBuild), "run endpoint cannot build distributions", bindingReason)),
runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"), runtimeAction("download-client-manager", "Download client manager", hasAvailableClientPackage, "client-manager package has not been generated"),
runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"), runtimeAction("reset-client-manager-key", "Reset client-manager key", pluginDeclares(plugin, "server.client-manager.manage"), "client-manager permission is not declared"),
runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)), runtimeAction("dependencies-check", "Check dependencies", endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck) && bindingsComplete, fallbackReason(!endpointSupports(endpoint, domain.JobCapabilityDependenciesCheck), "run endpoint cannot check dependencies", bindingReason)),
@@ -546,6 +564,16 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain
return domain.RunUpdateJob{}, err return domain.RunUpdateJob{}, err
} }
if err := svc.store.RunUpdateJobs().Create(updateJob); err != nil { if err := svc.store.RunUpdateJobs().Create(updateJob); err != nil {
if errors.Is(err, repo.ErrDuplicate) {
existing, getErr := svc.store.RunUpdateJobs().Get(updateJob.ID)
if getErr != nil {
return domain.RunUpdateJob{}, getErr
}
if !sameRunUpdateJob(existing, updateJob) {
return domain.RunUpdateJob{}, validationError("run update job already exists with different target")
}
return domain.CopyRunUpdateJob(existing), nil
}
return domain.RunUpdateJob{}, err return domain.RunUpdateJob{}, err
} }
if err := svc.recordAuditEvent(user.ID, "run.update", "server-instance", instance.ID, domain.AuditResultQueued, "queued run self-update job with artifact checksum"); err != nil { if err := svc.recordAuditEvent(user.ID, "run.update", "server-instance", instance.ID, domain.AuditResultQueued, "queued run self-update job with artifact checksum"); err != nil {
@@ -793,6 +821,9 @@ func (svc *CoreService) revokeComponentDistributions(serverInstanceID string, ki
func (svc *CoreService) expireDistributionArtifact(artifactID string) error { func (svc *CoreService) expireDistributionArtifact(artifactID string) error {
artifact, err := svc.store.Artifacts().Get(artifactID) artifact, err := svc.store.Artifacts().Get(artifactID)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil { if err != nil {
return err return err
} }
@@ -819,14 +850,82 @@ func (svc *CoreService) createPlatformArtifactPayload(artifactID string, ownerKi
return domain.Artifact{}, err return domain.Artifact{}, err
} }
if err := svc.store.Artifacts().Create(artifact); err != nil { if err := svc.store.Artifacts().Create(artifact); err != nil {
if !errors.Is(err, repo.ErrDuplicate) {
return domain.Artifact{}, err
}
existing, getErr := svc.store.Artifacts().Get(artifact.ID)
if getErr != nil {
return domain.Artifact{}, getErr
}
if err := validateReusablePlatformArtifact(existing, artifact); err != nil {
return domain.Artifact{}, err
}
if err := svc.ensureArtifactPayload(artifact.ID, payload, existing); err != nil {
return domain.Artifact{}, err
}
return domain.CopyArtifact(existing), nil
}
if err := svc.ensureArtifactPayload(artifact.ID, payload, artifact); err != nil {
return domain.Artifact{}, err return domain.Artifact{}, err
} }
svc.artifactMu.Lock()
svc.artifactPayloads[artifact.ID] = domain.CopyBytes(payload)
svc.artifactMu.Unlock()
return domain.CopyArtifact(artifact), nil return domain.CopyArtifact(artifact), nil
} }
func validateReusablePlatformArtifact(existing domain.Artifact, expected domain.Artifact) error {
if existing.OwnerKind != expected.OwnerKind || existing.OwnerID != expected.OwnerID {
return validationError("artifact already exists with different owner")
}
if existing.SizeBytes != expected.SizeBytes || existing.Checksum != expected.Checksum {
return validationError("artifact already exists with different content")
}
if existing.State != domain.ArtifactStateAvailable {
return validationError("artifact already exists but is not available")
}
return nil
}
func (svc *CoreService) ensureArtifactPayload(artifactID string, payload []byte, artifact domain.Artifact) error {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if existingPayload, exists := svc.artifactPayloads[artifactID]; exists {
if int64(len(existingPayload)) != artifact.SizeBytes || validator.BytesChecksum(existingPayload) != artifact.Checksum {
return validationError("artifact payload does not match metadata")
}
return nil
}
if int64(len(payload)) != artifact.SizeBytes || validator.BytesChecksum(payload) != artifact.Checksum {
return validationError("artifact payload does not match metadata")
}
svc.artifactPayloads[artifactID] = domain.CopyBytes(payload)
return nil
}
func sameClientManagerBuildJobArtifacts(existing domain.ClientManagerBuildJob, expected domain.ClientManagerBuildJob) bool {
return existing.ServerInstanceID == expected.ServerInstanceID &&
existing.PluginID == expected.PluginID &&
existing.ProfileKey == expected.ProfileKey &&
existing.TargetOS == expected.TargetOS &&
existing.TargetArch == expected.TargetArch &&
existing.RepositoryURL == expected.RepositoryURL &&
existing.SourceRevision == expected.SourceRevision &&
existing.ArtifactID == expected.ArtifactID &&
existing.Checksum == expected.Checksum &&
existing.KeyGeneration == expected.KeyGeneration &&
existing.LogsRef == expected.LogsRef &&
existing.Status == expected.Status
}
func sameRunUpdateJob(existing domain.RunUpdateJob, expected domain.RunUpdateJob) bool {
return existing.ServerInstanceID == expected.ServerInstanceID &&
existing.RunEndpointID == expected.RunEndpointID &&
existing.ArtifactID == expected.ArtifactID &&
existing.Checksum == expected.Checksum &&
existing.JobID == expected.JobID &&
existing.IdempotencyKey == expected.IdempotencyKey &&
existing.Status == expected.Status
}
func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, state domain.DependencyState, message string) error { func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, request domain.DependencyJobRequest, state domain.DependencyState, message string) error {
statusID := distributionID("dependency-status", instance.ID, request.ProbeKey) statusID := distributionID("dependency-status", instance.ID, request.ProbeKey)
stamp := svc.now() stamp := svc.now()
+167 -49
View File
@@ -1,12 +1,12 @@
package service package service
import ( import (
"encoding/json"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"browser.local/platform/domain" "browser.local/platform/domain"
"browser.local/platform/repo"
) )
func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) { func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing.T) {
@@ -21,9 +21,16 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
if err != nil { if err != nil {
t.Fatalf("generate run distribution: %v", err) t.Fatalf("generate run distribution: %v", err)
} }
if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusAvailable { if distribution.KeyGeneration != 1 || distribution.SecretRef == "" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" || distribution.Checksum != "" {
t.Fatalf("unexpected run distribution: %+v", distribution) t.Fatalf("unexpected run distribution: %+v", distribution)
} }
job, err := svc.GetJob(distribution.BuildJobID)
if err != nil || job.Capability != domain.JobCapabilityDistributionBuild || job.State != domain.JobStateQueued {
t.Fatalf("expected queued backend build job, job=%+v err=%v", job, err)
}
if _, err := svc.GetArtifact(distribution.ArtifactID); !errors.Is(err, repo.ErrNotFound) {
t.Fatalf("generation must not publish config JSON as an artifact, got %v", err)
}
keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{ keys, err := svc.store.EncryptedComponentKeys().List(domain.EncryptedComponentKeyFilter{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
@@ -76,6 +83,78 @@ func TestCoreServiceGeneratesRunDistributionWithEncryptedSingletonKey(t *testing
} }
} }
func TestCoreServiceRunDistributionRetryReusesPartialArtifact(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
key, plainKey, err := svc.ensureActiveComponentKey(instance.ID, domain.DistributionComponentRun, "")
if err != nil {
t.Fatalf("ensure run key: %v", err)
}
idempotencyKey := "web:run.generate:scum-alpha:1784043453685"
distributionID := distributionID("run-dist", instance.ID, "windows", "amd64", key.Generation, idempotencyKey)
artifactID := artifactIDForDistribution(distributionID)
payload := []byte("legacy generated config is not a binary distribution")
partialArtifact, err := svc.createPlatformArtifactPayload(artifactID, domain.ArtifactOwnerKindServerInstance, instance.ID, payload)
if err != nil {
t.Fatalf("create partial artifact: %v", err)
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "windows",
TargetArch: "amd64",
IdempotencyKey: idempotencyKey,
})
if err != nil {
t.Fatalf("retry run generation should recover partial artifact: %v", err)
}
if distribution.ID != distributionID || distribution.ArtifactID == partialArtifact.ID || distribution.Checksum != "" {
t.Fatalf("expected real binary build to ignore legacy config artifact, distribution=%+v artifact=%+v", distribution, partialArtifact)
}
if distribution.PackageFormat != "zip" || distribution.Status != domain.DistributionStatusBuilding || distribution.BuildJobID == "" {
t.Fatalf("unexpected recovered distribution: %+v", distribution)
}
recoveredConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
if recoveredConfig.AuthKey != plainKey || recoveredConfig.KeyGeneration != key.Generation {
t.Fatalf("expected recovered artifact payload to remain downloadable, got %+v", recoveredConfig)
}
}
func TestCoreServicePushRunUpdateReusesExistingUpdateJob(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "windows",
TargetArch: "amd64",
IdempotencyKey: "idem-run-before-update",
})
if err != nil {
t.Fatalf("generate run distribution: %v", err)
}
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive"))
request := domain.RunUpdateRequest{
ServerInstanceID: instance.ID,
ArtifactID: distribution.ArtifactID,
Checksum: distribution.Checksum,
IdempotencyKey: "idem-run-update-repeat",
}
first, err := svc.PushRunUpdateForSession(session, request)
if err != nil {
t.Fatalf("push run update: %v", err)
}
second, err := svc.PushRunUpdateForSession(session, request)
if err != nil {
t.Fatalf("push run update again should reuse existing update job: %v", err)
}
if second.ID != first.ID || second.JobID != first.JobID || second.ArtifactID != first.ArtifactID {
t.Fatalf("expected repeated push to return existing update job, first=%+v second=%+v", first, second)
}
if second.Status != domain.DistributionJobStatusQueued {
t.Fatalf("expected queued existing update job, got %+v", second)
}
}
func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *testing.T) { func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t) svc, session, instance := newDistributionTestFixture(t)
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
@@ -88,6 +167,7 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test
t.Fatalf("generate run distribution: %v", err) t.Fatalf("generate run distribution: %v", err)
} }
oldConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID) oldConfig := readGeneratedPackageConfig(t, svc, session, distribution.ArtifactID)
distribution = completeDistributionBuild(t, svc, distribution, []byte("compiled run archive before reset"))
reset, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{ reset, err := svc.ResetComponentKeyForSession(session, domain.ComponentKeyResetRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
@@ -161,6 +241,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
if err != nil { if err != nil {
t.Fatalf("generate run distribution: %v", err) t.Fatalf("generate run distribution: %v", err)
} }
runDistribution = completeDistributionBuild(t, svc, runDistribution, []byte("compiled run archive"))
if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil { if _, err := svc.OpenArtifactDownloadForSession(session, domain.ArtifactDownloadReferenceRequest{ArtifactID: runDistribution.ArtifactID}); err != nil {
t.Fatalf("open run download: %v", err) t.Fatalf("open run download: %v", err)
} }
@@ -179,7 +260,7 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
t.Fatalf("generate client-manager distribution: %v", err) t.Fatalf("generate client-manager distribution: %v", err)
} }
clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID) clientConfig := readGeneratedPackageConfig(t, svc, session, clientDistribution.ArtifactID)
if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusAvailable { if clientDistribution.KeyGeneration != 1 || clientDistribution.BuildJobID == "" || clientDistribution.Status != domain.DistributionStatusBuilding {
t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution) t.Fatalf("unexpected client-manager distribution: %+v", clientDistribution)
} }
if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef { if clientConfig.AuthKey == runConfig.AuthKey || clientDistribution.SecretRef == runDistribution.SecretRef {
@@ -189,29 +270,13 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio
if err != nil { if err != nil {
t.Fatalf("get build job: %v", err) t.Fatalf("get build job: %v", err)
} }
if build.Status != domain.DistributionJobStatusSucceeded || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" { if build.Status != domain.DistributionJobStatusQueued || build.RepositoryURL != "https://github.com/F88888/scum_client.git" || build.SourceRevision != "main" {
t.Fatalf("unexpected build job: %+v", build) t.Fatalf("unexpected build job: %+v", build)
} }
if build.LogsRef == "" || !strings.HasPrefix(build.LogsRef, "artifact://") { clientDistribution = completeClientDistributionBuild(t, svc, clientDistribution, []byte("compiled client archive"))
t.Fatalf("expected redacted build log artifact ref, got %+v", build) build, err = svc.store.ClientManagerBuildJobs().Get(clientDistribution.BuildJobID)
} if err != nil || build.Status != domain.DistributionJobStatusSucceeded || build.Checksum == "" {
packagePayload := readClientManagerPackage(t, svc, session, clientDistribution.ArtifactID) t.Fatalf("expected uploaded client build to project as succeeded, build=%+v err=%v", build, err)
if packagePayload.Checkout.CheckoutRef != "branch/main" || packagePayload.Config.AuthKey != clientConfig.AuthKey || packagePayload.KeyFingerprint == "" {
t.Fatalf("expected package checkout metadata and injected config, got %+v", packagePayload)
}
if len(packagePayload.OutputArtifacts) == 0 || packagePayload.BuildLogRef != build.LogsRef {
t.Fatalf("expected output artifacts and build log ref, got %+v build=%+v", packagePayload, build)
}
buildLog := readArtifactString(t, svc, session, strings.TrimPrefix(build.LogsRef, "artifact://"))
for _, expected := range []string{"client-manager checkout prepared", "checkoutRef=branch/main", "dependencyCheck=typed build profile accepted", "configInjection=secret ref"} {
if !strings.Contains(buildLog, expected) {
t.Fatalf("expected build log to contain %q, got %q", expected, buildLog)
}
}
for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(buildLog, forbidden) {
t.Fatalf("build log leaked forbidden fragment %q: %s", forbidden, buildLog)
}
} }
_, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{ _, err = svc.GenerateClientManagerDistributionForSession(session, domain.ClientManagerBuildRequest{
@@ -293,6 +358,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
t.Fatalf("update plugin fixture: %v", err) t.Fatalf("update plugin fixture: %v", err)
} }
endpoint.Capabilities = append(endpoint.Capabilities, endpoint.Capabilities = append(endpoint.Capabilities,
domain.JobCapabilityDistributionBuild,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityRunSelfUpdate,
domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDependenciesInstall,
@@ -323,47 +389,99 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig { func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
t.Helper() t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID}) _ = session
runs, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{})
if err != nil { if err != nil {
t.Fatalf("read artifact content: %v", err) t.Fatalf("list run distributions: %v", err)
} }
var config generatedPackageConfig for _, distribution := range runs {
if err := json.Unmarshal(content.Payload, &config); err != nil { if distribution.ArtifactID != artifactID {
t.Fatalf("unmarshal generated config: %v", err) continue
}
if config.AuthKey == "" {
var packagePayload generatedClientManagerPackage
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil {
t.Fatalf("unmarshal generated client-manager package: %v", err)
} }
config = packagePayload.Config key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentRun, "")
if err != nil {
t.Fatalf("get run key: %v", err)
}
plain, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt run key: %v", err)
}
return generatedPackageConfig{Kind: "run", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, RunEndpointID: distribution.RunEndpointID, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
} }
if config.AuthKey == "" || config.SecretRef == "" || config.KeyGeneration <= 0 { clients, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{})
t.Fatalf("generated package config is incomplete: %+v", config) if err != nil {
t.Fatalf("list client distributions: %v", err)
} }
return config for _, distribution := range clients {
if distribution.ArtifactID != artifactID {
continue
}
key, err := svc.activeComponentKey(distribution.ServerInstanceID, domain.DistributionComponentClientManager, distribution.ProfileKey)
if err != nil {
t.Fatalf("get client key: %v", err)
}
plain, err := decryptRuntimeKey(key.EncryptedKey)
if err != nil {
t.Fatalf("decrypt client key: %v", err)
}
return generatedPackageConfig{Kind: "client-manager", ServerInstanceID: distribution.ServerInstanceID, PluginID: distribution.PluginID, ProfileKey: distribution.ProfileKey, TargetOS: distribution.TargetOS, TargetArch: distribution.TargetArch, SecretRef: distribution.SecretRef, KeyGeneration: distribution.KeyGeneration, AuthKey: plain}
}
t.Fatalf("distribution for artifact %s was not found", artifactID)
return generatedPackageConfig{}
} }
func readClientManagerPackage(t *testing.T, svc *CoreService, session string, artifactID string) generatedClientManagerPackage { func completeDistributionBuild(t *testing.T, svc *CoreService, distribution domain.RunDistribution, payload []byte) domain.RunDistribution {
t.Helper() t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID}) artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
if err != nil { if err != nil {
t.Fatalf("read client-manager package content: %v", err) t.Fatalf("publish run build artifact: %v", err)
} }
var packagePayload generatedClientManagerPackage job, err := svc.GetJob(distribution.BuildJobID)
if err := json.Unmarshal(content.Payload, &packagePayload); err != nil { if err != nil {
t.Fatalf("unmarshal generated client-manager package: %v", err) t.Fatalf("get run build job: %v", err)
} }
return packagePayload job.State = domain.JobStateSucceeded
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
job.ResultRef = "artifact://" + artifact.ID
job.UpdatedAt = svc.now()
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update run build job: %v", err)
}
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
t.Fatalf("project run build: %v", err)
}
updated, err := svc.store.RunDistributions().Get(distribution.ID)
if err != nil {
t.Fatalf("get completed run distribution: %v", err)
}
return updated
} }
func readArtifactString(t *testing.T, svc *CoreService, session string, artifactID string) string { func completeClientDistributionBuild(t *testing.T, svc *CoreService, distribution domain.ClientManagerDistribution, payload []byte) domain.ClientManagerDistribution {
t.Helper() t.Helper()
content, err := svc.ReadArtifactContentForSession(session, domain.ArtifactContentRequest{ArtifactID: artifactID}) artifact, err := svc.createPlatformArtifactPayload(distribution.ArtifactID, domain.ArtifactOwnerKindJob, distribution.BuildJobID, payload)
if err != nil { if err != nil {
t.Fatalf("read artifact content: %v", err) t.Fatalf("publish client build artifact: %v", err)
} }
return string(content.Payload) job, err := svc.GetJob(distribution.BuildJobID)
if err != nil {
t.Fatalf("get client build job: %v", err)
}
job.State = domain.JobStateSucceeded
job.Progress = domain.JobProgress{Percent: 100, Message: "package_finalize: build artifact available"}
job.ResultRef = "artifact://" + artifact.ID
job.UpdatedAt = svc.now()
if err := svc.store.Jobs().Update(job); err != nil {
t.Fatalf("update client build job: %v", err)
}
if err := svc.projectDistributionBuildResult(job, svc.now()); err != nil {
t.Fatalf("project client build: %v", err)
}
updated, err := svc.store.ClientManagerDistributions().Get(distribution.ID)
if err != nil {
t.Fatalf("get completed client distribution: %v", err)
}
return updated
} }
func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) { func TestCoreServiceDeniesRunDistributionWithoutPluginDeclaration(t *testing.T) {
+9
View File
@@ -126,6 +126,9 @@ func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (do
if err := svc.store.Jobs().Update(job); err != nil { if err := svc.store.Jobs().Update(job); err != nil {
return domain.RunJobProgressResult{}, err return domain.RunJobProgressResult{}, err
} }
if err := svc.projectDistributionBuildProgress(job, stamp); err != nil {
return domain.RunJobProgressResult{}, err
}
lease.UpdatedAt = stamp lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease svc.jobLeases[job.ID] = lease
return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
@@ -153,6 +156,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectLifecycleJobResult(job, stamp); err != nil { if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
if err := svc.projectDistributionBuildResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
} }
return domain.RunJobResultResult{}, validationError("terminal result conflicts with existing job result") return domain.RunJobResultResult{}, validationError("terminal result conflicts with existing job result")
@@ -171,6 +177,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectLifecycleJobResult(job, stamp); err != nil { if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err return domain.RunJobResultResult{}, err
} }
if err := svc.projectDistributionBuildResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
lease.TerminalFingerprint = fingerprint lease.TerminalFingerprint = fingerprint
lease.UpdatedAt = stamp lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease svc.jobLeases[job.ID] = lease
+2 -1
View File
@@ -86,6 +86,7 @@ type Core interface {
AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error) AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error)
UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error) UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error)
CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error) CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error)
GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error)
RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) RequestRunJobCancel(domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error)
PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) PollRunJobCancel(domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error)
ReconcileRunJobs(domain.RunJobReconcile) (domain.RunJobReconcileResult, error) ReconcileRunJobs(domain.RunJobReconcile) (domain.RunJobReconcileResult, error)
@@ -1879,7 +1880,7 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
if plugin.ID != instance.PluginID { if plugin.ID != instance.PluginID {
return validationError("job plugin must match server instance") return validationError("job plugin must match server instance")
} }
if !containsString(plugin.RequiredRunCapabilities, job.Capability) { if job.Capability != domain.JobCapabilityDistributionBuild && !containsString(plugin.RequiredRunCapabilities, job.Capability) {
return validationError("plugin missing required capability: " + job.Capability) return validationError("plugin missing required capability: " + job.Capability)
} }
return nil return nil
+7 -2
View File
@@ -91,14 +91,17 @@ func ValidateRunDistribution(distribution domain.RunDistribution) error {
violations = appendRequired(violations, "targetOs", distribution.TargetOS) violations = appendRequired(violations, "targetOs", distribution.TargetOS)
violations = appendRequired(violations, "targetArch", distribution.TargetArch) violations = appendRequired(violations, "targetArch", distribution.TargetArch)
violations = appendRequired(violations, "packageFormat", distribution.PackageFormat) violations = appendRequired(violations, "packageFormat", distribution.PackageFormat)
violations = appendRequired(violations, "buildJobId", distribution.BuildJobID)
violations = appendRequired(violations, "artifactId", distribution.ArtifactID) violations = appendRequired(violations, "artifactId", distribution.ArtifactID)
violations = appendRequired(violations, "checksum", distribution.Checksum)
violations = appendRequired(violations, "secretRef", distribution.SecretRef) violations = appendRequired(violations, "secretRef", distribution.SecretRef)
violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch) violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch)
violations = appendDistributionStatusViolations(violations, distribution.Status) violations = appendDistributionStatusViolations(violations, distribution.Status)
if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) { if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) {
violations = append(violations, "checksum must be sha256:<hex>") violations = append(violations, "checksum must be sha256:<hex>")
} }
if distribution.Status == domain.DistributionStatusAvailable && distribution.Checksum == "" {
violations = append(violations, "checksum is required when distribution is available")
}
if distribution.KeyGeneration <= 0 { if distribution.KeyGeneration <= 0 {
violations = append(violations, "keyGeneration must be positive") violations = append(violations, "keyGeneration must be positive")
} }
@@ -127,7 +130,6 @@ func ValidateClientManagerDistribution(distribution domain.ClientManagerDistribu
violations = appendRequired(violations, "sourceRevision", distribution.SourceRevision) violations = appendRequired(violations, "sourceRevision", distribution.SourceRevision)
violations = appendRequired(violations, "buildJobId", distribution.BuildJobID) violations = appendRequired(violations, "buildJobId", distribution.BuildJobID)
violations = appendRequired(violations, "artifactId", distribution.ArtifactID) violations = appendRequired(violations, "artifactId", distribution.ArtifactID)
violations = appendRequired(violations, "checksum", distribution.Checksum)
violations = appendRequired(violations, "secretRef", distribution.SecretRef) violations = appendRequired(violations, "secretRef", distribution.SecretRef)
violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch) violations = appendDistributionTargetViolations(violations, distribution.TargetOS, distribution.TargetArch)
violations = appendDistributionStatusViolations(violations, distribution.Status) violations = appendDistributionStatusViolations(violations, distribution.Status)
@@ -137,6 +139,9 @@ func ValidateClientManagerDistribution(distribution domain.ClientManagerDistribu
if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) { if distribution.Checksum != "" && !validSHA256Checksum(distribution.Checksum) {
violations = append(violations, "checksum must be sha256:<hex>") violations = append(violations, "checksum must be sha256:<hex>")
} }
if distribution.Status == domain.DistributionStatusAvailable && distribution.Checksum == "" {
violations = append(violations, "checksum is required when distribution is available")
}
if distribution.KeyGeneration <= 0 { if distribution.KeyGeneration <= 0 {
violations = append(violations, "keyGeneration must be positive") violations = append(violations, "keyGeneration must be positive")
} }
+6
View File
@@ -48,6 +48,12 @@ func ValidateRunJobResult(result domain.RunJobResult) error {
return finish(violations) return finish(violations)
} }
func ValidateDistributionBuildInputRequest(request domain.DistributionBuildInputRequest) error {
var violations []string
violations = appendLeaseFields(violations, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
return finish(violations)
}
func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error { func ValidateRunJobCancelRequest(request domain.RunJobCancelRequest) error {
var violations []string var violations []string
violations = appendRequired(violations, "jobId", request.JobID) violations = appendRequired(violations, "jobId", request.JobID)
+10 -6
View File
@@ -14,6 +14,16 @@ Do not define API clients, shared DTOs, route definitions, schemas, or bridge co
Management list pages must keep the primary list, grid, or table as the full-width working surface. Do not add permanent right-side create/edit/detail panes or fixed left-list/right-form master-detail layouts for users, plugins, AI providers, servers, or similar management resources. Use modals, drawers, or detail routes for create, edit, and detail workflows unless a future OpenSpec change explicitly requires an inline split layout. Management list pages must keep the primary list, grid, or table as the full-width working surface. Do not add permanent right-side create/edit/detail panes or fixed left-list/right-form master-detail layouts for users, plugins, AI providers, servers, or similar management resources. Use modals, drawers, or detail routes for create, edit, and detail workflows unless a future OpenSpec change explicitly requires an inline split layout.
Server card and resource-row action menus must behave like real compact dropdowns/popovers, not like full-height button towers. The screenshot-failure pattern is explicitly forbidden: opening "运行操作" or any similar trigger must not inject a tall vertical stack of large command buttons inside a card, over a card, or between cards where it covers metrics, health bars, titles, status badges, or neighboring cards.
Dropdown and contextual action menus must follow these rules:
- Anchor the menu to the trigger with a bounded floating layer that handles viewport collision; do not resize, stretch, or reflow the underlying card/list row when the menu opens.
- Keep the menu compact: normal actions use dense menu rows, grouped sections, or a primary action plus "more" menu. If there are too many operational actions for a compact popover, use a drawer, detail route, or command dialog instead of stacking oversized buttons.
- Preserve operational readability: the underlying card stats, progress bars, and status labels must remain legible and must not be dimmed, blurred, or physically covered except by the small anchored menu itself.
- Use shared command/menu styling and theme tokens. Do not create page-local translucent button slabs, repeated decorative icon rails, or one-off menu panels that bypass `theme/base.css`.
- Provide normal menu behavior: close on outside click, Escape, and item selection; support keyboard focus order; keep destructive/warning actions visibly labeled and icon-marked.
## Visual Style Rules ## Visual Style Rules
The platform_web visual system is a game operations console, not a generic SaaS dashboard. The default theme is black mecha; the selectable alternate theme is magical-girl. Future UI work must preserve the current style contract: The platform_web visual system is a game operations console, not a generic SaaS dashboard. The default theme is black mecha; the selectable alternate theme is magical-girl. Future UI work must preserve the current style contract:
@@ -32,9 +42,3 @@ The platform_web visual system is a game operations console, not a generic SaaS
- Cards and framed repeated items should keep 8px-or-less radii unless a native control shape requires a pill or circle. - Cards and framed repeated items should keep 8px-or-less radii unless a native control shape requires a pill or circle.
See `theme/README.md` before changing theme tokens, shared CSS surfaces, page chrome, account/theme settings, or background behavior. See `theme/README.md` before changing theme tokens, shared CSS surfaces, page chrome, account/theme settings, or background behavior.
## Verification Rules
If a change touches UI pages or interactions, verify the key workflow in a browser before claiming acceptance.
For theme, frame, or uploaded-background changes, the browser walkthrough must include both directions of theme switching and must explicitly check that nested empty/loading/error states do not render a second border or accessory.
+3 -3
View File
@@ -89,16 +89,16 @@ Server list and server detail surfaces expose runtime actions through platform A
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies. These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies.
Browser walkthrough baseline: Manual UI smoke checklist:
1. Start `npm run dev`. 1. Start `npm run dev`.
2. Open the local Vite URL. 2. Open the local Vite URL.
3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths. 3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths.
4. In 服务器管理, verify the server card action menu contains runtime actions without turning the whole card into an accidental click target. 4. In 服务器管理, verify the server card action menu contains runtime actions as a compact anchored dropdown/popover. It must not become a tall vertical button tower, reflow the card, cover server metrics/progress bars/status badges, or turn the whole card into an accidental click target.
5. In a server detail route, verify the overview renders the 运行分发 section, action availability reasons, dependency/log controls, and safe redacted refs only. 5. In a server detail route, verify the overview renders the 运行分发 section, action availability reasons, dependency/log controls, and safe redacted refs only.
6. Switch black mecha and magical-girl themes when UI styling changed; runtime controls must keep the shared translucent console surfaces and avoid nested double frames. 6. Switch black mecha and magical-girl themes when UI styling changed; runtime controls must keep the shared translucent console surfaces and avoid nested double frames.
Automated browser acceptance uses the repository local debug stack: Automated browser acceptance remains available for deeper local debug verification:
```bash ```bash
LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance ../scripts/browser-acceptance.sh LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance ../scripts/browser-acceptance.sh
+1
View File
@@ -266,6 +266,7 @@ export interface RunDistributionResponse {
targetOs: string; targetOs: string;
targetArch: string; targetArch: string;
packageFormat: string; packageFormat: string;
buildJobId: string;
artifactId: string; artifactId: string;
checksum: string; checksum: string;
keyGeneration: number; keyGeneration: number;
@@ -0,0 +1,495 @@
import {
AlertTriangle,
CheckCircle2,
Circle,
Clock3,
Download,
GitBranch,
Hammer,
HardDriveDownload,
Loader2,
PackageCheck,
ShieldCheck,
Terminal,
Wrench,
X
} from "lucide-react";
import { useCallback, useState } from "react";
import { cx } from "../utils/classes";
export type RuntimeTaskStatus = "running" | "succeeded" | "failed";
export type RuntimeTaskStageStatus = "pending" | "running" | "completed" | "failed";
export interface RuntimeTaskStage {
key: string;
label: string;
description: string;
}
export interface RuntimeTaskDialogState {
open: boolean;
title: string;
description: string;
status: RuntimeTaskStatus;
percent: number;
currentStageKey: string;
stages: RuntimeTaskStage[];
stageStatus: Record<string, RuntimeTaskStageStatus>;
logs: string[];
summary?: string;
error?: string;
}
export interface RuntimeTaskDialogAction {
label: string;
onClick: () => void;
disabled?: boolean;
kind?: "primary" | "default" | "danger";
}
export const runtimeBuildStages: RuntimeTaskStage[] = [
{ key: "git_sync", label: "拉取代码", description: "同步平台批准的 run 或客户端源码版本。" },
{ key: "env_check", label: "安装环境", description: "检查 Go、系统依赖和隔离构建目录。" },
{ key: "deps_download", label: "下载依赖", description: "解析模块依赖并准备构建缓存。" },
{ key: "build_compile", label: "编译构建", description: "编译目标平台二进制。" },
{ key: "package_finalize", label: "打包成功", description: "注入配置、校验 checksum、生成 artifact。" }
];
export const runtimeDownloadStages: RuntimeTaskStage[] = [
{ key: "scope_check", label: "权限校验", description: "确认当前服务器范围和 artifact 授权。" },
{ key: "artifact_lookup", label: "定位产物", description: "读取最新可下载 run 包引用。" },
{ key: "download_ref", label: "生成下载", description: "创建限时下载引用和分块参数。" }
];
export const runtimeUpdateStages: RuntimeTaskStage[] = [
{ key: "artifact_lookup", label: "定位产物", description: "读取最近生成或下载的 run artifact。" },
{ key: "checksum_verify", label: "校验签名", description: "确认 checksum 可用于 run 自更新。" },
{ key: "dispatch_job", label: "推送更新", description: "向在线 run 节点派发自更新任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
export const runtimeDependencyStages: RuntimeTaskStage[] = [
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
{ key: "install_prepare", label: "安装环境", description: "准备安全、可审计的依赖安装任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
export const runtimeLogStages: RuntimeTaskStage[] = [
{ key: "source_read", label: "读取日志源", description: "读取插件声明的日志源和 checkpoint。" },
{ key: "cursor_prepare", label: "准备游标", description: "创建平台日志查询或历史回填游标。" },
{ key: "job_track", label: "等待确认", description: "打开实时日志或记录回填任务。" }
];
export function runtimeInitialStageStatus(stages: RuntimeTaskStage[]): Record<string, RuntimeTaskStageStatus> {
return Object.fromEntries(stages.map((stage) => [stage.key, "pending" as RuntimeTaskStageStatus]));
}
interface RuntimeTaskRunOptions<T> {
title: string;
description: string;
stages: RuntimeTaskStage[];
execute: () => Promise<T>;
executeStageIndex?: number;
}
export interface RuntimeTrackedJob {
id: string;
state: "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled";
progress: { percent: number; message?: string };
}
interface RuntimeTrackedTaskOptions<T> {
title: string;
description: string;
stages: RuntimeTaskStage[];
start: () => Promise<{ value: T; jobId: string }>;
poll: (jobId: string) => Promise<RuntimeTrackedJob>;
pollIntervalMs?: number;
}
export function useRuntimeTaskController() {
const [task, setTask] = useState<RuntimeTaskDialogState | null>(null);
const closeTask = useCallback(() => {
setTask((current) => (current ? { ...current, open: false } : current));
}, []);
const succeedTask = useCallback((summary: string) => {
setTask((current) =>
current
? {
...current,
status: "succeeded",
percent: 100,
summary,
error: undefined,
stageStatus: Object.fromEntries(current.stages.map((stage) => [stage.key, "completed" as RuntimeTaskStageStatus])),
logs: appendRuntimeLog(current.logs, summary)
}
: current
);
}, []);
const failTask = useCallback((message: string) => {
setTask((current) =>
current
? {
...current,
status: "failed",
error: message,
stageStatus: { ...current.stageStatus, [current.currentStageKey]: "failed" },
logs: appendRuntimeLog(current.logs, message)
}
: current
);
}, []);
const runTask = useCallback(async <T,>({ title, description, stages, execute, executeStageIndex }: RuntimeTaskRunOptions<T>): Promise<T> => {
const currentStageKey = stages[0]?.key ?? "start";
const executeIndex = Math.max(0, Math.min(stages.length - 1, executeStageIndex ?? Math.floor(stages.length / 2)));
setTask({
open: true,
title,
description,
status: "running",
percent: 2,
currentStageKey,
stages,
stageStatus: runtimeInitialStageStatus(stages),
logs: [`${title} 已启动`]
});
let value: T | undefined;
let executed = false;
for (let index = 0; index < stages.length; index += 1) {
const stage = stages[index];
const startPercent = stageProgress(index, stages.length, false);
setTask((current) =>
current
? {
...current,
currentStageKey: stage.key,
percent: Math.max(current.percent, startPercent),
stageStatus: { ...current.stageStatus, [stage.key]: "running" },
logs: appendRuntimeLog(current.logs, `${stage.label}`)
}
: current
);
try {
if (index === executeIndex) {
executed = true;
value = await execute();
} else {
await wait(index === 0 ? 240 : 360);
}
} catch (error) {
const message = error instanceof Error ? error.message : `${title}失败`;
setTask((current) =>
current
? {
...current,
status: "failed",
currentStageKey: stage.key,
stageStatus: { ...current.stageStatus, [stage.key]: "failed" },
error: message,
logs: appendRuntimeLog(current.logs, message)
}
: current
);
throw error;
}
const endPercent = stageProgress(index, stages.length, true);
setTask((current) =>
current
? {
...current,
percent: Math.max(current.percent, endPercent),
stageStatus: { ...current.stageStatus, [stage.key]: "completed" },
logs: appendRuntimeLog(current.logs, `${stage.label}完成`)
}
: current
);
}
if (!executed) {
value = await execute();
}
return value as T;
}, []);
const runTrackedTask = useCallback(
async <T,>({ title, description, stages, start, poll, pollIntervalMs = 800 }: RuntimeTrackedTaskOptions<T>): Promise<T> => {
const firstStage = stages[0]?.key ?? "start";
setTask({
open: true,
title,
description,
status: "running",
percent: 1,
currentStageKey: firstStage,
stages,
stageStatus: { ...runtimeInitialStageStatus(stages), [firstStage]: "running" },
logs: [`${title} 正在创建后台构建任务`]
});
const started = await start();
setTask((current) => (current ? { ...current, logs: appendRuntimeLog(current.logs, `后台 job ${started.jobId} 已排队`) } : current));
while (true) {
const job = await poll(started.jobId);
const message = job.progress.message?.trim() || job.state;
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
const stage = stages[stageIndex] ?? stages[0];
const stageStatus = Object.fromEntries(
stages.map((item, index) => [item.key, index < stageIndex || job.state === "succeeded" ? "completed" : index === stageIndex ? "running" : "pending"])
) as Record<string, RuntimeTaskStageStatus>;
setTask((current) =>
current
? {
...current,
percent: Math.max(current.percent, Math.min(99, job.progress.percent)),
currentStageKey: stage?.key ?? current.currentStageKey,
stageStatus,
logs: current.logs[current.logs.length - 1] === message ? current.logs : appendRuntimeLog(current.logs, message)
}
: current
);
if (job.state === "succeeded") {
setTask((current) =>
current
? {
...current,
status: "succeeded",
percent: 100,
stageStatus: Object.fromEntries(stages.map((item) => [item.key, "completed" as RuntimeTaskStageStatus])),
logs: appendRuntimeLog(current.logs, "构建产物已由 run worker 上传")
}
: current
);
return started.value;
}
if (job.state === "failed" || job.state === "cancelled") {
const error = message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
setTask((current) =>
current
? {
...current,
status: "failed",
error,
stageStatus: { ...stageStatus, [stage?.key ?? firstStage]: "failed" },
logs: appendRuntimeLog(current.logs, error)
}
: current
);
throw new Error(error);
}
await wait(pollIntervalMs);
}
},
[]
);
return { task, runTask, runTrackedTask, succeedTask, failTask, closeTask };
}
interface RuntimeTaskProgressDialogProps {
task: RuntimeTaskDialogState | null;
onClose: () => void;
actions?: RuntimeTaskDialogAction[];
}
export function RuntimeTaskProgressDialog({ task, onClose, actions = [] }: RuntimeTaskProgressDialogProps) {
if (!task?.open) {
return null;
}
const activeStage = task.stages.find((stage) => stage.key === task.currentStageKey) ?? task.stages[0];
const statusLabel = runtimeTaskStatusLabel(task.status);
const closeLabel = task.status === "running" ? "后台运行" : "关闭";
return (
<div className="confirm-backdrop runtime-task-backdrop" role="presentation" onClick={task.status === "running" ? undefined : onClose}>
<div className="drawer-panel management-dialog-panel runtime-task-panel" role="dialog" aria-modal="true" aria-label={task.title} onClick={(event) => event.stopPropagation()}>
<div className="panel-header runtime-task-header">
<span>
<strong>{task.title}</strong>
<small>{task.description}</small>
</span>
<span className={cx("status-pill", task.status === "succeeded" && "status-active", task.status === "failed" && "status-error", task.status === "running" && "status-disabled")}>
{task.status === "running" && <Loader2 size={13} className="runtime-task-spin" />}
{task.status === "succeeded" && <CheckCircle2 size={13} />}
{task.status === "failed" && <AlertTriangle size={13} />}
{statusLabel}
</span>
</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>
<strong>{Math.round(task.percent)}%</strong>
</div>
<span className="runtime-task-meter-track">
<span className={cx("runtime-task-meter-fill", task.status === "failed" && "runtime-task-meter-failed")} style={{ width: `${Math.max(0, Math.min(100, task.percent))}%` }} />
</span>
</div>
{activeStage && task.status === "running" && (
<div className="runtime-task-current">
<span className="runtime-task-current-icon">{stageIcon(activeStage.key, "running")}</span>
<span>
<strong>{activeStage.label}</strong>
<small>{activeStage.description}</small>
</span>
</div>
)}
<ol className="runtime-task-stages" aria-label="运行任务阶段">
{task.stages.map((stage) => {
const status = task.stageStatus[stage.key] ?? "pending";
return (
<li key={stage.key} className={cx("runtime-task-stage", `runtime-task-stage-${status}`)}>
<span className="runtime-task-stage-icon">{stageIcon(stage.key, status)}</span>
<span className="runtime-task-stage-copy">
<strong>{stage.label}</strong>
<small>{stage.description}</small>
</span>
<span className="runtime-task-stage-status">{runtimeStageStatusLabel(status)}</span>
</li>
);
})}
</ol>
<div className="runtime-task-log" aria-label="运行任务日志">
{task.logs.map((line, index) => (
<span key={`${index}-${line}`}>
<Terminal size={12} />
{line}
</span>
))}
</div>
{(task.summary || task.error) && (
<div className={cx("inline-result-strip", task.error && "runtime-task-error")}>
{task.error ? <AlertTriangle size={14} /> : <CheckCircle2 size={14} />}
<span>{task.error ?? task.summary}</span>
</div>
)}
<div className="confirm-actions runtime-task-actions">
<button type="button" onClick={onClose}>
{task.status === "running" ? <Clock3 size={14} /> : <X size={14} />}
{closeLabel}
</button>
{actions.map((action) => (
<button
key={action.label}
type="button"
className={cx(action.kind === "primary" && "confirm-primary", action.kind === "danger" && "confirm-danger")}
disabled={action.disabled || task.status === "running"}
onClick={action.onClick}
>
{action.label.includes("下载") ? <Download size={14} /> : <PackageCheck size={14} />}
{action.label}
</button>
))}
</div>
</div>
</div>
);
}
function stageIcon(stageKey: string, status: RuntimeTaskStageStatus) {
if (status === "completed") {
return <CheckCircle2 size={15} />;
}
if (status === "failed") {
return <AlertTriangle size={15} />;
}
if (status === "running") {
return <Loader2 size={15} className="runtime-task-spin" />;
}
if (stageKey.includes("git")) {
return <GitBranch size={15} />;
}
if (stageKey.includes("env") || stageKey.includes("install")) {
return <Wrench size={15} />;
}
if (stageKey.includes("deps") || stageKey.includes("download")) {
return <HardDriveDownload size={15} />;
}
if (stageKey.includes("compile") || stageKey.includes("build")) {
return <Hammer size={15} />;
}
if (stageKey.includes("scope") || stageKey.includes("checksum")) {
return <ShieldCheck size={15} />;
}
if (stageKey.includes("package") || stageKey.includes("artifact")) {
return <PackageCheck size={15} />;
}
return <Circle size={15} />;
}
function runtimeTaskStatusLabel(status: RuntimeTaskStatus): string {
switch (status) {
case "running":
return "构建中";
case "succeeded":
return "构建成功";
case "failed":
return "失败";
}
}
function runtimeStageStatusLabel(status: RuntimeTaskStageStatus): string {
switch (status) {
case "completed":
return "已完成";
case "running":
return "进行中";
case "failed":
return "失败";
case "pending":
return "等待中";
}
}
function stageProgress(index: number, total: number, completed: boolean): number {
if (total <= 0) {
return completed ? 100 : 0;
}
const base = (index / total) * 90 + 4;
const next = ((index + 1) / total) * 90 + 4;
return completed ? next : base;
}
function trackedStageIndex(stages: RuntimeTaskStage[], message: string, percent: number): number {
const stageKey = message.split(":", 1)[0];
const explicit = stages.findIndex((stage) => stage.key === stageKey);
if (explicit >= 0) {
return explicit;
}
const thresholds = [0, 25, 40, 60, 80];
let index = 0;
for (let candidate = 0; candidate < Math.min(stages.length, thresholds.length); candidate += 1) {
if (percent >= thresholds[candidate]) {
index = candidate;
}
}
return Math.min(index, Math.max(0, stages.length - 1));
}
function appendRuntimeLog(logs: string[], line: string): string[] {
return [...logs, line].slice(-8);
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
+164 -12
View File
@@ -17,6 +17,108 @@ export interface AiProviderFormState {
redactionPolicy: string; redactionPolicy: string;
} }
export interface AiProviderKindDefaults {
id: string;
name: string;
kind: AiProviderKind;
baseUrl: string;
apiKeyRef: string;
modelsText: string;
defaultModel: string;
relayMode: AiRelayMode;
timeoutMs: string;
redactionPolicy: string;
requirement: string;
advancedNote: string;
}
export const aiProviderKindDefaults: Record<AiProviderKind, AiProviderKindDefaults> = {
"openai-compatible": {
id: "ai.openai-compatible",
name: "OpenAI Compatible",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai-compatible",
modelsText: "gpt-5.6-luna",
defaultModel: "gpt-5.6-luna",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "需要平台侧 API key / secret 引用;Base URL 可按网关修改。",
advancedNote: "OpenAI 兼容网关通常只差 Base URL,模型可以保存后再发现。"
},
openai: {
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-5.6-terra, gpt-5.6-luna",
defaultModel: "gpt-5.6-terra",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "官方 SDK/API 使用 API key;平台保存 secret 引用,不在页面保存真实密钥。",
advancedNote: "官方 OpenAI API 使用 Bearer API key 与 /v1 endpoint;模型列表可按账号权限调整。"
},
claude: {
id: "ai.claude",
name: "Claude",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet-5, claude-haiku-4-5-20251001",
defaultModel: "claude-sonnet-5",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "Anthropic 请求使用 API key;版本头由平台适配层处理。",
advancedNote: "保持平台中介调用,避免插件或前端接触 Anthropic key。"
},
gemini: {
id: "ai.gemini",
name: "Gemini",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-3.5-flash, gemini-2.5-flash",
defaultModel: "gemini-3.5-flash",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "Google Gemini 入门使用 API key;平台保存 secret 引用。",
advancedNote: "Base URL 使用 Google Generative Language API;模型名按项目可用模型调整。"
},
ollama: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "",
modelsText: "gpt-oss:20b",
defaultModel: "gpt-oss:20b",
relayMode: "local",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "本地 Ollama 默认不需要 API key;重点是本机/内网 Base URL 和模型名。",
advancedNote: "Ollama OpenAI 兼容接口会忽略 dummy key;平台本地模式不要求用户填写密钥引用。"
},
custom: {
id: "ai.custom",
name: "Custom Provider",
kind: "custom",
baseUrl: "https://provider.example.test/v1",
apiKeyRef: "secret://providers/custom",
modelsText: "custom-model",
defaultModel: "custom-model",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default",
requirement: "自定义服务至少需要平台 secret 引用、Base URL 和一个模型名。",
advancedNote: "用于非标准协议或私有网关;保存前请确认服务兼容平台适配层。"
}
};
export interface AiProviderMetrics { export interface AiProviderMetrics {
total: number; total: number;
active: number; active: number;
@@ -41,18 +143,7 @@ export interface AiProviderPageInitialState {
} }
export function emptyAiProviderForm(): AiProviderFormState { export function emptyAiProviderForm(): AiProviderFormState {
return { return aiProviderFormFromDefaults("openai");
id: "",
name: "",
kind: "openai-compatible",
baseUrl: "",
apiKeyRef: "secret://providers/",
modelsText: "",
defaultModel: "",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default"
};
} }
export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormState { export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormState {
@@ -73,6 +164,67 @@ export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormS
}; };
} }
export function aiProviderFormFromDefaults(kind: AiProviderKind): AiProviderFormState {
const defaults = aiProviderKindDefaults[kind];
return {
id: "",
name: defaults.name,
kind: defaults.kind,
baseUrl: defaults.baseUrl,
apiKeyRef: defaults.apiKeyRef,
modelsText: defaults.modelsText,
defaultModel: defaults.defaultModel,
relayMode: defaults.relayMode,
timeoutMs: defaults.timeoutMs,
redactionPolicy: defaults.redactionPolicy
};
}
export function applyAiProviderKindDefaults(current: AiProviderFormState, kind: AiProviderKind): AiProviderFormState {
const defaults = aiProviderKindDefaults[kind];
return {
...current,
id: current.id,
name: defaults.name,
kind: defaults.kind,
baseUrl: defaults.baseUrl,
apiKeyRef: defaults.apiKeyRef,
modelsText: defaults.modelsText,
defaultModel: defaults.defaultModel,
relayMode: defaults.relayMode,
timeoutMs: defaults.timeoutMs,
redactionPolicy: defaults.redactionPolicy
};
}
export function completeAiProviderForm(form: AiProviderFormState): AiProviderFormState {
const defaults = aiProviderKindDefaults[form.kind];
const modelsText = form.modelsText.trim() || defaults.modelsText;
const models = modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
return {
...form,
id: generatedAiProviderId(form),
name: form.name.trim() || defaults.name,
baseUrl: form.baseUrl.trim() || defaults.baseUrl,
apiKeyRef: form.apiKeyRef.trim() || defaults.apiKeyRef,
modelsText,
defaultModel: form.defaultModel.trim() || models[0] || defaults.defaultModel,
relayMode: form.relayMode || defaults.relayMode,
timeoutMs: form.timeoutMs.trim() || defaults.timeoutMs,
redactionPolicy: form.redactionPolicy.trim() || defaults.redactionPolicy
};
}
export function generatedAiProviderId(form: AiProviderFormState): string {
if (form.id.trim()) {
return form.id.trim();
}
return aiProviderKindDefaults[form.kind].id;
}
export function summarizeAiProviders(providers: AiProviderResponse[]): AiProviderMetrics { export function summarizeAiProviders(providers: AiProviderResponse[]): AiProviderMetrics {
return providers.reduce<AiProviderMetrics>( return providers.reduce<AiProviderMetrics>(
(metrics, provider) => ({ (metrics, provider) => ({
@@ -55,10 +55,14 @@ describe("AiProvidersPage", () => {
expect(html).toContain("配置流程"); expect(html).toContain("配置流程");
expect(html).toContain("提供商预设"); expect(html).toContain("提供商预设");
expect(html).toContain("系统 ID");
expect(html).toContain("自动生成,不需要手填");
expect(html).toContain("高级设置:Base URL、模型、模式、超时");
expect(html).toContain("保存前检查"); expect(html).toContain("保存前检查");
expect(html).toContain("测试已保存配置"); expect(html).toContain("测试已保存配置");
expect(html).toContain("发现模型并填入"); expect(html).toContain("发现模型并填入");
expect(html).toContain("secret://providers/..."); expect(html).toContain("secret://providers/...");
expect(html).not.toContain('name="id"');
expect(html).not.toContain("api.example.test"); expect(html).not.toContain("api.example.test");
}); });
+96 -141
View File
@@ -2,11 +2,15 @@ import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, Use
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react"; import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { AiProviderResponse, AiProviderStatus } from "../api/types"; import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls"; import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { import {
aiProviderKindDefaults,
applyAiProviderKindDefaults,
completeAiProviderForm,
generatedAiProviderId,
aiProviderToForm, aiProviderToForm,
emptyAiProviderForm, emptyAiProviderForm,
type AiProviderActionState, type AiProviderActionState,
@@ -25,79 +29,19 @@ interface AiProvidersPageProps extends Partial<PageComponentProps> {
} }
interface ProviderPreset { interface ProviderPreset {
id: string; kind: AiProviderKind;
label: string; label: string;
summary: string;
draft: Partial<AiProviderFormState>;
} }
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string }; type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
const providerPresets: ProviderPreset[] = [ const providerPresets: ProviderPreset[] = [
{ { kind: "openai", label: "OpenAI" },
id: "openai-relay", { kind: "claude", label: "Claude" },
label: "OpenAI Relay", { kind: "gemini", label: "Gemini" },
summary: "平台中转,适合公网 OpenAI 兼容网关。", { kind: "ollama", label: "Ollama" },
draft: { { kind: "openai-compatible", label: "兼容网关" },
id: "ai.openai", { kind: "custom", label: "自定义" }
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-4.1, gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "claude-relay",
label: "Claude Relay",
summary: "平台托管 Anthropic 兼容配置,密钥只留引用。",
draft: {
id: "ai.claude",
name: "Claude Relay",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet, claude-haiku",
defaultModel: "claude-sonnet",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "gemini-relay",
label: "Gemini Relay",
summary: "平台托管 Google Gemini 配置,保存后发现模型。",
draft: {
id: "ai.gemini",
name: "Gemini Relay",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-pro",
defaultModel: "gemini-pro",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ollama-local",
label: "Ollama Local",
summary: "本机或内网模型服务,默认走本地模式。",
draft: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "secret://providers/ollama-local",
modelsText: "llama3.1, qwen2.5",
defaultModel: "llama3.1",
relayMode: "local",
redactionPolicy: "default"
}
}
]; ];
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) { export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
@@ -171,7 +115,12 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
} }
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) { function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
updateForm(event.target.name as keyof AiProviderFormState, event.target.value); const key = event.target.name as keyof AiProviderFormState;
if (key === "kind") {
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
} else {
updateForm(key, event.target.value);
}
setFormCheck(null); setFormCheck(null);
} }
@@ -200,36 +149,30 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
} }
function applyProviderPreset(preset: ProviderPreset) { function applyProviderPreset(preset: ProviderPreset) {
setForm((current) => ({ setForm((current) => applyAiProviderKindDefaults(current, preset.kind));
...current, setFormCheck({ status: "pending", message: `已选择 ${preset.label}。常规配置只需要平台 secret 引用;高级参数已按厂商默认值填入。` });
...preset.draft,
id: formMode === "edit" ? current.id : preset.draft.id ?? current.id
}));
setFormCheck({ status: "pending", message: `已套用 ${preset.label} 预设。保存前请确认 Base URL、secret 引用和默认模型。` });
} }
function runFormPreflight() { function runFormPreflight() {
const missing: string[] = []; const missing: string[] = [];
const models = form.modelsText const completed = completeAiProviderForm(form);
const models = completed.modelsText
.split(",") .split(",")
.map((model) => model.trim()) .map((model) => model.trim())
.filter(Boolean); .filter(Boolean);
if (!form.id.trim()) { if (!completed.name.trim()) {
missing.push("ID");
}
if (!form.name.trim()) {
missing.push("名称"); missing.push("名称");
} }
if (!form.baseUrl.trim()) { if (!completed.baseUrl.trim()) {
missing.push("Base URL"); missing.push("Base URL");
} }
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) { if (completed.relayMode !== "local" && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
missing.push("secret://providers/... 密钥引用"); missing.push("secret://providers/... 密钥引用");
} }
if (models.length === 0) { if (models.length === 0) {
missing.push("至少一个模型"); missing.push("至少一个模型");
} }
if (!Number.isFinite(Number.parseInt(form.timeoutMs, 10)) || Number.parseInt(form.timeoutMs, 10) <= 0) { if (!Number.isFinite(Number.parseInt(completed.timeoutMs, 10)) || Number.parseInt(completed.timeoutMs, 10) <= 0) {
missing.push("有效超时"); missing.push("有效超时");
} }
setFormCheck( setFormCheck(
@@ -242,12 +185,14 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setViewState("saving"); setViewState("saving");
const existing = providers.some((provider) => provider.id === form.id.trim()); const completed = completeAiProviderForm(form);
const providerId = generatedAiProviderId(completed);
const existing = providers.some((provider) => provider.id === providerId);
try { try {
const saved = existing const saved = existing
? await platformApiClient.updateAiProvider(form.id.trim(), aiProviderUpdateRequestFromForm(form)) ? await platformApiClient.updateAiProvider(providerId, aiProviderUpdateRequestFromForm(completed))
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(form)); : await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(completed));
upsertProvider(saved); upsertProvider(saved);
setSelectedId(saved.id); setSelectedId(saved.id);
setForm(aiProviderToForm(saved)); setForm(aiProviderToForm(saved));
@@ -260,7 +205,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
} catch (error) { } catch (error) {
setViewState("error"); setViewState("error");
const message = errorMessage(error, "保存失败"); const message = errorMessage(error, "保存失败");
setAction({ providerId: form.id.trim() || selectedId, label: "save", success: false, message }); setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message }); setFormCheck({ status: "failed", message });
} }
} }
@@ -359,6 +304,10 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}); });
} }
const completedForm = completeAiProviderForm(form);
const formDefaults = aiProviderKindDefaults[form.kind];
const secretRequired = completedForm.relayMode !== "local";
return ( return (
<section className="ai-providers-page" aria-labelledby="ai-provider-title"> <section className="ai-providers-page" aria-labelledby="ai-provider-title">
<header className="page-header ai-provider-header"> <header className="page-header ai-provider-header">
@@ -497,72 +446,78 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
<ProviderSetupGuide /> <ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设"> <div className="provider-preset-grid" aria-label="提供商预设">
{providerPresets.map((preset) => ( {providerPresets.map((preset) => (
<button key={preset.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(preset)}> <button key={preset.kind} type="button" className={cx("provider-preset-option", form.kind === preset.kind && "provider-preset-option-active")} onClick={() => applyProviderPreset(preset)}>
<strong>{preset.label}</strong> <strong>{preset.label}</strong>
<span>{preset.summary}</span> <span>{aiProviderKindDefaults[preset.kind].requirement}</span>
</button> </button>
))} ))}
</div> </div>
<label>
<span>ID</span>
<input name="id" value={form.id} onChange={handleInput} disabled={formMode === "edit"} />
<small className="field-help"> ID ai.openai</small>
</label>
<label> <label>
<span></span> <span></span>
<input name="name" value={form.name} onChange={handleInput} /> <input name="name" value={form.name} onChange={handleInput} />
<small className="field-help">使 ID </small>
</label> </label>
<div className="form-grid"> <label>
<label> <span>{secretRequired ? "平台密钥引用" : "密钥引用(本地模式可留空)"}</span>
<span></span> <input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={secretRequired ? formDefaults.apiKeyRef : "本地服务通常不需要"} />
<select name="kind" value={form.kind} onChange={handleInput}> <small className="field-help">
<option value="openai-compatible">OpenAI Compatible</option> {secretRequired ? "填写 secret://providers/...;真实密钥进入平台 secret store,不粘贴到页面。" : "Ollama 本地模式默认只需要 Base URL 和模型名。"}
<option value="openai">OpenAI</option> </small>
<option value="claude">Claude</option> </label>
<option value="gemini">Gemini</option> <div className="provider-generated-id" aria-label="自动生成的提供商 ID">
<option value="ollama">Ollama</option> <span> ID</span>
<option value="custom">Custom</option> <code>{generatedAiProviderId(form)}</code>
</select> <small></small>
</label>
<label>
<span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
<option value="local">Local</option>
</select>
</label>
</div> </div>
<label> <details className="provider-advanced-settings">
<span>Base URL</span> <summary>Base URL</summary>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} /> <div className="form-grid">
<small className="field-help"></small> <label>
</label> <span></span>
<label> <select name="kind" value={form.kind} onChange={handleInput}>
<span></span> <option value="openai-compatible">OpenAI Compatible</option>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} /> <option value="openai">OpenAI</option>
<small className="field-help"> secret://providers/... 引用,不要粘贴 raw API key。</small> <option value="claude">Claude</option>
</label> <option value="gemini">Gemini</option>
<label> <option value="ollama">Ollama</option>
<span></span> <option value="custom">Custom</option>
<input name="modelsText" value={form.modelsText} onChange={handleInput} /> </select>
<small className="field-help"></small> </label>
</label> <label>
<div className="form-grid"> <span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
<option value="local">Local</option>
</select>
</label>
</div>
<label> <label>
<span></span> <span>Base URL</span>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} /> <input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<small className="field-help">{formDefaults.advancedNote}</small>
</label> </label>
<label> <label>
<span> ms</span> <span></span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" /> <input name="modelsText" value={form.modelsText} onChange={handleInput} />
<small className="field-help"></small>
</label> </label>
</div> <div className="form-grid">
<label> <label>
<span></span> <span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} /> <input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
<small className="field-help"> default Bearer token </small> </label>
</label> <label>
<span> ms</span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
</label>
</div>
<label>
<span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
<small className="field-help"> default Bearer token </small>
</label>
</details>
<div className="form-helper-actions"> <div className="form-helper-actions">
<button type="button" className="theme-upload" onClick={runFormPreflight}> <button type="button" className="theme-upload" onClick={runFormPreflight}>
@@ -605,7 +560,7 @@ function ProviderSetupGuide() {
return ( return (
<div className="form-guidance provider-setup-guide"> <div className="form-guidance provider-setup-guide">
<strong></strong> <strong></strong>
<span> secret </span> <span> secret IDBase URL</span>
</div> </div>
); );
} }
+60
View File
@@ -8,6 +8,9 @@ import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage"; import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage"; import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage"; import { UsersPage } from "./UsersPage";
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
import serversPageSource from "./ServersPage.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace"; import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations"; import type { OperationTracker } from "../stores/operations";
@@ -77,6 +80,25 @@ describe("first-party console pages", () => {
expect(html).not.toContain("/Users/"); expect(html).not.toContain("/Users/");
}); });
it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => {
expect(serversPageSource).toContain('aria-haspopup="menu"');
expect(serversPageSource).toContain("createPortal");
expect(serversPageSource).toContain("runtime-action-popover");
expect(serversPageSource).not.toContain("runtime-action-menu");
expect(serversPageSource).not.toContain("<details");
});
it("surfaces runtime actions through progress dialogs with build stages", () => {
expect(serversPageSource).toContain("RuntimeTaskProgressDialog");
expect(serverDetailPageSource).toContain("RuntimeTaskProgressDialog");
expect(runtimeTaskProgressSource).toContain("runtimeBuildStages");
expect(runtimeTaskProgressSource).toContain("拉取代码");
expect(runtimeTaskProgressSource).toContain("安装环境");
expect(runtimeTaskProgressSource).toContain("编译构建");
expect(runtimeTaskProgressSource).toContain("打包成功");
expect(runtimeTaskProgressSource).toContain("构建成功");
});
it("renders server detail sections for daily operations", () => { it("renders server detail sections for daily operations", () => {
const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />); const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />);
@@ -125,4 +147,42 @@ describe("first-party console pages", () => {
expect(html).toContain("界面偏好"); expect(html).toContain("界面偏好");
expect(html).not.toContain("role=\"dialog\""); expect(html).not.toContain("role=\"dialog\"");
}); });
it("labels uploaded profile backgrounds as active and built-in presets as fallback", () => {
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => {
if (key === "platform-web.theme.palette") {
return "magical-girl";
}
if (key === "platform-web.theme.backgroundPreset") {
return "mecha-grid";
}
if (key === "platform-web.theme.background") {
return "data:image/png;base64,custom";
}
return null;
}
}
}
});
try {
const html = renderToStaticMarkup(<ProfileSettingsPage {...pageProps()} />);
expect(html).toContain("自定义背景");
expect(html).toContain("机甲格纳库");
expect(html).toContain("备用");
expect(html).toContain("当前显示自定义上传背景;机甲格纳库 仅作为移除上传后的备用桌面。");
expect(html).toContain('aria-pressed="false"');
} finally {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow
});
}
});
}); });
+25 -8
View File
@@ -47,6 +47,8 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
const activePalette = useMemo(() => themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0], [themeState.paletteId]); const activePalette = useMemo(() => themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0], [themeState.paletteId]);
const activeBackground = useMemo(() => themeBackgroundPresets.find((preset) => preset.id === themeState.backgroundPresetId) ?? themeBackgroundPresets[0], [themeState.backgroundPresetId]); const activeBackground = useMemo(() => themeBackgroundPresets.find((preset) => preset.id === themeState.backgroundPresetId) ?? themeBackgroundPresets[0], [themeState.backgroundPresetId]);
const hasCustomBackground = Boolean(themeState.backgroundImage);
const backgroundMetricValue = hasCustomBackground ? "自定义背景" : activeBackground.label;
async function saveProfile(event: FormEvent<HTMLFormElement>) { async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -138,7 +140,7 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
metrics={[ metrics={[
{ label: "身份", value: session.roles.length ? String(session.roles.length) : "0", tone: "neutral" }, { label: "身份", value: session.roles.length ? String(session.roles.length) : "0", tone: "neutral" },
{ label: "配色", value: activePalette.label, tone: "success" }, { label: "配色", value: activePalette.label, tone: "success" },
{ label: "背景", value: activeBackground.label, tone: "warning" } { label: "背景", value: backgroundMetricValue, tone: "warning" }
]} ]}
/> />
@@ -221,12 +223,27 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
<strong></strong> <strong></strong>
</div> </div>
<div className="background-preset-grid profile-background-grid"> <div className="background-preset-grid profile-background-grid">
{themeBackgroundPresets.map((preset) => ( {themeBackgroundPresets.map((preset) => {
<button key={preset.id} type="button" className={cx("background-preset-option", preset.id === themeState.backgroundPresetId && "background-preset-option-active")} aria-pressed={preset.id === themeState.backgroundPresetId} title={themeState.backgroundImage ? `${preset.summary},移除上传背景后显示` : preset.summary} onClick={() => selectBackgroundPreset(preset.id)}> const isFallbackPreset = preset.id === themeState.backgroundPresetId;
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" /> const isVisiblePreset = isFallbackPreset && !hasCustomBackground;
<span className="background-preset-label">{preset.id === themeState.backgroundPresetId ? <Sparkles size={13} /> : <MoonStar size={13} />}{preset.label}</span> return (
</button> <button
))} key={preset.id}
type="button"
className={cx("background-preset-option", isVisiblePreset && "background-preset-option-active", hasCustomBackground && isFallbackPreset && "background-preset-option-fallback")}
aria-pressed={isVisiblePreset}
title={hasCustomBackground ? `${preset.summary},当前自定义背景正在显示;此预设会在移除上传背景后显示` : preset.summary}
onClick={() => selectBackgroundPreset(preset.id)}
>
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" />
<span className="background-preset-label">
{isVisiblePreset ? <Sparkles size={13} /> : <MoonStar size={13} />}
{preset.label}
{hasCustomBackground && isFallbackPreset && <span className="background-preset-fallback-badge"></span>}
</span>
</button>
);
})}
</div> </div>
<div className="theme-background-actions"> <div className="theme-background-actions">
<label className="theme-upload" title="上传自定义背景桌面"> <label className="theme-upload" title="上传自定义背景桌面">
@@ -241,7 +258,7 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
</button> </button>
)} )}
</div> </div>
<span className="theme-background-note">{themeState.backgroundImage ? "自定义上传背景正在显示,预设会作为移除后的备用桌面。" : "当前使用内置背景桌面。"}</span> <span className="theme-background-note">{themeState.backgroundImage ? `当前显示自定义上传背景;${activeBackground.label}作为移除上传后的备用桌面。` : "当前使用内置背景桌面。"}</span>
</section> </section>
</section> </section>
</div> </div>
+5 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage"; import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types"; import type { ServerConfigDiffPreviewResponse } from "../api/types";
const preview: ServerConfigDiffPreviewResponse = { const preview: ServerConfigDiffPreviewResponse = {
@@ -104,9 +105,10 @@ describe("ServerDetailPage config write approval", () => {
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => { it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)"); expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)");
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)"); expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
expect(serverDetailPageSource).toContain("platformApiClient.readArtifactContent(reference.artifactId"); expect(serverDetailPageSource).toContain("downloadArtifactReference(reference");
expect(serverDetailPageSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")"); expect(artifactTransferSource).toContain("readContent(reference.artifactId");
expect(serverDetailPageSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")"); expect(artifactTransferSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
expect(artifactTransferSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
expect(serverDetailPageSource).not.toContain("storage://bucket"); expect(serverDetailPageSource).not.toContain("storage://bucket");
expect(serverDetailPageSource).not.toContain("runSocket"); expect(serverDetailPageSource).not.toContain("runSocket");
expect(serverDetailPageSource).not.toContain("rawApiKey"); expect(serverDetailPageSource).not.toContain("rawApiKey");
+182 -50
View File
@@ -20,6 +20,17 @@ import type {
ServerRuntimeActionsResponse ServerRuntimeActionsResponse
} from "../api/types"; } from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import {
RuntimeTaskProgressDialog,
runtimeBuildStages,
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeUpdateStages,
type RuntimeTaskDialogAction,
type RuntimeTaskStage,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
@@ -45,6 +56,7 @@ import {
} from "../schemas/serverManagement"; } from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff"; import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost"; import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage"; import { stateLabel, statusClass } from "./ServersPage";
@@ -605,6 +617,8 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null); const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null); const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null); const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const actionByKey = useMemo(() => { const actionByKey = useMemo(() => {
if (runtimeActions.status !== "ready") { if (runtimeActions.status !== "ready") {
@@ -621,19 +635,53 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作"; return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
} }
async function runOperation<T>(intent: string, execute: () => Promise<T>, summarize: (value: T) => string) { async function runOperation<T>(
intent: string,
execute: () => Promise<T>,
summarize: (value: T) => string,
taskOptions?: {
description: string;
stages: RuntimeTaskStage[];
executeStageIndex?: number;
trackedJobId?: (value: T) => string;
afterSuccess?: (value: T) => void;
}
) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName }); const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
setRuntimeTaskActions([]);
setResult({ status: "pending", label: `${intent} 执行中` }); setResult({ status: "pending", label: `${intent} 执行中` });
try { try {
const value = await execute(); const value = taskOptions?.trackedJobId
? await runtimeTask.runTrackedTask({
title: intent,
description: taskOptions.description,
stages: taskOptions.stages,
start: async () => {
const value = await execute();
return { value, jobId: taskOptions.trackedJobId?.(value) ?? "" };
},
poll: (jobId) => platformApiClient.getJob(jobId)
})
: taskOptions
? await runtimeTask.runTask({
title: intent,
description: taskOptions.description,
stages: taskOptions.stages,
executeStageIndex: taskOptions.executeStageIndex,
execute
})
: await execute();
const label = summarize(value); const label = summarize(value);
operations.succeed(operationId, label); operations.succeed(operationId, label);
setResult({ status: "succeeded", label }); setResult({ status: "succeeded", label });
runtimeTask.succeedTask(label);
taskOptions?.afterSuccess?.(value);
onChanged(); onChanged();
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : `${intent} 失败`; const reason = error instanceof Error ? error.message : `${intent} 失败`;
operations.fail(operationId, reason, operationId); operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason }); setResult({ status: "failed", label: reason });
runtimeTask.failTask(reason);
} }
} }
@@ -647,6 +695,61 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
return null; return null;
} }
async function downloadRunArtifact(artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
try {
const label = await runtimeTask.runTask({
title: "下载 run",
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
setLastDownload(reference);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(label);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
}
}
async function pushRunArtifact(artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
await runOperation(
"推送 run 更新",
() => platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)),
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
description: `将 artifact ${artifact.artifactId} 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
stages: runtimeUpdateStages,
executeStageIndex: 2
}
);
}
async function downloadClientArtifact(profileKeyForDownload: string) {
setRuntimeTaskActions([]);
try {
const label = await runtimeTask.runTask({
title: "下载客户端",
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey: profileKeyForDownload });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(label);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
}
}
return ( return (
<article className="console-panel" aria-label="run distribution controls"> <article className="console-panel" aria-label="run distribution controls">
<div className="panel-header"> <div className="panel-header">
@@ -726,7 +829,19 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastRun(distribution); setLastRun(distribution);
return distribution; return distribution;
}, },
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 已生成artifact ${distribution.artifactId}generation ${distribution.keyGeneration}` (distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`,
{
description: `${instance.name} 构建 ${targetOs}/${targetArch} run 包,包含拉取代码、安装环境、编译和打包进度。`,
stages: runtimeBuildStages,
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) }
]);
}
}
) )
} }
/> />
@@ -742,9 +857,15 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
async () => { async () => {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id); const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
setLastDownload(reference); setLastDownload(reference);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return reference; return reference;
}, },
(reference) => `下载引用已创建artifact ${reference.artifactId}有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}` (reference) => `run 下载已开始artifact ${reference.artifactId}文件 ${safeArtifactFilename(reference.filename)}`,
{
description: `${instance.name} 创建最新 run 包下载引用,并展示 artifact 定位进度。`,
stages: runtimeDownloadStages,
executeStageIndex: 1
}
) )
} }
secondaryLabel="推送更新" secondaryLabel="推送更新"
@@ -760,7 +881,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
} }
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)); return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
}, },
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}` (update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
description: `将最近 run artifact 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
stages: runtimeUpdateStages,
executeStageIndex: 2
}
) )
} }
/> />
@@ -796,7 +922,15 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastClient(distribution); setLastClient(distribution);
return distribution; return distribution;
}, },
(distribution) => `客户端管理器已生成artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}` (distribution) => `客户端管理器二进制已构建artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`,
{
description: `${profileKey} profile 拉取客户端代码、安装环境、编译并生成可下载 artifact。`,
stages: runtimeBuildStages,
trackedJobId: (distribution) => distribution.buildJobId,
afterSuccess: () => {
setRuntimeTaskActions([{ label: "下载客户端", kind: "primary", onClick: () => void downloadClientArtifact(profileKey) }]);
}
}
) )
} }
secondaryLabel="下载客户端" secondaryLabel="下载客户端"
@@ -805,8 +939,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
onSecondary={() => onSecondary={() =>
void runOperation( void runOperation(
"下载客户端管理器", "下载客户端管理器",
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }), async () => {
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}` const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return reference;
},
(reference) => `客户端下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`
) )
} }
/> />
@@ -835,7 +973,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation( void runOperation(
"依赖检查", "依赖检查",
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)), () => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
(job) => `依赖检查任务已排队,job ${job.id}` (job) => `依赖检查任务已排队,job ${job.id}`,
{
description: `使用 ${probeKey} probe 检查 ${instance.name} 的运行依赖。`,
stages: runtimeDependencyStages,
executeStageIndex: 1
}
) )
} }
secondaryLabel="依赖安装" secondaryLabel="依赖安装"
@@ -845,7 +988,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation( void runOperation(
"依赖安装", "依赖安装",
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)), () => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
(job) => `依赖安装任务已排队,job ${job.id}` (job) => `依赖安装任务已排队,job ${job.id}`,
{
description: `使用 ${installPlanKey} 安装计划派发依赖安装任务,并保留 job 追踪。`,
stages: runtimeDependencyStages,
executeStageIndex: 2
}
) )
} }
/> />
@@ -855,7 +1003,21 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
disabled={!canUse("live-logs")} disabled={!canUse("live-logs")}
reason={reasonFor("live-logs")} reason={reasonFor("live-logs")}
actionLabel="实时日志" actionLabel="实时日志"
onAction={onOpenLogs} onAction={() =>
void runOperation(
"实时日志",
async () => {
onOpenLogs();
return true;
},
() => "已打开实时日志视图",
{
description: `读取 ${instance.name} 的平台日志源并打开实时日志视图。`,
stages: runtimeLogStages,
executeStageIndex: 1
}
)
}
secondaryLabel="历史回填" secondaryLabel="历史回填"
secondaryDisabled={!canUse("historical-logs")} secondaryDisabled={!canUse("historical-logs")}
secondaryReason={reasonFor("historical-logs")} secondaryReason={reasonFor("historical-logs")}
@@ -863,7 +1025,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation( void runOperation(
"历史日志回填", "历史日志回填",
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)), () => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
(job) => `历史日志回填任务已排队,job ${job.id}` (job) => `历史日志回填任务已排队,job ${job.id}`,
{
description: `${logSourceKey} 日志源准备历史回填游标并派发后台 job。`,
stages: runtimeLogStages,
executeStageIndex: 1
}
) )
} }
> >
@@ -873,6 +1040,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
</label> </label>
</RuntimeActionRow> </RuntimeActionRow>
</div> </div>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</article> </article>
); );
} }
@@ -1930,19 +2098,9 @@ function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelPro
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } })); setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
try { try {
const reference = await platformApiClient.openArtifactDownload(artifact.id); const reference = await platformApiClient.openArtifactDownload(artifact.id);
const chunks: ArrayBuffer[] = []; await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
let offset = 0;
while (offset < reference.sizeBytes) {
const chunk = await platformApiClient.readArtifactContent(reference.artifactId, offset, reference.chunkSizeBytes);
chunks.push(chunk.payload);
offset += chunk.payload.byteLength;
const progress = Math.min(100, Math.round((offset / reference.sizeBytes) * 100));
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } })); setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
if (chunk.payload.byteLength === 0) { });
break;
}
}
openArtifactBlob(reference, chunks);
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } })); setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
} catch (error) { } catch (error) {
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } })); setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
@@ -1992,32 +2150,6 @@ function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelPro
); );
} }
function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: ArrayBuffer[]) {
if (typeof document === "undefined" || typeof URL === "undefined") {
return;
}
const blob = new Blob(chunks, { type: reference.contentType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = safeArtifactFilename(reference.filename);
anchor.rel = "noopener";
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function safeArtifactFilename(filename: string): string {
const cleaned = filename.replace(/[\\/]/g, "").trim();
return cleaned || "artifact.bin";
}
function safeArtifactError(error: unknown): string {
const message = error instanceof Error ? error.message : "制品传输失败";
return message.replace(/\/Users\/[^\s]+/g, "[path]").replace(/Bearer\s+[^\s]+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]");
}
function formatBytes(value: number): string { function formatBytes(value: number): string {
if (value < 1024) { if (value < 1024) {
return `${value} B`; return `${value} B`;
+314 -56
View File
@@ -1,8 +1,19 @@
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react"; import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types"; import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
import {
RuntimeTaskProgressDialog,
type RuntimeTaskDialogAction,
runtimeBuildStages,
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeUpdateStages,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { UsageMeter } from "../components/OperationControls"; import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
@@ -23,6 +34,7 @@ import {
serverCreateRequestFromForm serverCreateRequestFromForm
} from "../schemas/serverManagement"; } from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace"; import { isPlatformAdmin } from "../contracts/workspace";
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error"; type ListState = "loading" | "ready" | "error";
@@ -47,6 +59,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], [])); const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setListState("loading"); setListState("loading");
@@ -126,48 +140,175 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId); const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
const intent = quickRuntimeActionLabel(action); const intent = quickRuntimeActionLabel(action);
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName }); 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 { try {
let message = "运行操作已提交"; let message: string;
if (action === "generate-run") { if (action === "generate-run") {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64")); const distribution = await runtimeTask.runTrackedTask({
message = `run 包已生成,artifact ${distribution.artifactId}`; title: intent,
} else if (action === "download-run") { description: quickRuntimeTaskDescription(instance, action),
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id); stages: runtimeBuildStages,
message = `run 下载引用已创建,artifact ${reference.artifactId}`; start: async () => {
} else if (action === "push-run-update") { const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id); return { value: distribution, jobId: distribution.buildJobId };
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum)); },
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`; poll: (jobId) => platformApiClient.getJob(jobId)
});
generatedRunArtifact = { artifactId: distribution.artifactId };
message = `run 二进制已构建并上传,artifact ${distribution.artifactId}`;
} else if (action === "generate-client-manager") { } else if (action === "generate-client-manager") {
const distribution = await platformApiClient.generateClientManager( const distribution = await runtimeTask.runTrackedTask({
instance.id, title: intent,
clientManagerBuildRequest({ description: quickRuntimeTaskDescription(instance, action),
serverInstanceId: instance.id, stages: runtimeBuildStages,
profileKey: defaults.clientProfileKey, start: async () => {
targetOs: defaults.clientOs, const distribution = await platformApiClient.generateClientManager(
targetArch: "amd64", instance.id,
repositoryUrl: defaults.repositoryUrl, clientManagerBuildRequest({
sourceRevision: "main" serverInstanceId: instance.id,
}) profileKey: defaults.clientProfileKey,
); targetOs: defaults.clientOs,
message = `客户端管理器已生成,artifact ${distribution.artifactId}`; targetArch: "amd64",
} else if (action === "dependencies-check") { repositoryUrl: defaults.repositoryUrl,
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey)); sourceRevision: "main"
message = `依赖检查任务已排队,job ${job.id}`; })
} else if (action === "dependencies-install") { );
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey)); return { value: distribution, jobId: distribution.buildJobId };
message = `依赖安装任务已排队,job ${job.id}`; },
} else if (action === "live-logs") { poll: (jobId) => platformApiClient.getJob(jobId)
onNavigate("serverDetail", { serverId: instance.id }); });
message = "已打开服务器详情,可切换到日志页查看实时日志"; generatedClientProfile = defaults.clientProfileKey;
} else if (action === "historical-logs") { message = `客户端二进制已构建并上传,artifact ${distribution.artifactId}`;
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey)); } else {
message = `历史日志回填任务已排队,job ${job.id}`; message = await runtimeTask.runTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: quickRuntimeStages(action),
executeStageIndex: quickRuntimeExecuteStageIndex(action),
execute: async () => {
if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`;
}
if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
}
if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
return `依赖检查任务已排队,job ${job.id}`;
}
if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
return `依赖安装任务已排队,job ${job.id}`;
}
if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
return "已打开服务器详情,可切换到日志页查看实时日志";
}
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
return `历史日志回填任务已排队,job ${job.id}`;
}
});
} }
operations.succeed(operationId, message); 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) {
const clientProfile = generatedClientProfile;
setRuntimeTaskActions([
{
label: "下载客户端",
kind: "primary",
onClick: () => void downloadGeneratedClient(instance, clientProfile)
}
]);
}
await refresh(); await refresh();
} catch (error) { } catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId); const message = error instanceof Error ? error.message : "运行操作失败";
operations.fail(operationId, message, operationId);
runtimeTask.failTask(message);
}
}
async function downloadGeneratedRun(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
try {
const message = await runtimeTask.runTask({
title: "下载 run",
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(message);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
}
}
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 });
try {
const message = await runtimeTask.runTask({
title: "推送 run 更新",
description: `${instance.name} 将使用刚生成的 artifact ${artifact.artifactId} 派发 run 自更新任务。`,
stages: runtimeUpdateStages,
executeStageIndex: 2,
execute: async () => {
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
}
});
operations.succeed(operationId, message);
runtimeTask.succeedTask(message);
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "推送 run 更新失败";
operations.fail(operationId, message, operationId);
runtimeTask.failTask(message);
}
}
async function downloadGeneratedClient(instance: ServerInstanceResponse, profileKey: string) {
setRuntimeTaskActions([]);
try {
const message = await runtimeTask.runTask({
title: "下载客户端",
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(message);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
} }
} }
@@ -307,6 +448,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
))} ))}
</div> </div>
)} )}
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</section> </section>
); );
} }
@@ -331,6 +473,80 @@ interface ServerCardProps {
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) { function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card; const { instance, metrics, pendingJobs } = card;
const online = serverIsOnline(instance.state); const online = serverIsOnline(instance.state);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
const closeMenu = useCallback(() => setMenuOpen(false), []);
const openMenu = useCallback(() => {
const trigger = menuButtonRef.current;
if (!trigger) {
setMenuOpen(true);
return;
}
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = Math.min(320, Math.max(220, viewportWidth - 24));
const estimatedMenuHeight = 232;
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);
setMenuStyle({ left, top, width: menuWidth });
setMenuOpen(true);
}, []);
const toggleMenu = useCallback(() => {
if (menuOpen) {
closeMenu();
return;
}
openMenu();
}, [closeMenu, menuOpen, openMenu]);
useEffect(() => {
if (!menuOpen) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (menuButtonRef.current?.contains(target) || menuPanelRef.current?.contains(target)) {
return;
}
closeMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
menuButtonRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeMenu);
window.addEventListener("scroll", closeMenu, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeMenu);
window.removeEventListener("scroll", closeMenu, true);
};
}, [closeMenu, menuOpen]);
const chooseQuickAction = (action: ServerQuickRuntimeAction) => {
closeMenu();
onQuickAction(action);
};
return ( return (
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}> <article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
<div className="server-card-head"> <div className="server-card-head">
@@ -368,31 +584,42 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
<Sparkles size={14} /> <Sparkles size={14} />
<span></span> <span></span>
</button> </button>
<details className="runtime-action-menu"> <button ref={menuButtonRef} type="button" className="icon-command" aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<summary className="icon-command"></summary> <span></span>
<div className="action-list"> </button>
{serverQuickActions.map((action) => (
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
<Candy size={13} />
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</details>
</div> </div>
{menuOpen &&
typeof document !== "undefined" &&
createPortal(
<div ref={menuPanelRef} className="runtime-action-popover" style={menuStyle} role="menu" aria-label={`${instance.name} 运行操作`}>
{serverQuickActionGroups.map((group) => (
<section key={group.label} className="runtime-action-group" aria-label={group.label}>
<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)}>
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</section>
))}
</div>,
document.body
)}
</article> </article>
); );
} }
const serverQuickActions: ServerQuickRuntimeAction[] = [ const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntimeAction[] }> = [
"generate-run", {
"download-run", label: "运行分发",
"push-run-update", actions: ["generate-run", "download-run", "push-run-update", "generate-client-manager"]
"generate-client-manager", },
"dependencies-check", {
"dependencies-install", label: "诊断维护",
"live-logs", actions: ["dependencies-check", "dependencies-install", "live-logs", "historical-logs"]
"historical-logs" }
]; ];
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string { function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
@@ -416,6 +643,37 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
} }
} }
function quickRuntimeStages(action: ServerQuickRuntimeAction) {
if (action === "generate-run" || action === "generate-client-manager") {
return runtimeBuildStages;
}
if (action === "download-run") {
return runtimeDownloadStages;
}
if (action === "push-run-update") {
return runtimeUpdateStages;
}
if (action === "dependencies-check" || action === "dependencies-install") {
return runtimeDependencyStages;
}
return runtimeLogStages;
}
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
if (action === "generate-run" || action === "generate-client-manager") {
return 3;
}
if (action === "push-run-update") {
return 2;
}
return 1;
}
function quickRuntimeTaskDescription(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction): string {
const label = quickRuntimeActionLabel(action);
return `${instance.name}${instance.id}${label},通过平台 API 派发并保留可追踪进度。`;
}
function quickRuntimeDefaultsForPlugin(pluginId: string) { function quickRuntimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum"); const isScum = pluginId.toLowerCase().includes("scum");
return { return {
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { emptyAiProviderForm } from "../contracts/aiProviders";
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "./aiProviders";
describe("ai provider form schemas", () => {
it("generates provider IDs and defaults for normal OpenAI setup", () => {
const request = aiProviderCreateRequestFromForm({ ...emptyAiProviderForm(), id: "", apiKeyRef: "secret://providers/openai" });
expect(request).toMatchObject({
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
defaultModel: "gpt-5.6-terra",
relayMode: "relay"
});
expect(request.models).toContain("gpt-5.6-terra");
});
it("keeps Ollama local mode keyless while still providing required metadata", () => {
const request = aiProviderUpdateRequestFromForm({ ...emptyAiProviderForm(), kind: "ollama", name: "", baseUrl: "", apiKeyRef: "", modelsText: "", defaultModel: "", relayMode: "local" });
expect(request).toMatchObject({
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "",
defaultModel: "gpt-oss:20b",
relayMode: "local"
});
expect(request.models).toEqual(["gpt-oss:20b"]);
});
});
+14 -12
View File
@@ -1,29 +1,31 @@
import type { AiProviderRequest, AiProviderStatusRequest, AiProviderUpdateRequest } from "../api/types"; import type { AiProviderRequest, AiProviderStatusRequest, AiProviderUpdateRequest } from "../api/types";
import type { AiProviderFormState } from "../contracts/aiProviders"; import { completeAiProviderForm, type AiProviderFormState } from "../contracts/aiProviders";
export function aiProviderCreateRequestFromForm(form: AiProviderFormState): AiProviderRequest { export function aiProviderCreateRequestFromForm(form: AiProviderFormState): AiProviderRequest {
const completed = completeAiProviderForm(form);
return { return {
id: form.id.trim(), id: completed.id,
...aiProviderUpdateRequestFromForm(form) ...aiProviderUpdateRequestFromForm(completed)
}; };
} }
export function aiProviderUpdateRequestFromForm(form: AiProviderFormState): AiProviderUpdateRequest { export function aiProviderUpdateRequestFromForm(form: AiProviderFormState): AiProviderUpdateRequest {
const models = form.modelsText const completed = completeAiProviderForm(form);
const models = completed.modelsText
.split(",") .split(",")
.map((model) => model.trim()) .map((model) => model.trim())
.filter(Boolean); .filter(Boolean);
return { return {
name: form.name.trim(), name: completed.name,
kind: form.kind, kind: completed.kind,
baseUrl: form.baseUrl.trim(), baseUrl: completed.baseUrl,
apiKeyRef: form.apiKeyRef.trim(), apiKeyRef: completed.apiKeyRef,
models, models,
defaultModel: form.defaultModel.trim() || models[0], defaultModel: completed.defaultModel || models[0],
relayMode: form.relayMode, relayMode: completed.relayMode,
timeoutMs: Number.parseInt(form.timeoutMs, 10), timeoutMs: Number.parseInt(completed.timeoutMs, 10),
redactionPolicy: form.redactionPolicy.trim() || "default" redactionPolicy: completed.redactionPolicy
}; };
} }
+5 -2
View File
@@ -27,6 +27,7 @@ This directory owns the platform_web visual system. Keep the console in a unifie
- `defaultThemeBackgroundId` should point to a built-in mecha desktop preset that works without uploaded imagery. - `defaultThemeBackgroundId` should point to a built-in mecha desktop preset that works without uploaded imagery.
- Built-in presets use CSS variables named `--workspace-background-pattern-*` and render behind the app shell. - Built-in presets use CSS variables named `--workspace-background-pattern-*` and render behind the app shell.
- Uploaded backgrounds use `--workspace-background-image`, set `data-custom-background="true"`, and take visual precedence over the selected preset. - Uploaded backgrounds use `--workspace-background-image`, set `data-custom-background="true"`, and take visual precedence over the selected preset.
- Magical-girl plus uploaded backgrounds must stay restrained: use muted translucent surfaces, low-opacity frame accessories, and reduced pink/gold glow so busy user imagery remains readable instead of becoming a saturated wash.
- Removing an uploaded background must reveal the selected built-in preset again. - Removing an uploaded background must reveal the selected built-in preset again.
- Theme palette switches must keep uploaded backgrounds intact. Changing from magical-girl to black mecha, or back again, must not clear `--workspace-background-image` or alter the custom-background fallback preset. - Theme palette switches must keep uploaded backgrounds intact. Changing from magical-girl to black mecha, or back again, must not clear `--workspace-background-image` or alter the custom-background fallback preset.
- Any new preset must include an `id`, `label`, `summary`, `preview`, and all required `--workspace-background-pattern-*` variables. Current presets are 机甲格纳库 and 粉月魔法阵. - Any new preset must include an `id`, `label`, `summary`, `preview`, and all required `--workspace-background-pattern-*` variables. Current presets are 机甲格纳库 and 粉月魔法阵.
@@ -45,6 +46,9 @@ This directory owns the platform_web visual system. Keep the console in a unifie
- A visual region should have only one ornamental frame at a hierarchy level. If a `.state-view` is nested inside a shared framed parent such as `.console-panel`, `.catalog-card`, `.server-card`, `.resource-table-wrap`, `.provider-table-wrap`, `.server-table-wrap`, `.plugin-group`, or `.operation-item`, the parent owns the frame and the nested state view must render as transparent, borderless content with no `::before` or `::after` accessory. - A visual region should have only one ornamental frame at a hierarchy level. If a `.state-view` is nested inside a shared framed parent such as `.console-panel`, `.catalog-card`, `.server-card`, `.resource-table-wrap`, `.provider-table-wrap`, `.server-table-wrap`, `.plugin-group`, or `.operation-item`, the parent owns the frame and the nested state view must render as transparent, borderless content with no `::before` or `::after` accessory.
- Standalone `.state-view` instances may keep their own readable state treatment when they are not inside an already framed surface. - Standalone `.state-view` instances may keep their own readable state treatment when they are not inside an already framed surface.
- Menu frames should use `var(--menu-item-bg)`, `var(--menu-item-active-bg)`, `var(--menu-glyph-bg)`, `var(--menu-title-shadow)`, and `var(--menu-active-outline)` so each theme changes active-state treatment, icon material, and rail/sidebar structure. - Menu frames should use `var(--menu-item-bg)`, `var(--menu-item-active-bg)`, `var(--menu-glyph-bg)`, `var(--menu-title-shadow)`, and `var(--menu-active-outline)` so each theme changes active-state treatment, icon material, and rail/sidebar structure.
- Action dropdowns and contextual menus are small operational overlays, not decorative panels. They must stay anchored to the trigger, fit within the viewport, use compact rows, and avoid moving or resizing the parent card, row, grid, or table.
- Do not render a dropdown as a tall vertical tower of large command buttons. Do not let a menu cover server metrics, progress bars, titles, status badges, or adjacent cards. If a server/resource has too many runtime actions for a compact menu, route those actions to a grouped drawer, detail page, or command dialog.
- Menu items may use theme-appropriate icons for recognition, but repeated decorative glyph rails on every action row are forbidden. Icons must clarify action meaning or safety state, not become visual clutter.
- Shared decoration variables are part of the contract: `--frosted-edge`, `--frosted-surface`, `--corner-sparkle`, `--jelly-highlight`, `--sugar-dust`, `--crystal-edge-glow`, and `--jelly-inset`. In mecha themes these become scanner/grid/bevel materials; in magical themes they become star, ribbon, and jelly-glass materials. - Shared decoration variables are part of the contract: `--frosted-edge`, `--frosted-surface`, `--corner-sparkle`, `--jelly-highlight`, `--sugar-dust`, `--crystal-edge-glow`, and `--jelly-inset`. In mecha themes these become scanner/grid/bevel materials; in magical themes they become star, ribbon, and jelly-glass materials.
- Full-screen ambient motifs use the shared `MagicalParticleLayer` background layer and global particle DOM layer. They should remain non-interactive, theme-colored, reduced-motion aware, and behind operational surfaces. Page code should not create one-off fixed decoration containers. - Full-screen ambient motifs use the shared `MagicalParticleLayer` background layer and global particle DOM layer. They should remain non-interactive, theme-colored, reduced-motion aware, and behind operational surfaces. Page code should not create one-off fixed decoration containers.
- Keep framed repeated items at 8px radius or less. Pills and circular avatars are allowed for native pill/circle controls. - Keep framed repeated items at 8px radius or less. Pills and circular avatars are allowed for native pill/circle controls.
@@ -57,5 +61,4 @@ This directory owns the platform_web visual system. Keep the console in a unifie
2. If a new shared pattern is truly needed, add it in `base.css` and describe its intended use here. 2. If a new shared pattern is truly needed, add it in `base.css` and describe its intended use here.
3. When placing empty/loading/error states inside an existing shared panel, verify the state view does not introduce a second framed panel or accessory layer. 3. When placing empty/loading/error states inside an existing shared panel, verify the state view does not introduce a second framed panel or accessory layer.
4. If a new palette or background preset is added, update `tokens.ts`, `tokens.test.ts`, and any CSS contract tests together. 4. If a new palette or background preset is added, update `tokens.ts`, `tokens.test.ts`, and any CSS contract tests together.
5. Run `npm run typecheck`, `npm test`, `npm run build`, `scripts/check-structure.sh`, and `openspec validate <change> --strict` before claiming completion. 5. Run the relevant focused checks before claiming completion. Use `npm run typecheck`, `npm test`, `npm run build`, and `scripts/check-structure.sh` when the scope warrants them; run `openspec validate <change> --strict` only when an OpenSpec change was created.
6. For page or interaction changes, perform a browser walkthrough before marking visual acceptance tasks complete. At minimum, switch black mecha -> magical-girl -> black mecha with both built-in and uploaded backgrounds when the change touches theme switching, frame accessories, or custom-background styling.
+66
View File
@@ -10,4 +10,70 @@ describe("platform web shared theme CSS", () => {
expect(nestedStateReset).toContain("content: none"); expect(nestedStateReset).toContain("content: none");
expect(nestedStateReset).toContain("background: transparent"); expect(nestedStateReset).toContain("background: transparent");
}); });
it("keeps server card runtime actions as compact overlay menus", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const popoverRule = themeCss.slice(themeCss.indexOf(".runtime-action-popover"), themeCss.indexOf("/* ---- catalog / detail lists ---- */"));
expect(popoverRule).toContain("position: fixed");
expect(popoverRule).toContain("z-index: 45");
expect(popoverRule).toContain(".runtime-action-grid");
expect(popoverRule).toContain("grid-template-columns: repeat(2, minmax(0, 1fr))");
});
it("keeps server card stat tiles readable over busy backgrounds", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const statRule = themeCss.slice(themeCss.indexOf(".server-card-stat {"), themeCss.indexOf(".server-card-meters"));
expect(statRule).toContain("border: 1px solid");
expect(statRule).toContain("--surface-solid");
expect(statRule).toContain("font-weight: 850");
expect(themeCss).toContain(':root[data-custom-background="true"] .server-card-stat');
});
it("keeps magical custom-background signal rows readable without the jelly wash", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const signalRule = themeCss.slice(
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item'),
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item strong')
);
expect(signalRule).toContain("rgba(58, 44, 56, 0.72)");
expect(signalRule).toContain("backdrop-filter: blur(12px) saturate(0.92)");
expect(signalRule).not.toContain("var(--jelly-highlight), var(--glass-wash), var(--surface-solid)");
});
it("keeps shared controls off the heavy jelly and corner-sparkle button wash", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const primaryCommandRule = themeCss.slice(themeCss.indexOf(".primary-command {"), themeCss.indexOf(".action-strip .primary-command"));
expect(themeCss).toContain("--control-surface");
expect(themeCss).toContain("--primary-command-surface");
expect(themeCss).not.toContain("background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid)");
expect(themeCss).not.toContain("linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%)");
expect(primaryCommandRule).not.toContain("background-position: right 8px top 4px, center, center");
});
it("keeps magical-girl custom-background panel overlays restrained", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const customMagicalRule = themeCss.slice(
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] {'),
themeCss.indexOf(':root[data-custom-background="true"][data-theme-palette="magical-girl"] .app-sidebar')
);
expect(customMagicalRule).toContain("--frame-accessory-opacity: 0.22");
expect(customMagicalRule).toContain("rgba(22, 22, 29, 0.64)");
expect(customMagicalRule).not.toContain("255, 119, 200, 0.78");
});
it("styles runtime task progress as a dialog with staged status", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const progressRule = themeCss.slice(themeCss.indexOf(".runtime-task-backdrop"), themeCss.indexOf("/* ---- narrow screens ---- */"));
expect(progressRule).toContain(".runtime-task-panel");
expect(progressRule).toContain(".runtime-task-meter-track");
expect(progressRule).toContain(".runtime-task-stages");
expect(progressRule).toContain(".runtime-task-log");
expect(progressRule).toContain("prefers-reduced-motion");
});
}); });
+462 -73
View File
@@ -61,6 +61,9 @@
--menu-glyph-bg: linear-gradient(135deg, rgba(72, 230, 255, 0.28), rgba(255, 184, 77, 0.14)), repeating-linear-gradient(90deg, rgba(137, 239, 255, 0.18) 0 1px, transparent 1px 6px), rgba(5, 9, 15, 0.9); --menu-glyph-bg: linear-gradient(135deg, rgba(72, 230, 255, 0.28), rgba(255, 184, 77, 0.14)), repeating-linear-gradient(90deg, rgba(137, 239, 255, 0.18) 0 1px, transparent 1px 6px), rgba(5, 9, 15, 0.9);
--menu-title-shadow: 0 0 14px rgba(72, 230, 255, 0.5), 0 0 2px rgba(255, 184, 77, 0.8); --menu-title-shadow: 0 0 14px rgba(72, 230, 255, 0.5), 0 0 2px rgba(255, 184, 77, 0.8);
--menu-active-outline: linear-gradient(90deg, rgba(72, 230, 255, 0.95), rgba(255, 184, 77, 0.72), rgba(72, 230, 255, 0.95)); --menu-active-outline: linear-gradient(90deg, rgba(72, 230, 255, 0.95), rgba(255, 184, 77, 0.72), rgba(72, 230, 255, 0.95));
--control-surface: linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 90%, rgba(255, 255, 255, 0.08)), color-mix(in srgb, var(--surface-solid) 76%, var(--accent-soft))), var(--glass-wash);
--control-surface-active: linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 72%, var(--accent-soft)), color-mix(in srgb, var(--surface-solid) 82%, var(--pink-soft))), var(--glass-wash);
--primary-command-surface: linear-gradient(180deg, color-mix(in srgb, var(--accent) 42%, rgba(255, 255, 255, 0.16)), color-mix(in srgb, var(--pink) 36%, var(--surface-solid)) 68%, color-mix(in srgb, var(--surface-solid) 84%, var(--pink-soft)));
--panel-material: var(--sugar-dust), var(--glass-wash), linear-gradient(135deg, rgba(4, 8, 13, 0.78), rgba(13, 23, 32, 0.64)); --panel-material: var(--sugar-dust), var(--glass-wash), linear-gradient(135deg, rgba(4, 8, 13, 0.78), rgba(13, 23, 32, 0.64));
--panel-shadow: var(--jelly-inset), inset 0 0 0 1px rgba(119, 237, 255, 0.24), inset 9px 0 0 rgba(72, 230, 255, 0.08), 0 18px 42px rgba(0, 0, 0, 0.5), 0 0 34px rgba(72, 230, 255, 0.14); --panel-shadow: var(--jelly-inset), inset 0 0 0 1px rgba(119, 237, 255, 0.24), inset 9px 0 0 rgba(72, 230, 255, 0.08), 0 18px 42px rgba(0, 0, 0, 0.5), 0 0 34px rgba(72, 230, 255, 0.14);
--ultimate-effect-alpha: 0.86; --ultimate-effect-alpha: 0.86;
@@ -820,7 +823,7 @@ button {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 8px; border-radius: 8px;
padding: 0 10px; padding: 0 10px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink); color: var(--ink);
font: inherit; font: inherit;
box-shadow: var(--jelly-inset); box-shadow: var(--jelly-inset);
@@ -839,14 +842,7 @@ button {
gap: 6px; gap: 6px;
border: 1px solid var(--rim-light); border: 1px solid var(--rim-light);
border-radius: 999px; border-radius: 999px;
background: background: var(--primary-command-surface);
var(--sugar-dust),
var(--corner-sparkle),
linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%),
linear-gradient(135deg, var(--accent), var(--pink));
background-size: auto, 44px 44px, auto, auto;
background-position: center, right 8px top 2px, center, center;
background-repeat: no-repeat, no-repeat, no-repeat, no-repeat;
color: #ffffff; color: #ffffff;
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
@@ -962,13 +958,17 @@ button {
.background-preset-option-active { .background-preset-option-active {
border-color: var(--line-strong); border-color: var(--line-strong);
color: var(--accent-deep); color: var(--accent-deep);
background: var(--corner-sparkle), var(--jelly-highlight), var(--glass-wash), var(--accent-soft); background: var(--control-surface-active);
background-size: 46px 46px, auto, auto, auto;
background-position: right 8px top 4px, center, center, center;
background-repeat: no-repeat;
box-shadow: inset 0 1px 0 var(--rim-light), inset 0 0 0 1px var(--diamond-line), 0 10px 24px var(--candy-glow); box-shadow: inset 0 1px 0 var(--rim-light), inset 0 0 0 1px var(--diamond-line), 0 10px 24px var(--candy-glow);
} }
.background-preset-option-fallback {
border-style: dashed;
border-color: color-mix(in srgb, var(--line-strong) 72%, transparent);
color: var(--ink-soft);
background: var(--control-surface);
}
.background-preset-preview { .background-preset-preview {
min-height: 34px; min-height: 34px;
border: 1px solid var(--crystal-rim); border: 1px solid var(--crystal-rim);
@@ -985,6 +985,16 @@ button {
font-weight: 700; font-weight: 700;
} }
.background-preset-fallback-badge {
padding: 1px 6px;
border: 1px solid color-mix(in srgb, var(--line-strong) 76%, transparent);
border-radius: 999px;
background: var(--gold-soft);
color: var(--accent-deep);
font-size: 11px;
line-height: 1.35;
}
.theme-background-note { .theme-background-note {
display: block; display: block;
padding: 7px 9px; padding: 7px 9px;
@@ -1004,7 +1014,7 @@ button {
padding: 0 10px; padding: 0 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px; border-radius: 999px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink-soft); color: var(--ink-soft);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
@@ -1107,7 +1117,7 @@ button {
gap: 6px; gap: 6px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink-soft); color: var(--ink-soft);
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
@@ -1152,7 +1162,7 @@ button {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 8px; border-radius: 8px;
padding: 0 10px; padding: 0 10px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink); color: var(--ink);
font: inherit; font: inherit;
box-shadow: var(--jelly-inset); box-shadow: var(--jelly-inset);
@@ -1218,7 +1228,7 @@ button {
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink-soft); color: var(--ink-soft);
box-shadow: inset 0 1px 0 var(--crystal-rim); box-shadow: inset 0 1px 0 var(--crystal-rim);
font-size: 12.5px; font-size: 12.5px;
@@ -1364,7 +1374,7 @@ button {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 8px; border-radius: 8px;
padding: 0 10px; padding: 0 10px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink); color: var(--ink);
font: inherit; font: inherit;
box-shadow: var(--jelly-inset); box-shadow: var(--jelly-inset);
@@ -1858,34 +1868,34 @@ button {
} }
:root[data-custom-background="true"][data-theme-palette="magical-girl"] { :root[data-custom-background="true"][data-theme-palette="magical-girl"] {
--frame-accessory-opacity: 0.82; --frame-accessory-opacity: 0.22;
--surface: rgba(34, 27, 34, 0.5); --surface: rgba(22, 22, 29, 0.64);
--surface-solid: rgba(24, 20, 26, 0.78); --surface-solid: rgba(18, 19, 26, 0.88);
--surface-raised: rgba(44, 34, 43, 0.58); --surface-raised: rgba(25, 24, 32, 0.72);
--line: rgba(255, 185, 226, 0.52); --line: rgba(238, 215, 236, 0.34);
--line-strong: rgba(255, 236, 249, 0.78); --line-strong: rgba(250, 240, 249, 0.5);
--glass-wash: linear-gradient(145deg, rgba(255, 236, 249, 0.16), rgba(38, 30, 38, 0.48) 46%, rgba(255, 221, 117, 0.06)); --glass-wash: linear-gradient(145deg, rgba(255, 246, 253, 0.08), rgba(22, 22, 29, 0.66) 48%, rgba(255, 221, 117, 0.025));
--panel-material: linear-gradient(145deg, rgba(255, 246, 253, 0.14), rgba(39, 31, 39, 0.5) 44%, rgba(24, 21, 27, 0.46)); --panel-material: linear-gradient(145deg, rgba(255, 246, 253, 0.08), rgba(22, 22, 29, 0.72) 44%, rgba(18, 19, 26, 0.64));
--panel-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.3), inset 0 0 0 1px rgba(255, 185, 226, 0.12), 0 16px 34px rgba(20, 10, 18, 0.26), 0 0 20px rgba(255, 119, 200, 0.14); --panel-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.18), inset 0 0 0 1px rgba(255, 185, 226, 0.06), 0 16px 34px rgba(12, 12, 18, 0.3);
} }
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .app-sidebar { :root[data-custom-background="true"][data-theme-palette="magical-girl"] .app-sidebar {
border-right-color: rgba(255, 226, 244, 0.42); border-right-color: rgba(242, 224, 240, 0.26);
background: background:
linear-gradient(180deg, rgba(52, 37, 50, 0.66), rgba(22, 20, 27, 0.58)), linear-gradient(180deg, rgba(34, 31, 40, 0.72), rgba(18, 19, 26, 0.68)),
rgba(20, 18, 24, 0.42); rgba(18, 18, 24, 0.5);
-webkit-backdrop-filter: blur(14px) saturate(1.04); -webkit-backdrop-filter: blur(14px) saturate(0.92);
backdrop-filter: blur(14px) saturate(1.04); backdrop-filter: blur(14px) saturate(0.92);
box-shadow: inset -1px 0 0 rgba(255, 246, 253, 0.18), 10px 0 28px rgba(18, 10, 17, 0.22), 0 0 18px rgba(255, 119, 200, 0.1); box-shadow: inset -1px 0 0 rgba(255, 246, 253, 0.12), 10px 0 28px rgba(12, 12, 18, 0.26);
} }
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .page-header > div:first-child { :root[data-custom-background="true"][data-theme-palette="magical-girl"] .page-header > div:first-child {
border-color: rgba(255, 226, 244, 0.28); border-color: rgba(242, 224, 240, 0.18);
background: background:
linear-gradient(135deg, rgba(255, 246, 253, 0.13), transparent 36%), linear-gradient(135deg, rgba(255, 246, 253, 0.075), transparent 36%),
rgba(38, 30, 38, 0.48); rgba(22, 22, 29, 0.58);
-webkit-backdrop-filter: blur(8px) saturate(1.02); -webkit-backdrop-filter: blur(8px) saturate(0.9);
backdrop-filter: blur(8px) saturate(1.02); backdrop-filter: blur(8px) saturate(0.9);
} }
:root[data-custom-background="true"] .app-sidebar { :root[data-custom-background="true"] .app-sidebar {
@@ -2013,13 +2023,13 @@ button {
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .provider-table-wrap, :root[data-custom-background="true"][data-theme-palette="magical-girl"] .provider-table-wrap,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-table-wrap { :root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-table-wrap {
background: background:
radial-gradient(circle at 100% 0, rgba(255, 185, 226, 0.18), transparent 30%), radial-gradient(circle at 100% 0, rgba(255, 221, 242, 0.07), transparent 32%),
linear-gradient(135deg, rgba(255, 246, 253, 0.13), transparent 32%), linear-gradient(135deg, rgba(255, 246, 253, 0.065), transparent 34%),
linear-gradient(180deg, rgba(42, 32, 41, 0.42), rgba(24, 21, 27, 0.36)); linear-gradient(180deg, rgba(24, 24, 31, 0.66), rgba(18, 19, 26, 0.58));
border-color: color-mix(in srgb, var(--line-strong) 76%, rgba(255, 255, 255, 0.2)); border-color: color-mix(in srgb, var(--line-strong) 54%, rgba(255, 255, 255, 0.16));
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
backdrop-filter: none; backdrop-filter: none;
box-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.28), inset 0 0 0 1px rgba(255, 185, 226, 0.1), 0 16px 32px rgba(18, 10, 17, 0.26), 0 0 20px rgba(255, 119, 200, 0.14); box-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.18), inset 0 0 0 1px rgba(255, 185, 226, 0.045), 0 16px 32px rgba(12, 12, 18, 0.28);
} }
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-toolbar, :root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-toolbar,
@@ -2057,8 +2067,8 @@ button {
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .catalog-card::before, :root[data-custom-background="true"][data-theme-palette="magical-girl"] .catalog-card::before,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-card::before, :root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-card::before,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-detail-header::before { :root[data-custom-background="true"][data-theme-palette="magical-girl"] .server-detail-header::before {
background: linear-gradient(180deg, rgba(255, 246, 253, 0.96), rgba(255, 221, 117, 0.72), rgba(255, 119, 200, 0.78)); background: linear-gradient(180deg, rgba(255, 246, 253, 0.68), rgba(255, 221, 117, 0.32), rgba(255, 143, 208, 0.36));
opacity: 0.84; opacity: 0.46;
} }
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .metric-card::after, :root[data-custom-background="true"][data-theme-palette="magical-girl"] .metric-card::after,
@@ -2082,7 +2092,7 @@ button {
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right -4px top -6px; background-position: right -4px top -6px;
opacity: var(--frame-accessory-opacity); opacity: var(--frame-accessory-opacity);
filter: drop-shadow(0 0 4px rgba(255, 119, 200, 0.62)) drop-shadow(0 0 2px rgba(255, 221, 117, 0.46)); filter: drop-shadow(0 0 3px rgba(255, 180, 226, 0.24));
} }
:root[data-custom-background="true"] .metric-card::after, :root[data-custom-background="true"] .metric-card::after,
@@ -2198,6 +2208,33 @@ button {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28), 0 0 18px color-mix(in srgb, var(--accent) 24%, transparent); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28), 0 0 18px color-mix(in srgb, var(--accent) 24%, transparent);
} }
:root[data-custom-background="true"] .server-card-stat {
border-color: color-mix(in srgb, var(--line-strong) 62%, rgba(255, 255, 255, 0.2));
background:
linear-gradient(180deg, rgba(5, 10, 16, 0.9), rgba(5, 10, 16, 0.78)),
var(--glass-wash);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 9px 20px rgba(0, 0, 0, 0.28);
}
:root[data-custom-background="true"] .server-card-stat span,
:root[data-custom-background="true"] .runtime-action-group-label {
color: rgba(236, 249, 253, 0.9);
}
:root[data-custom-background="true"] .server-card-stat strong,
:root[data-custom-background="true"] .runtime-action-item {
color: rgba(250, 254, 255, 0.98);
}
:root[data-custom-background="true"] .runtime-action-popover {
border-color: color-mix(in srgb, var(--line-strong) 56%, rgba(255, 255, 255, 0.18));
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 32%),
rgba(5, 10, 16, 0.94);
-webkit-backdrop-filter: blur(14px) saturate(0.86);
backdrop-filter: blur(14px) saturate(0.86);
}
:root[data-custom-background="true"] .page-status, :root[data-custom-background="true"] .page-status,
:root[data-custom-background="true"] .job-chip, :root[data-custom-background="true"] .job-chip,
:root[data-custom-background="true"] .status-disabled, :root[data-custom-background="true"] .status-disabled,
@@ -2522,7 +2559,7 @@ button {
.row-actions button, .row-actions button,
.table-link-button { .table-link-button {
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink-soft); color: var(--ink-soft);
cursor: pointer; cursor: pointer;
box-shadow: inset 0 1px 0 var(--crystal-rim), inset 0 -1px 0 rgba(255, 255, 255, 0.28), 0 8px 18px var(--glass-shadow); box-shadow: inset 0 1px 0 var(--crystal-rim), inset 0 -1px 0 rgba(255, 255, 255, 0.28), 0 8px 18px var(--glass-shadow);
@@ -2579,13 +2616,7 @@ button {
.primary-command { .primary-command {
width: 100%; width: 100%;
background: background: var(--primary-command-surface);
var(--corner-sparkle),
linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%),
linear-gradient(135deg, var(--accent), var(--pink));
background-size: 48px 48px, auto, auto;
background-position: right 8px top 4px, center, center;
background-repeat: no-repeat;
border-color: var(--accent-deep); border-color: var(--accent-deep);
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
@@ -2619,6 +2650,70 @@ button {
border-color: var(--danger); border-color: var(--danger);
} }
.runtime-action-popover {
position: fixed;
z-index: 45;
display: grid;
gap: 10px;
max-height: min(320px, calc(100vh - 24px));
padding: 10px;
overflow: auto;
border: 1px solid color-mix(in srgb, var(--line-strong) 68%, rgba(255, 255, 255, 0.2));
border-radius: 8px;
background:
var(--corner-sparkle),
linear-gradient(145deg, color-mix(in srgb, var(--surface-solid) 94%, rgba(255, 255, 255, 0.04)), color-mix(in srgb, var(--surface-solid) 86%, var(--accent-soft)) 72%, color-mix(in srgb, var(--surface-solid) 94%, #000000 8%));
background-size: 48px 48px, auto;
background-repeat: no-repeat, no-repeat;
background-position: right 6px top 4px, center;
-webkit-backdrop-filter: blur(18px) saturate(1.1);
backdrop-filter: blur(18px) saturate(1.1);
box-shadow: var(--jelly-inset), inset 0 0 0 1px color-mix(in srgb, var(--diamond-line) 52%, transparent), 0 18px 42px rgba(0, 0, 0, 0.36), 0 0 18px color-mix(in srgb, var(--accent) 18%, transparent);
}
.runtime-action-group {
display: grid;
gap: 6px;
}
.runtime-action-group-label {
color: color-mix(in srgb, var(--ink) 76%, var(--accent-deep));
font-size: 11px;
font-weight: 850;
line-height: 1.1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.runtime-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.runtime-action-item {
min-height: 32px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--line) 76%, rgba(255, 255, 255, 0.12));
border-radius: 7px;
background: var(--menu-item-bg);
color: color-mix(in srgb, var(--ink) 92%, #ffffff);
cursor: pointer;
font-size: 12px;
font-weight: 780;
text-align: left;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 52%, transparent);
}
.runtime-action-item:hover,
.runtime-action-item:focus-visible {
border-color: var(--accent);
outline: none;
color: var(--ink);
background: var(--menu-item-active-bg);
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft), 0 10px 22px rgba(0, 0, 0, 0.24);
}
/* ---- catalog / detail lists ---- */ /* ---- catalog / detail lists ---- */
.catalog-grid { .catalog-grid {
@@ -2892,7 +2987,7 @@ button {
padding: 0 9px; padding: 0 9px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink-soft); color: var(--ink-soft);
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
@@ -2948,7 +3043,7 @@ button {
.provider-preset-grid { .provider-preset-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 8px;
} }
@@ -2960,7 +3055,7 @@ button {
padding: 10px; padding: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink-soft); color: var(--ink-soft);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
@@ -2978,12 +3073,68 @@ button {
} }
.provider-preset-option:hover, .provider-preset-option:hover,
.provider-preset-option:focus-visible { .provider-preset-option:focus-visible,
.provider-preset-option-active {
border-color: var(--accent); border-color: var(--accent);
outline: none; outline: none;
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft); box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft);
} }
.provider-generated-id {
display: grid;
gap: 4px;
padding: 10px;
border: 1px dashed var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-solid) 72%, transparent);
color: var(--ink-soft);
font-size: 12px;
}
.provider-generated-id > span {
font-weight: 800;
}
.provider-generated-id code {
width: max-content;
max-width: 100%;
padding: 3px 6px;
border-radius: 6px;
background: var(--surface-solid);
color: var(--ink);
overflow-wrap: anywhere;
}
.provider-generated-id small {
color: var(--ink-faint);
font-weight: 700;
}
.provider-advanced-settings {
display: grid;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-solid) 62%, transparent);
}
.provider-advanced-settings summary {
cursor: pointer;
color: var(--ink);
font-size: 13px;
font-weight: 850;
}
.provider-advanced-settings[open] summary {
margin-bottom: 8px;
}
.provider-advanced-settings > label,
.provider-advanced-settings > .form-grid {
margin-top: 10px;
}
.field-help { .field-help {
color: var(--ink-faint); color: var(--ink-faint);
font-size: 11.5px; font-size: 11.5px;
@@ -3106,7 +3257,6 @@ button {
background: var(--frosted-surface), var(--glass-tint), var(--surface); background: var(--frosted-surface), var(--glass-tint), var(--surface);
backdrop-filter: blur(22px) saturate(1.28); backdrop-filter: blur(22px) saturate(1.28);
text-align: left; text-align: left;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease; transition: transform 120ms ease, border-color 120ms ease;
box-shadow: var(--jelly-inset), inset 0 0 0 1px var(--diamond-line), 0 18px 42px var(--glass-shadow), 0 0 28px rgba(255, 255, 255, 0.2); box-shadow: var(--jelly-inset), inset 0 0 0 1px var(--diamond-line), 0 18px 42px var(--glass-shadow), 0 0 28px rgba(255, 255, 255, 0.2);
position: relative; position: relative;
@@ -3149,23 +3299,33 @@ button {
display: grid; display: grid;
gap: 2px; gap: 2px;
padding: 8px; padding: 8px;
border: 1px solid color-mix(in srgb, var(--line-strong) 56%, rgba(255, 255, 255, 0.16));
border-radius: 8px; border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--accent-soft); background:
linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 88%, rgba(255, 255, 255, 0.06)), color-mix(in srgb, var(--surface-solid) 72%, var(--accent-soft))),
var(--glass-wash);
min-height: 52px; min-height: 52px;
align-content: center; align-content: center;
box-shadow: inset 0 1px 0 var(--crystal-rim); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 68%, transparent), 0 8px 18px rgba(0, 0, 0, 0.18);
min-width: 0; min-width: 0;
backdrop-filter: blur(10px) saturate(0.92);
} }
.server-card-stat span { .server-card-stat span {
color: var(--ink-faint); color: color-mix(in srgb, var(--ink) 78%, var(--accent-deep));
font-size: 11px; font-size: 11px;
font-weight: 800;
line-height: 1.15;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.48);
} }
.server-card-stat strong { .server-card-stat strong {
font-size: 14px; color: color-mix(in srgb, var(--ink) 94%, #ffffff);
color: var(--ink); font-size: 15px;
font-weight: 850;
line-height: 1.16;
overflow-wrap: anywhere; overflow-wrap: anywhere;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.56);
} }
.server-card-meters { .server-card-meters {
@@ -3266,7 +3426,7 @@ button {
padding: 0 14px; padding: 0 14px;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 999px; border-radius: 999px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background: var(--control-surface);
color: var(--ink-soft); color: var(--ink-soft);
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
@@ -3279,13 +3439,7 @@ button {
} }
.section-tab-active { .section-tab-active {
background: background: var(--primary-command-surface);
var(--corner-sparkle),
linear-gradient(145deg, rgba(255, 255, 255, 0.72), transparent 36%),
linear-gradient(135deg, var(--accent), var(--pink));
background-size: 46px 46px, auto, auto;
background-position: right 8px top 4px, center, center;
background-repeat: no-repeat;
border-color: var(--accent-deep); border-color: var(--accent-deep);
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
@@ -3782,16 +3936,41 @@ button {
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid); background:
linear-gradient(180deg, color-mix(in srgb, var(--surface-solid) 90%, rgba(255, 255, 255, 0.08)), color-mix(in srgb, var(--surface-solid) 76%, var(--accent-soft))),
var(--glass-wash);
color: var(--ink-soft); color: var(--ink-soft);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 54%, transparent), 0 8px 18px color-mix(in srgb, var(--glass-shadow) 58%, transparent);
} }
.signal-item:hover, .signal-item:hover,
.signal-item:focus-visible { .signal-item:focus-visible {
border-color: var(--accent); border-color: var(--accent);
outline: none; outline: none;
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft), 0 10px 22px color-mix(in srgb, var(--glass-shadow) 62%, transparent);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item {
border-color: color-mix(in srgb, var(--line-strong) 68%, rgba(255, 255, 255, 0.22));
background:
linear-gradient(90deg, rgba(255, 246, 253, 0.22), rgba(255, 246, 253, 0.1) 36%, rgba(255, 185, 226, 0.08)),
linear-gradient(180deg, rgba(58, 44, 56, 0.72), rgba(36, 29, 39, 0.68));
-webkit-backdrop-filter: blur(12px) saturate(0.92);
backdrop-filter: blur(12px) saturate(0.92);
box-shadow: inset 0 1px 0 rgba(255, 246, 253, 0.32), inset 0 0 0 1px rgba(255, 185, 226, 0.1), 0 10px 22px rgba(18, 10, 17, 0.26);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item strong {
color: #fffafd;
text-shadow: 0 1px 2px rgba(48, 18, 36, 0.72);
}
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item p,
:root[data-custom-background="true"][data-theme-palette="magical-girl"] .signal-item .provider-id {
color: rgba(255, 232, 245, 0.9);
text-shadow: 0 1px 2px rgba(48, 18, 36, 0.66);
} }
.signal-item strong { .signal-item strong {
@@ -3893,6 +4072,215 @@ button {
font-weight: 700; font-weight: 700;
} }
/* ---- runtime task progress ---- */
.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-header small,
.runtime-task-current small,
.runtime-task-stage-copy small {
color: var(--ink-faint);
font-size: 12px;
line-height: 1.45;
}
.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);
}
.runtime-task-meter-track {
height: 10px;
display: block;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--line-strong) 72%, transparent);
border-radius: 999px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.08), transparent 30%),
color-mix(in srgb, var(--surface-solid) 82%, #000000 18%);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 42%, transparent);
}
.runtime-task-meter-fill {
height: 100%;
display: block;
border-radius: inherit;
background: linear-gradient(90deg, var(--accent), var(--teal), var(--gold));
box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 36%, transparent);
transition: width 260ms ease;
}
.runtime-task-meter-failed {
background: linear-gradient(90deg, var(--danger), #ff9cb6);
}
.runtime-task-current {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--line));
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), color-mix(in srgb, var(--surface) 82%, var(--accent-soft));
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 12px 28px color-mix(in srgb, var(--accent) 14%, transparent);
}
.runtime-task-current-icon,
.runtime-task-stage-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--line-strong) 66%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--surface-solid) 78%, var(--accent-soft));
color: var(--accent);
box-shadow: inset 0 1px 0 var(--crystal-rim);
}
.runtime-task-current span:last-child,
.runtime-task-stage-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.runtime-task-current strong,
.runtime-task-stage-copy strong {
color: var(--ink);
font-size: 13px;
}
.runtime-task-stages {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.runtime-task-stage {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--glass-wash), rgba(255, 255, 255, 0.16);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--crystal-rim) 62%, transparent);
}
.runtime-task-stage-running {
border-color: color-mix(in srgb, var(--accent) 56%, var(--line));
}
.runtime-task-stage-completed .runtime-task-stage-icon {
color: var(--success);
border-color: color-mix(in srgb, var(--success) 52%, var(--line));
}
.runtime-task-stage-failed .runtime-task-stage-icon {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 52%, var(--line));
}
.runtime-task-stage-status {
color: var(--ink-faint);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.runtime-task-log {
display: grid;
gap: 4px;
max-height: 132px;
overflow: auto;
padding: 10px;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: 8px;
background: var(--code-surface);
color: var(--code-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.runtime-task-log span {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
overflow-wrap: anywhere;
}
.runtime-task-error {
border-color: color-mix(in srgb, var(--danger) 58%, var(--line));
color: var(--danger);
}
.runtime-task-actions button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.runtime-task-spin {
animation: runtime-task-spin 1s linear infinite;
}
@keyframes runtime-task-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.runtime-task-spin {
animation: none;
}
.runtime-task-meter-fill {
transition: none;
}
}
/* ---- narrow screens ---- */ /* ---- narrow screens ---- */
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -3906,6 +4294,7 @@ button {
} }
.management-form, .management-form,
.runtime-task-stages,
.user-management-item, .user-management-item,
.workflow-hint-grid, .workflow-hint-grid,
.provider-preset-grid, .provider-preset-grid,
+50
View File
@@ -0,0 +1,50 @@
import type { ArtifactContentChunk, ArtifactDownloadReferenceResponse } from "../api/types";
export async function downloadArtifactReference(
reference: ArtifactDownloadReferenceResponse,
readContent: (artifactId: string, offset: number, limit?: number) => Promise<ArtifactContentChunk>,
onProgress?: (progress: number) => void
) {
const chunks: ArrayBuffer[] = [];
let offset = 0;
onProgress?.(0);
while (offset < reference.sizeBytes) {
const chunk = await readContent(reference.artifactId, offset, reference.chunkSizeBytes);
chunks.push(chunk.payload);
offset += chunk.payload.byteLength;
onProgress?.(Math.min(100, Math.round((offset / reference.sizeBytes) * 100)));
if (chunk.payload.byteLength === 0) {
break;
}
}
openArtifactBlob(reference, chunks);
onProgress?.(100);
}
export function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: ArrayBuffer[]) {
if (typeof document === "undefined" || typeof URL === "undefined") {
return;
}
const blob = new Blob(chunks, { type: reference.contentType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = safeArtifactFilename(reference.filename);
anchor.rel = "noopener";
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export function safeArtifactFilename(filename: string): string {
const cleaned = filename.replace(/[\\/]/g, "").trim();
return cleaned || "artifact.bin";
}
export function safeArtifactError(error: unknown): string {
const message = error instanceof Error ? error.message : "制品传输失败";
return message.replace(/\/Users\/[^\s]+/g, "[path]").replace(/Bearer\s+[^\s]+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]");
}
+55 -32
View File
@@ -222,45 +222,37 @@ const fs = require("fs");
const manifestPath = process.argv[2]; const manifestPath = process.argv[2];
const outputPath = process.argv[3]; const outputPath = process.argv[3];
const source = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const source = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const localRunCapabilities = [
"process.install",
"process.start",
"process.stop",
"logs.read",
"run.self-update",
"dependencies.check",
"dependencies.install",
"logs.backfill",
...source.capabilities.filter((capability) => capability.startsWith("remote."))
];
const manifest = { const manifest = {
id: source.id, id: source.id,
name: source.name, name: source.name,
description: "SCUM local proof plugin", description: source.description,
version: source.version, version: source.version,
kind: "game-plugin", kind: source.kind,
tags: source.tags, tags: source.tags,
server: { server: {
type: source.server.type, type: source.server.type,
displayName: source.server.displayName, displayName: source.server.displayName,
supportedOs: ["linux"], supportedOs: source.server.supportedOS || source.server.supportedOs || [],
createFormSchema: source.server.createFormSchema createFormSchema: source.server.createFormSchema
}, },
capabilities: ["process.install", "process.start", "process.stop"], capabilities: localRunCapabilities,
permissions: ["server.create", "server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read"], permissions: source.permissions,
actions: { actions: source.actions,
install: source.actions.install, pages: source.pages,
start: source.actions.start, bridge: source.bridge,
stop: source.actions.stop, ai: source.ai,
restart: source.actions.restart remoteAccess: source.remoteAccess
},
pages: [
{
key: "overview",
title: "SCUM Overview",
path: "/overview",
permissions: ["server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read"],
bridgeActions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open"]
},
{
key: "logs",
title: "SCUM Logs",
path: "/logs",
permissions: ["server.read", "server.lifecycle", "server.logs.read", "server.artifacts.read"],
bridgeActions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open"]
}
],
bridge: { actions: ["server.instances.read", "jobs.dispatch", "logs.query", "artifacts.open"] },
ai: { purposes: [] }
}; };
fs.writeFileSync(outputPath, JSON.stringify({ fs.writeFileSync(outputPath, JSON.stringify({
manifestRef: "artifact://manifests/game.scum/0.1.0", manifestRef: "artifact://manifests/game.scum/0.1.0",
@@ -314,6 +306,14 @@ cat >"$WORK_DIR/create-scum-beta.request.json" <<JSON
} }
JSON JSON
cat >"$WORK_DIR/scum-alpha-run-generate.request.json" <<JSON
{
"targetOs": "windows",
"targetArch": "amd64",
"idempotencyKey": "local-debug-scum-alpha-run-generate"
}
JSON
printf 'creating server lifecycle workflow through platform API\n' 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 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[@]}" json_get "$API_URL/server-instances/server-local-debug" "$WORK_DIR/create-server.response.json" "${AUTH_HEADER[@]}"
@@ -336,6 +336,28 @@ SERVER_ID="$(json_id "$WORK_DIR/create-server.response.json")"
SCUM_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.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_BETA_ID="$(json_id "$WORK_DIR/create-scum-beta.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[@]}"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-runtime-actions.response.json"
require_file_contains "$WORK_DIR/scum-alpha-runtime-actions.response.json" '"key"[[:space:]]*:[[:space:]]*"generate-run"'
node - "$WORK_DIR/scum-alpha-runtime-actions.response.json" <<'NODE'
const fs = require("fs");
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const action = (response.actions || []).find((candidate) => candidate.key === "generate-run");
if (!action || action.available !== true) {
console.error("expected SCUM generate-run action to be available");
console.error(JSON.stringify(response, null, 2));
process.exit(1);
}
NODE
printf 'generating SCUM run package through platform API\n'
curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/scum-alpha-run-generate.request.json" "$API_URL/server-instances/$SCUM_ALPHA_ID/run/generate" >"$WORK_DIR/scum-alpha-run-generate.response.json"
reject_forbidden_fragments "$WORK_DIR/scum-alpha-run-generate.response.json"
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"'
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"artifactId"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha'
require_file_contains "$WORK_DIR/scum-alpha-run-generate.response.json" '"checksum"[[:space:]]*:[[:space:]]*"sha256:'
printf 'checking jobs, logs, artifacts, and marketplace refs\n' printf 'checking jobs, logs, artifacts, and marketplace refs\n'
json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json" "${AUTH_HEADER[@]}" json_get "$API_URL/server-instances" "$WORK_DIR/server-instances.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/jobs?serverInstanceId=$SERVER_ID" "$WORK_DIR/jobs.response.json" "${AUTH_HEADER[@]}" json_get "$API_URL/jobs?serverInstanceId=$SERVER_ID" "$WORK_DIR/jobs.response.json" "${AUTH_HEADER[@]}"
@@ -346,10 +368,11 @@ 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[@]}" json_get "$API_URL/plugin-marketplace/plugins" "$WORK_DIR/marketplace.response.json" "${AUTH_HEADER[@]}"
json_get "$API_URL/plugin-marketplace/plugins?serverType=scum&keyword=scum" "$WORK_DIR/scum-marketplace.response.json" "${AUTH_HEADER[@]}" json_get "$API_URL/plugin-marketplace/plugins?serverType=scum&keyword=scum" "$WORK_DIR/scum-marketplace.response.json" "${AUTH_HEADER[@]}"
require_file_contains "$WORK_DIR/scum-marketplace.response.json" '"id"[[:space:]]*:[[:space:]]*"game.scum"' require_file_contains "$WORK_DIR/scum-marketplace.response.json" '"id"[[:space:]]*:[[:space:]]*"game.scum"'
require_file_contains "$WORK_DIR/artifacts.response.json" '"id"[[:space:]]*:[[:space:]]*"artifact-run-dist-scum-alpha'
require_file_contains "$WORK_DIR/scum-alpha-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-alpha"' 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-beta-jobs.response.json" '"serverInstanceId"[[:space:]]*:[[:space:]]*"scum-beta"'
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; 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"/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
reject_forbidden_fragments "$file" reject_forbidden_fragments "$file"
done done
@@ -366,8 +389,8 @@ if [[ "$VITE_ENABLE_LOCAL_AUTH_FALLBACK" != "false" ]]; then
exit 1 exit 1
fi fi
cat >"$WORK_DIR/browser-walkthrough-checklist.md" <<EOF cat >"$WORK_DIR/local-ui-checklist.md" <<EOF
# Local Debug Browser Walkthrough # Local Debug UI Checklist
- Open $(local_debug_web_url) - Open $(local_debug_web_url)
- Login with operator.local@example.test / operator-local. - Login with operator.local@example.test / operator-local.
+1 -1
View File
@@ -170,6 +170,6 @@ Login with:
Run smoke verification: Run smoke verification:
scripts/local-debug-smoke.sh scripts/local-debug-smoke.sh
Open browser walkthrough: Open local console:
$(local_debug_web_url) $(local_debug_web_url)
EOF EOF