first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
PLATFORM_ADDR=:8080
# Platform metadata storage:
# file -> local durable JSON snapshot at PLATFORM_METADATA_PATH.
# mysql -> MySQL metadata snapshot table using PLATFORM_MYSQL_DSN.
# memory -> tests/disposable local runs only.
PLATFORM_STORAGE_BACKEND=mysql
# To use local MySQL metadata, change PLATFORM_STORAGE_BACKEND above from file to mysql,
# then uncomment and adjust:
PLATFORM_MYSQL_DSN=platform:5SZGTpX68YryfmHH@tcp(bt.npc0.com:30306)/platform?parseTime=true
PLATFORM_DATA_DIR=.platform-data
PLATFORM_METADATA_PATH=.platform-data/metadata.json
# Log bodies are intentionally separate from metadata.
# MySQL metadata still defaults logs to segmented files; do not put hundreds/thousands
# of server log lines into MySQL rows.
PLATFORM_LOG_BODY_BACKEND=file
PLATFORM_LOG_DIR=.platform-data/logs
+20
View File
@@ -0,0 +1,20 @@
PLATFORM_ADDR=:8080
# Platform metadata storage:
# file -> local durable JSON snapshot at PLATFORM_METADATA_PATH.
# mysql -> MySQL metadata snapshot table using PLATFORM_MYSQL_DSN.
# memory -> tests/disposable local runs only.
PLATFORM_STORAGE_BACKEND=file
# To use local MySQL metadata, change PLATFORM_STORAGE_BACKEND above from file to mysql,
# then uncomment and adjust:
# PLATFORM_MYSQL_DSN=platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true
PLATFORM_DATA_DIR=.platform-data
PLATFORM_METADATA_PATH=.platform-data/metadata.json
# Log bodies are intentionally separate from metadata.
# MySQL metadata still defaults logs to segmented files; do not put hundreds/thousands
# of server log lines into MySQL rows.
PLATFORM_LOG_BODY_BACKEND=file
PLATFORM_LOG_DIR=.platform-data/logs
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
# AGENTS.md for platform
This file applies to `platform/`.
## Backend Structure
Keep definitions out of business logic:
- Request/response structs go in `dto/` or a dedicated contract package.
- Database tables go in `model/` with field comments and tags before migrations or repositories reference them.
- API route declarations and handlers go in `api/`.
- Business aggregates and value objects go in `domain/`.
- Protocol payloads go in `protocol/`.
- Shared helper functions go in `shared/` only when at least two packages need them.
## API Rules
Every HTTP/API handler must have OpenAPI-style comments when implemented. Request bodies, response bodies, and errors must reference named DTO structs.
## Database Rules
Prefer model-first table definitions. Do not define table schemas only inside migration SQL. Migrations may use raw DDL only when the model remains the source of truth.
## Platform Boundaries
Plugins and platform_web must never receive run credentials, raw host paths, or AI provider keys. All access must pass through platform authorization and bounded DTOs.
+24
View File
@@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1
FROM golang:1.25.1-alpine AS build
WORKDIR /src
COPY platform/go.mod ./
RUN go mod download
COPY platform/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/platform ./cmd/platform
FROM alpine:3.21
RUN addgroup -S platform && adduser -S platform -G platform
WORKDIR /app
COPY --from=build /out/platform /app/platform
RUN mkdir -p /data/platform && chown -R platform:platform /data/platform
USER platform
EXPOSE 8080
ENV PLATFORM_ADDR=:8080 \
PLATFORM_STORAGE_BACKEND=file \
PLATFORM_MYSQL_DSN="" \
PLATFORM_DATA_DIR=/data/platform \
PLATFORM_METADATA_PATH=/data/platform/metadata.json \
PLATFORM_LOG_BODY_BACKEND=file \
PLATFORM_LOG_DIR=/data/platform/logs
ENTRYPOINT ["/app/platform"]
+76
View File
@@ -0,0 +1,76 @@
# platform
Backend control plane for the game server management platform.
## Responsibilities
- Users, roles, permissions, sessions, and audit.
- Game management plugin installation metadata and marketplace views.
- Server instance records and lifecycle orchestration.
- AI provider configuration and platform-mediated AI invocation.
- Run registration, capabilities, jobs, artifacts, log stream metadata, and storage adapters.
## Required Directory Plan
Implementation should use dedicated directories for:
- `api/`: route wiring and HTTP/gRPC adapters.
- `dto/`: request and response structures.
- `domain/`: business types and aggregates.
- `model/`: database models only.
- `repo/`: repository interfaces and persistence implementations.
- `service/`: use cases and orchestration.
- `protocol/`: run, plugin, artifact, log, and AI contracts.
- `validator/`: validation rules.
- `config/`: configuration structures and loading.
- `shared/`: small shared helpers.
Do not put DTOs, database models, or protocol structs inside handlers or service functions.
## Development Baseline
Tooling:
- Go 1.25.1.
- Module: `browser.local/platform`.
Commands:
```bash
go test ./...
go run ./cmd/platform
```
Runtime configuration:
- `PLATFORM_ADDR`: local listen address, default `:8080`.
- `PLATFORM_STORAGE_BACKEND`: storage backend, default `file`; use `memory` only for tests or disposable local runs.
- `PLATFORM_MYSQL_DSN`: MySQL DSN used when `PLATFORM_STORAGE_BACKEND=mysql`, for example `platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true`.
- `PLATFORM_DATA_DIR`: default platform data directory, default `.platform-data`.
- `PLATFORM_METADATA_PATH`: file-backed metadata snapshot path, default `.platform-data/metadata.json`.
- `PLATFORM_LOG_BODY_BACKEND`: log body backend, default follows metadata backend except MySQL uses `file`; supported values are `file` and `memory`.
- `PLATFORM_LOG_DIR`: segmented log body directory, default `.platform-data/logs`.
MySQL configuration example:
```bash
export PLATFORM_STORAGE_BACKEND=mysql
export PLATFORM_MYSQL_DSN='platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true'
export PLATFORM_LOG_BODY_BACKEND=file
export PLATFORM_LOG_DIR=.platform-data/logs
go run ./cmd/platform
```
MySQL is the platform metadata database here. It stores the platform metadata snapshot table and should later hold normalized users/plugins/servers/jobs/audit/log stream indexes. It is not the high-volume log body store; keep log bodies in segmented files locally, or add a future ClickHouse/Loki/OpenSearch/object-storage `LogBodyStore` adapter for production scale.
For local direct debugging, copy `platform/.env.example` to `platform/.env`, edit the values, and run:
```bash
go run ./cmd/platform
```
The platform process automatically reads root `.env` and `platform/.env` before loading configuration. Explicitly exported process environment values still take precedence over values in those files.
For Docker, the root `docker-compose.yml` sets platform data under `/data/platform` and mounts it through the `platform-data` named volume.
Current executable behavior includes the platform API, local auth/session support, durable file-backed metadata, segmented log bodies, run control/job/log/artifact routes, plugin bridge dispatch, and platform-mediated AI invocation.
@@ -0,0 +1,177 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestArtifactDownloadAPIWorkflowIsPlatformMediated(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download API"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-api", ServerInstanceID: "server-download-api", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-api"})
payload := []byte("artifact payload for browser mediated download")
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-api", "job-download-api", payload, 9)
reference := postOKJSONWithAuth[dto.ArtifactDownloadReferenceResponse](t, router, "/api/v1/artifacts/artifact-download-api/download", map[string]string{}, adminSession)
if reference.ArtifactID != "artifact-download-api" || reference.DownloadURL != "/api/v1/artifacts/artifact-download-api/content" || !reference.RangeSupported || reference.ChunkSizeBytes != validator.MaxArtifactDownloadBytes {
t.Fatalf("unexpected artifact reference: %+v", reference)
}
if reference.Checksum != validator.BytesChecksum(payload) || reference.SizeBytes != int64(len(payload)) || reference.StorageBehavior == "" {
t.Fatalf("expected integrity metadata in reference, got %+v", reference)
}
contentRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-api/content?offset=9&limit=7", "", adminSession)
assertStatus(t, contentRecorder, http.StatusPartialContent)
if got, want := contentRecorder.Body.Bytes(), payload[9:16]; !bytes.Equal(got, want) {
t.Fatalf("expected range payload %q, got %q", want, got)
}
if contentRecorder.Header().Get("Content-Range") != "bytes 9-15/46" || contentRecorder.Header().Get("X-Artifact-Checksum") != validator.BytesChecksum(payload) || contentRecorder.Header().Get("X-Artifact-Content-Checksum") != validator.BytesChecksum(payload[9:16]) {
t.Fatalf("expected safe integrity headers, got %+v", contentRecorder.Header())
}
rangeRequest := httptest.NewRequest(http.MethodGet, "/api/v1/artifacts/artifact-download-api/content", nil)
rangeRequest.Header.Set("Authorization", "Bearer "+adminSession)
rangeRequest.Header.Set("Range", "bytes=0-7")
rangeRecorder := httptest.NewRecorder()
router.ServeHTTP(rangeRecorder, rangeRequest)
assertStatus(t, rangeRecorder, http.StatusPartialContent)
if !bytes.Equal(rangeRecorder.Body.Bytes(), payload[:8]) {
t.Fatalf("expected range header payload, got %q", rangeRecorder.Body.String())
}
for _, body := range []string{mustJSON(t, reference), contentRecorder.Header().Get("Content-Disposition"), contentRecorder.Header().Get("X-Artifact-Storage")} {
assertNoArtifactForbiddenFragments(t, body)
}
}
func TestArtifactDownloadAPIDeniesUnavailableAndUnauthorizedArtifacts(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
ID: "user-download-other",
DisplayName: "Other Operator",
Email: "download-other@example.test",
Roles: []string{"server-admin"},
Password: "secret-password",
}, adminSession)
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "download-other@example.test", Password: "secret-password"}).SessionID
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-download-denied", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Download Denied"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-download-denied", ServerInstanceID: "server-download-denied", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-download-denied"})
payload := []byte("download denied payload")
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-download-denied", "job-download-denied", payload, 8)
unauthorized := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-download-denied/download", map[string]string{}, otherSession)
assertErrorResponse(t, unauthorized, http.StatusForbidden, errorCodeForbidden)
unauthorizedContent := requestWithAuth(t, router, http.MethodGet, "/api/v1/artifacts/artifact-download-denied/content?limit=8", "", otherSession)
assertErrorResponse(t, unauthorizedContent, http.StatusForbidden, errorCodeForbidden)
unavailable := postJSON[dto.ArtifactResponse](t, router, "/api/v1/artifacts", dto.ArtifactCreateRequest{ID: "artifact-uploading-denied", OwnerKind: domain.ArtifactOwnerKindJob, OwnerID: "job-download-denied", SizeBytes: 12, Checksum: validator.BytesChecksum([]byte("not-complete!"))})
if unavailable.State != domain.ArtifactStateUploading {
t.Fatalf("expected uploading metadata, got %+v", unavailable)
}
unavailableDownload := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/artifacts/artifact-uploading-denied/download", map[string]string{}, adminSession)
assertErrorResponse(t, unavailableDownload, http.StatusBadRequest, errorCodeValidation)
for _, body := range []string{unauthorized.Body.String(), unauthorizedContent.Body.String(), unavailableDownload.Body.String()} {
assertNoArtifactForbiddenFragments(t, body)
}
}
func TestPluginBridgeArtifactOpenReturnsSafeReference(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, artifactDownloadHelloRequest()))
registration := validGamePluginManifestRegistrationRequest()
registration.Manifest.Bridge.Actions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)}
registration.Manifest.Pages[0].Permissions = []string{"server.read", "server.artifacts.read"}
registration.Manifest.Pages[0].BridgeActions = []string{string(domain.PluginBridgeActionServerInstancesRead), string(domain.PluginBridgeActionArtifactsOpen)}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", registration)
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-bridge-artifact", PluginID: "game.example", RunEndpointID: "run-local", Name: "Bridge Artifact"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-bridge-artifact", ServerInstanceID: instance.ID, RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-bridge-artifact"})
payload := []byte("bridge artifact reference payload")
uploadCompletedArtifact(t, router, hello.SessionToken, "artifact-bridge-open", "job-bridge-artifact", payload, 8)
bridge := postOKJSONWithAuth[dto.PluginBridgeExecuteResponse](t, router, "/api/v1/plugin-bridge/execute", dto.PluginBridgeExecuteRequest{
RequestID: "bridge-artifact-open",
PluginID: "game.example",
RouteKey: "logs",
ServerInstanceID: instance.ID,
Action: string(domain.PluginBridgeActionArtifactsOpen),
Payload: map[string]string{"artifactId": "artifact-bridge-open"},
}, adminSession)
if bridge.Status != "ok" || bridge.Result["downloadUrl"] != "/api/v1/artifacts/artifact-bridge-open/content" || bridge.Result["sizeBytes"] == "" || bridge.Result["checksum"] != validator.BytesChecksum(payload) {
t.Fatalf("expected safe artifact reference through bridge, got %+v", bridge)
}
assertNoArtifactForbiddenFragments(t, mustJSON(t, bridge))
}
func uploadCompletedArtifact(t *testing.T, router http.Handler, sessionToken string, artifactID string, jobID string, payload []byte, chunkSize int) dto.ArtifactTransferCompleteResponse {
t.Helper()
open := dto.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: artifactID,
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: jobID,
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: artifactID + "-upload",
}
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, open))
for index := 0; index < opened.TotalChunks; index++ {
offset := index * chunkSize
end := offset + chunkSize
if end > len(payload) {
end = len(payload)
}
part := payload[offset:end]
chunk := dto.ArtifactChunkUploadRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
TransferID: opened.TransferID,
ArtifactID: artifactID,
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(part),
Checksum: validator.BytesChecksum(part),
Payload: part,
}
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", chunk), http.StatusOK)
}
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: artifactID, Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertStatus(t, completeRecorder, http.StatusOK)
return decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder)
}
func artifactDownloadHelloRequest() dto.RunControlHelloRequest {
request := validRunControlHelloRequest()
request.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.install", "process.start", "process.stop", "logs.read", "files.read", "artifacts.read", "ai.invoke"}
request.CapabilityReport.Fingerprint = "cap-artifact-download"
return request
}
func assertNoArtifactForbiddenFragments(t *testing.T, body string) {
t.Helper()
for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "storage://", "file://"} {
if strings.Contains(body, forbidden) {
t.Fatalf("artifact response exposed forbidden fragment %q: %s", forbidden, body)
}
}
}
@@ -0,0 +1,144 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestArtifactTransferAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
payload := []byte("artifact payload for api upload")
openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", openRequest)
assertStatus(t, openRecorder, http.StatusOK)
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder)
if !opened.Accepted || opened.TransferID == "" || opened.TotalChunks != 4 || opened.Artifact.State != domain.ArtifactStateUploading {
t.Fatalf("unexpected open response: %+v", opened)
}
chunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
assertStatus(t, chunkRecorder, http.StatusOK)
chunk := decodeBody[dto.ArtifactChunkUploadResponse](t, chunkRecorder)
if !chunk.Accepted || chunk.NextMissingChunkIndex != 1 || len(chunk.ReceivedChunkIndexes) != 1 {
t.Fatalf("unexpected chunk response: %+v", chunk)
}
duplicateRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8))
assertStatus(t, duplicateRecorder, http.StatusOK)
duplicate := decodeBody[dto.ArtifactChunkUploadResponse](t, duplicateRecorder)
if !duplicate.Duplicate {
t.Fatalf("expected duplicate chunk ack, got %+v", duplicate)
}
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1"})
assertStatus(t, statusRecorder, http.StatusOK)
status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder)
if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
t.Fatalf("unexpected status response: %+v", status)
}
missingComplete := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertErrorResponse(t, missingComplete, http.StatusBadRequest, errorCodeValidation)
for index := 1; index < opened.TotalChunks; index++ {
partRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, index, 8))
assertStatus(t, partRecorder, http.StatusOK)
}
completeRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/complete", dto.ArtifactTransferCompleteRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
assertStatus(t, completeRecorder, http.StatusOK)
complete := decodeBody[dto.ArtifactTransferCompleteResponse](t, completeRecorder)
if !complete.Accepted || !complete.Completed || complete.Artifact.State != domain.ArtifactStateAvailable {
t.Fatalf("unexpected complete response: %+v", complete)
}
}
func TestArtifactTransferAPIErrors(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
payload := []byte("artifact payload")
openRequest := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
opened := decodeBody[dto.ArtifactTransferOpenResponse](t, performArtifactTransferOpen(t, router, openRequest))
badChunk := validArtifactChunkRequest(hello.SessionToken, opened.TransferID, payload, 0, 8)
badChunk.Checksum = validator.BytesChecksum([]byte("different"))
badChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", badChunk)
assertErrorResponse(t, badChunkRecorder, http.StatusBadRequest, errorCodeValidation)
invalidSession := validArtifactTransferOpenRequest("stale-token", payload, 8)
invalidSession.ArtifactID = "artifact-invalid-session"
invalidSession.IdempotencyKey = "artifact-invalid-session"
invalidSessionRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidSession)
assertErrorResponse(t, invalidSessionRecorder, http.StatusBadRequest, errorCodeValidation)
invalidOwner := validArtifactTransferOpenRequest(hello.SessionToken, payload, 8)
invalidOwner.OwnerKind = domain.ArtifactOwnerKindPlatform
invalidOwner.ArtifactID = "artifact-invalid-owner"
invalidOwner.IdempotencyKey = "artifact-invalid-owner"
invalidOwnerRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", invalidOwner)
assertErrorResponse(t, invalidOwnerRecorder, http.StatusBadRequest, errorCodeValidation)
methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/artifacts/open", "")
assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed)
}
func createArtifactTransferAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse {
t.Helper()
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read", "files.read")
helloRequest.CapabilityReport.Fingerprint = "cap-artifacts"
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1"}, adminSession)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-1", ServerInstanceID: "server-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-start"})
return hello
}
func performArtifactTransferOpen(t *testing.T, router http.Handler, request dto.ArtifactTransferOpenRequest) *httptest.ResponseRecorder {
t.Helper()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", request)
assertStatus(t, recorder, http.StatusOK)
return recorder
}
func validArtifactTransferOpenRequest(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest {
return dto.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: "artifact-1",
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: "job-1",
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: "artifact-upload-1",
}
}
func validArtifactChunkRequest(sessionToken string, transferID string, payload []byte, index int, chunkSize int) dto.ArtifactChunkUploadRequest {
offset := index * chunkSize
end := offset + chunkSize
if end > len(payload) {
end = len(payload)
}
part := payload[offset:end]
return dto.ArtifactChunkUploadRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
TransferID: transferID,
ArtifactID: "artifact-1",
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(part),
Checksum: validator.BytesChecksum(part),
Payload: part,
}
}
@@ -0,0 +1,241 @@
package api
import (
"fmt"
"net/http"
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestRunChannelAPIInterleavedRequestsMutateIndependentState(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
createOnlyLogStreamForChannelIsolation(t, router)
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{"process.start"},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, claimRecorder, http.StatusOK)
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
if !claim.HasJob || claim.Job.JobID != "job-1" {
t.Fatalf("expected claimed job, got %+v", claim)
}
payload := []byte("interleaved artifact payload for api isolation")
openRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/open", artifactOpenForChannelIsolation(hello.SessionToken, payload, 8))
assertStatus(t, openRecorder, http.StatusOK)
open := decodeBody[dto.ArtifactTransferOpenResponse](t, openRecorder)
firstChunkRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/chunks", artifactChunkForChannelIsolation(hello.SessionToken, open.TransferID, payload, 0, 8))
assertStatus(t, firstChunkRecorder, http.StatusOK)
firstChunk := decodeBody[dto.ArtifactChunkUploadResponse](t, firstChunkRecorder)
if !firstChunk.Accepted || firstChunk.NextMissingChunkIndex != 1 {
t.Fatalf("expected first artifact chunk ack, got %+v", firstChunk)
}
heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-artifacts",
Capacity: dto.RunCapacityResponse{MaxJobs: 4, RunningJobs: 1},
})
assertStatus(t, heartbeatRecorder, http.StatusOK)
heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder)
if !heartbeat.Accepted {
t.Fatalf("expected heartbeat accepted, got %+v", heartbeat)
}
ack := postRunJobAck(t, router, dto.RunJobAckRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Message: "job accepted while artifact transfer is active",
})
if ack.Job.State != domain.JobStateRunning {
t.Fatalf("expected running job after ack, got %+v", ack)
}
logBatch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
logBatch.LogStreamID = "log-channel-isolation"
logRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", logBatch)
assertStatus(t, logRecorder, http.StatusOK)
logAck := decodeBody[dto.LogBatchIngestResponse](t, logRecorder)
if !logAck.Accepted || logAck.LatestSeq != 1 {
t.Fatalf("expected log ack independent from artifact transfer, got %+v", logAck)
}
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: dto.JobProgressBody{Percent: 100, Message: "done"},
ResultRef: "artifact://jobs/job-1/result",
Message: "done",
})
assertStatus(t, resultRecorder, http.StatusOK)
result := decodeBody[dto.RunJobResultResponse](t, resultRecorder)
if result.Job.State != domain.JobStateSucceeded || result.Job.ResultRef == "" {
t.Fatalf("expected terminal result independent from transfer, got %+v", result)
}
statusRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/artifacts/status", dto.ArtifactTransferStatusRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
TransferID: open.TransferID,
ArtifactID: "artifact-channel-isolation",
})
assertStatus(t, statusRecorder, http.StatusOK)
status := decodeBody[dto.ArtifactTransferStatusResponse](t, statusRecorder)
if status.Completed || status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
t.Fatalf("artifact state should remain independent after heartbeat/job/log calls, got %+v", status)
}
stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-channel-isolation")
if stream.LatestSeq != 1 {
t.Fatalf("expected log stream latest seq updated independently, got %+v", stream)
}
job := getJSON[dto.JobResponse](t, router, "/api/v1/jobs/job-1")
if job.State != domain.JobStateSucceeded {
t.Fatalf("expected job terminal state preserved, got %+v", job)
}
}
func TestLightweightRunRoutesRejectHeavyChannelPayloads(t *testing.T) {
router := newTestRouter()
hello := createArtifactTransferAPIFixtures(t, router)
createOnlyLogStreamForChannelIsolation(t, router)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
ID: "job-heavy-payload",
ServerInstanceID: "server-1",
RunEndpointID: "run-local",
Capability: "process.start",
IdempotencyKey: "heavy-payload-job",
})
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{"process.start"},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, claimRecorder, http.StatusOK)
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
for _, tc := range []struct {
name string
path string
body string
}{
{
name: "heartbeat rejects artifact chunk fields",
path: "/api/v1/run/control/heartbeat",
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4},"payload":"AAAA","transferId":"transfer-1","hostPath":"/Users/tasia/server"}`, hello.SessionToken),
},
{
name: "job result rejects inline logs and sockets",
path: "/api/v1/run/jobs/result",
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"jobId":%q,"leaseToken":%q,"attempt":%d,"state":"succeeded","progress":{"percent":100},"resultRef":"artifact://jobs/job-heavy-payload/result","entries":[{"seq":1,"line":"log"}],"directSocket":"unix:///tmp/run.sock"}`, hello.SessionToken, claim.Job.JobID, claim.Job.LeaseToken, claim.Job.Attempt),
},
{
name: "log ingest rejects artifact transfer payload",
path: "/api/v1/run/logs/batches",
body: fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"logStreamId":"log-channel-isolation","serverInstanceId":"server-1","streamKey":"stdout","source":"process","firstSeq":1,"lastSeq":1,"compression":"none","checksum":"sha256:bad","entries":[{"seq":1,"timestamp":"2026-07-03T12:00:01Z","line":"line"}],"payload":"AAAA","transferId":"transfer-1"}`, hello.SessionToken),
},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := performRaw(t, router, http.MethodPost, tc.path, tc.body)
assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest)
})
}
}
func createLogStreamForChannelIsolation(t *testing.T, router http.Handler) {
t.Helper()
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-1",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "SCUM #1",
}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-channel-isolation",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
}
func createOnlyLogStreamForChannelIsolation(t *testing.T, router http.Handler) {
t.Helper()
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-channel-isolation",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
}
func artifactOpenForChannelIsolation(sessionToken string, payload []byte, chunkSize int) dto.ArtifactTransferOpenRequest {
return dto.ArtifactTransferOpenRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: "artifact-channel-isolation",
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: "job-1",
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: "artifact-channel-isolation",
}
}
func artifactChunkForChannelIsolation(sessionToken string, transferID string, payload []byte, index int, chunkSize int) dto.ArtifactChunkUploadRequest {
offset := index * chunkSize
end := offset + chunkSize
if end > len(payload) {
end = len(payload)
}
part := payload[offset:end]
return dto.ArtifactChunkUploadRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
TransferID: transferID,
ArtifactID: "artifact-channel-isolation",
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(part),
Checksum: validator.BytesChecksum(part),
Payload: part,
}
}
func TestRunChannelAPIHeavyPayloadRejectionsDoNotMutateState(t *testing.T) {
router := newTestRouter()
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest()))
recorder := performRaw(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", fmt.Sprintf(`{"runEndpointId":"run-local","sessionToken":%q,"version":"0.1.1","status":"online","capabilityFingerprint":"cap-jobs","capacity":{"maxJobs":4,"runningJobs":1},"payload":"AAAA"}`, hello.SessionToken))
assertErrorResponse(t, recorder, http.StatusBadRequest, errorCodeBadRequest)
endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local")
if endpoint.Capacity.RunningJobs != 0 || strings.Contains(endpoint.Capacity.Summary, "AAAA") {
t.Fatalf("rejected heartbeat must not mutate endpoint capacity or store heavy payload, got %+v", endpoint)
}
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
func TestRunControlAPIHelloHeartbeatWorkflow(t *testing.T) {
router := newTestRouter()
helloRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", validRunControlHelloRequest())
assertStatus(t, helloRecorder, http.StatusOK)
hello := decodeBody[dto.RunControlHelloResponse](t, helloRecorder)
if !hello.Accepted || hello.SessionToken == "" || hello.RunEndpointID != "run-local" {
t.Fatalf("expected accepted hello response, got %+v", hello)
}
endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local")
if endpoint.Status != domain.RunEndpointStatusOnline || len(endpoint.Capabilities) != 2 {
t.Fatalf("expected registered endpoint metadata, got %+v", endpoint)
}
heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v2",
Capacity: dto.RunCapacityResponse{
MaxJobs: 4,
RunningJobs: 1,
},
})
assertStatus(t, heartbeatRecorder, http.StatusOK)
heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder)
if !heartbeat.Accepted || !heartbeat.RefreshCapabilities || heartbeat.NextHeartbeatSeconds <= 0 {
t.Fatalf("expected accepted heartbeat with refresh, got %+v", heartbeat)
}
}
func TestRunControlAPIReRegistrationRotatesToken(t *testing.T) {
router := newTestRouter()
first := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
request := validRunControlHelloRequest()
request.Version = "0.2.0"
second := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, request))
if second.SessionToken == first.SessionToken {
t.Fatalf("expected new session token after re-registration, got %q", second.SessionToken)
}
}
func TestRunControlAPIErrors(t *testing.T) {
router := newTestRouter()
invalid := validRunControlHelloRequest()
invalid.RegistrationToken = ""
invalid.Capacity.RunningJobs = 8
invalid.Capacity.MaxJobs = 4
invalidHello := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", invalid)
assertErrorResponse(t, invalidHello, http.StatusBadRequest, errorCodeValidation)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
invalidHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: "stale-token",
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertErrorResponse(t, invalidHeartbeat, http.StatusBadRequest, errorCodeValidation)
validHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, validHeartbeat, http.StatusOK)
methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/control/hello", "")
assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed)
}
func performRunControlHello(t *testing.T, router http.Handler, request dto.RunControlHelloRequest) *httptest.ResponseRecorder {
t.Helper()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", request)
assertStatus(t, recorder, http.StatusOK)
return recorder
}
func validRunControlHelloRequest() dto.RunControlHelloRequest {
return dto.RunControlHelloRequest{
RegistrationToken: "registration-token",
RunEndpointID: "run-local",
DisplayName: "Local Run",
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
Platform: "darwin/arm64",
CapabilityReport: dto.RunCapabilityReport{
Capabilities: []string{"control.hello", "control.heartbeat"},
Fingerprint: "cap-v1",
},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"encoding/json"
"net/http"
"time"
"browser.local/platform/dto"
)
const serviceVersion = "0.1.0-dev"
// HealthHandler godoc
// @Summary Platform health
// @Description Returns process health for local development smoke tests.
// @Tags health
// @Success 200 {object} dto.HealthResponse
// @Router /healthz [get]
func HealthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(dto.HealthResponse{
Service: "platform",
Status: "ok",
Version: serviceVersion,
Time: time.Now().UTC().Format(time.RFC3339),
})
}
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"browser.local/platform/dto"
)
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var body dto.HealthResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Service != "platform" || body.Status != "ok" || body.Version == "" || body.Time == "" {
t.Fatalf("unexpected health body: %+v", body)
}
}
func TestHealthHandlerRejectsUnsupportedMethods(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/healthz", nil)
rec := httptest.NewRecorder()
HealthHandler(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
+160
View File
@@ -0,0 +1,160 @@
package api
import (
"net/http"
"testing"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
func TestRunJobChannelAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest()))
heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-jobs",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, heartbeatRecorder, http.StatusOK)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
ID: "job-1",
RunEndpointID: "run-local",
Capability: "process.start",
IdempotencyKey: "idem-1",
})
claimRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{"process.start"},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertStatus(t, claimRecorder, http.StatusOK)
claim := decodeBody[dto.RunJobClaimResponse](t, claimRecorder)
if !claim.HasJob || claim.Job.JobID != "job-1" || claim.Job.State != domain.JobStateAccepted {
t.Fatalf("expected claimed accepted job, got %+v", claim)
}
ack := postRunJobAck(t, router, dto.RunJobAckRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Message: "started",
})
if ack.Job.State != domain.JobStateRunning {
t.Fatalf("expected running ack, got %+v", ack)
}
progressRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/progress", dto.RunJobProgressRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Progress: dto.JobProgressBody{Percent: 60, Message: "working"},
})
assertStatus(t, progressRecorder, http.StatusOK)
progress := decodeBody[dto.RunJobProgressResponse](t, progressRecorder)
if progress.Job.Progress.Percent != 60 {
t.Fatalf("expected progress update, got %+v", progress)
}
cancelRecorder := performJSON(t, router, http.MethodPost, "/api/v1/jobs/job-1/cancel", dto.RunJobCancelRequestBody{Reason: "operator requested"})
assertStatus(t, cancelRecorder, http.StatusOK)
cancel := decodeBody[dto.RunJobCancelRequestResponse](t, cancelRecorder)
if !cancel.Accepted || cancel.Reason != "operator requested" {
t.Fatalf("expected cancel request, got %+v", cancel)
}
pollRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/cancel", dto.RunJobCancelPollRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
})
assertStatus(t, pollRecorder, http.StatusOK)
poll := decodeBody[dto.RunJobCancelPollResponse](t, pollRecorder)
if !poll.HasCancel || poll.JobID != "job-1" {
t.Fatalf("expected cancel poll result, got %+v", poll)
}
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateCancelled,
Progress: dto.JobProgressBody{Percent: 100, Message: "cancelled"},
Message: "cancelled",
})
assertStatus(t, resultRecorder, http.StatusOK)
result := decodeBody[dto.RunJobResultResponse](t, resultRecorder)
if result.Job.State != domain.JobStateCancelled {
t.Fatalf("expected cancelled result, got %+v", result)
}
reconcileRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/reconcile", dto.RunJobReconcileRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
ActiveJobIDs: []string{"local-only"},
})
assertStatus(t, reconcileRecorder, http.StatusOK)
reconcile := decodeBody[dto.RunJobReconcileResponse](t, reconcileRecorder)
if len(reconcile.ActiveJobs) != 0 || len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" {
t.Fatalf("expected no active platform jobs and one unknown local job, got %+v", reconcile)
}
}
func TestRunJobChannelAPIErrors(t *testing.T) {
router := newTestRouter()
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunJobControlHelloRequest()))
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{ID: "job-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-1"})
invalidClaim := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: "stale",
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
})
assertErrorResponse(t, invalidClaim, http.StatusBadRequest, errorCodeValidation)
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}))
invalidProgress := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/progress", dto.RunJobProgressRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Progress: dto.JobProgressBody{Percent: 101},
})
assertErrorResponse(t, invalidProgress, http.StatusBadRequest, errorCodeValidation)
badMethod := performRaw(t, router, http.MethodGet, "/api/v1/run/jobs/claim", "")
assertErrorResponse(t, badMethod, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed)
}
func postRunJobAck(t *testing.T, router http.Handler, request dto.RunJobAckRequest) dto.RunJobAckResponse {
t.Helper()
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/ack", request)
assertStatus(t, recorder, http.StatusOK)
return decodeBody[dto.RunJobAckResponse](t, recorder)
}
func validRunJobControlHelloRequest() dto.RunControlHelloRequest {
request := validRunControlHelloRequest()
request.CapabilityReport.Capabilities = append(request.CapabilityReport.Capabilities, "process.start")
request.CapabilityReport.Fingerprint = "cap-jobs"
return request
}
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"browser.local/platform/dto"
"browser.local/platform/repo"
"browser.local/platform/service"
"browser.local/platform/validator"
)
const (
errorCodeForbidden = "forbidden"
errorCodeBadRequest = "bad_request"
errorCodeDuplicate = "duplicate_resource"
errorCodeInternal = "internal_error"
errorCodeMethodNotAllowed = "method_not_allowed"
errorCodeNotFound = "not_found"
errorCodeUnauthorized = "unauthorized"
errorCodeValidation = "validation_failed"
)
func decodeJSON[T any](r *http.Request) (T, error) {
var value T
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return value, fmt.Errorf("decode json: %w", err)
}
var extra struct{}
if err := decoder.Decode(&extra); err != io.EOF {
return value, errors.New("decode json: multiple JSON values are not allowed")
}
return value, nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeAPIError(w http.ResponseWriter, status int, code string, message string, details []string) {
writeJSON(w, status, dto.ErrorResponse{
Code: code,
Message: message,
Details: details,
})
}
func writeMethodNotAllowed(w http.ResponseWriter, allow string) {
w.Header().Set("Allow", allow)
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
}
func writeServiceError(w http.ResponseWriter, err error) {
var validationErr validator.ValidationError
switch {
case errors.As(err, &validationErr):
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", validationErr.Violations)
case errors.Is(err, service.ErrUnauthorized):
writeAPIError(w, http.StatusUnauthorized, errorCodeUnauthorized, "authentication required", nil)
case errors.Is(err, service.ErrForbidden):
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, "account is not allowed to access this resource", nil)
case errors.Is(err, repo.ErrDuplicate):
writeAPIError(w, http.StatusConflict, errorCodeDuplicate, "resource already exists", nil)
case errors.Is(err, repo.ErrNotFound):
writeAPIError(w, http.StatusNotFound, errorCodeNotFound, "resource not found", nil)
default:
writeAPIError(w, http.StatusInternalServerError, errorCodeInternal, "internal server error", nil)
}
}
func writeDecodeError(w http.ResponseWriter, err error) {
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid JSON request body", []string{err.Error()})
}
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"net/http"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/validator"
)
func TestLogIngestAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
ackRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch)
assertStatus(t, ackRecorder, http.StatusOK)
ack := decodeBody[dto.LogBatchIngestResponse](t, ackRecorder)
if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 2 || ack.LatestSeq != 2 {
t.Fatalf("unexpected ack: %+v", ack)
}
stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1")
if stream.LatestSeq != 2 {
t.Fatalf("expected latest seq update, got %+v", stream)
}
queryRecorder := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "log-1", AfterSeq: 0, Limit: 1})
assertStatus(t, queryRecorder, http.StatusOK)
query := decodeBody[dto.LogStreamCursorResponse](t, queryRecorder)
if len(query.Entries) != 1 || query.Entries[0].Seq != 1 || query.NextSeq != 1 || query.LatestSeq != 2 {
t.Fatalf("unexpected query: %+v", query)
}
}
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
batch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
first := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch)
assertStatus(t, first, http.StatusOK)
duplicate := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch)
assertStatus(t, duplicate, http.StatusOK)
duplicateAck := decodeBody[dto.LogBatchIngestResponse](t, duplicate)
if !duplicateAck.Duplicate {
t.Fatalf("expected duplicate ack, got %+v", duplicateAck)
}
gap := validLogBatchRequest(t, hello.SessionToken, 3, 3)
gapRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", gap)
assertErrorResponse(t, gapRecorder, http.StatusBadRequest, errorCodeValidation)
missingQuery := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "missing", Limit: 1})
assertErrorResponse(t, missingQuery, http.StatusNotFound, errorCodeNotFound)
}
func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse {
t.Helper()
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read")
helloRequest.CapabilityReport.Fingerprint = "cap-logs"
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1"}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-1",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
return hello
}
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
t.Helper()
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
domainEntries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1)
for seq := firstSeq; seq <= lastSeq; seq++ {
entry := dto.LogEntryBody{Seq: seq, Timestamp: time.Date(2026, 7, 3, 12, 0, int(seq), 0, time.UTC), Level: "info", Line: "line"}
entries = append(entries, entry)
domainEntries = append(domainEntries, domain.LogEntry{Seq: entry.Seq, Timestamp: entry.Timestamp, Level: entry.Level, Line: entry.Line})
}
checksum, err := validator.LogEntriesChecksum(domainEntries)
if err != nil {
t.Fatalf("checksum entries: %v", err)
}
return dto.LogBatchIngestRequest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
LogStreamID: "log-1",
ServerInstanceID: "server-1",
StreamKey: "stdout",
Source: domain.LogStreamSourceProcess,
FirstSeq: firstSeq,
LastSeq: lastSeq,
Compression: "none",
Checksum: checksum,
Entries: entries,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"fmt"
"net/http"
"strings"
"browser.local/platform/config"
"browser.local/platform/repo"
"browser.local/platform/service"
)
func NewRouter() http.Handler {
router, err := NewRouterFromConfig(config.Load())
if err != nil {
panic(err)
}
return router
}
func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
store, err := storeFromConfig(cfg)
if err != nil {
return nil, err
}
logStore, err := logStoreFromConfig(cfg)
if err != nil {
return nil, err
}
core := service.NewCoreServiceWithLogStore(store, logStore)
if err := core.SeedLocalPlatformAdmin(); err != nil {
return nil, err
}
return NewRouterWithCore(core), nil
}
func NewRouterWithCore(core service.Core) http.Handler {
handlers := newCoreHandlers(core)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", HealthHandler)
handlers.register(mux)
return mux
}
func storeFromConfig(cfg config.Config) (repo.Store, error) {
switch strings.ToLower(strings.TrimSpace(cfg.StorageBackend)) {
case "", "file":
return repo.NewFileStore(cfg.MetadataPath)
case "memory":
return repo.NewMemoryStore(), nil
case "mysql":
return repo.NewMySQLStore(cfg.MySQLDSN)
default:
return nil, fmt.Errorf("unsupported platform storage backend %q", cfg.StorageBackend)
}
}
func logStoreFromConfig(cfg config.Config) (service.LogBodyStore, error) {
backend := strings.ToLower(strings.TrimSpace(cfg.LogBodyBackend))
if backend == "" {
backend = strings.ToLower(strings.TrimSpace(cfg.StorageBackend))
if backend == "mysql" {
backend = "file"
}
}
switch backend {
case "", "file":
return service.NewFileLogBodyStore(cfg.LogDir)
case "memory":
return service.NewMemoryLogBodyStore(), nil
default:
return nil, fmt.Errorf("unsupported platform log body backend %q", backend)
}
}
+201
View File
@@ -0,0 +1,201 @@
# Platform API Route Catalog
Route declarations and handler comments live in `platform/api`. Request, response, list, and error contracts live in `platform/dto`; handlers must call `platform/service.Core` rather than repositories directly.
## Implemented Core Resource Routes
All routes use JSON request and response bodies. Collection routes support `GET` for lists and `POST` for create. Detail routes support `GET` by ID. Unsupported methods return `dto.ErrorResponse` with `405`.
| Resource | Collection | Detail | DTO contracts |
| --- | --- | --- | --- |
| Users | `GET /api/v1/users`, `POST /api/v1/users` | `GET /api/v1/users/{id}`, `PUT /api/v1/users/{id}` | `UserCreateRequest`, `UserUpdateRequest`, `UserResponse`, `UserListResponse` |
| AI providers | `GET /api/v1/ai-providers`, `POST /api/v1/ai-providers`, `POST /api/v1/ai/invocations`, `POST /api/v1/ai/config-suggestions` | `GET /api/v1/ai-providers/{id}`, `PUT /api/v1/ai-providers/{id}` | `AIProviderCreateRequest`, `AIProviderUpdateRequest`, redacted `AIProviderResponse`, `AIProviderListResponse`, `AIInvocationRequest`, `AIInvocationResponse`, `LlmConfigSuggestionRequest`, `LlmConfigSuggestionResponse` |
| Game plugins | `GET /api/v1/game-plugins`, `POST /api/v1/game-plugins` | `GET /api/v1/game-plugins/{id}` | `GamePluginCreateRequest`, `GamePluginResponse`, `GamePluginListResponse` |
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` |
| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` |
| Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
| Artifacts | `GET /api/v1/artifacts`, `POST /api/v1/artifacts` | `GET /api/v1/artifacts/{id}`, `POST /api/v1/artifacts/{id}/download`, `GET /api/v1/artifacts/{id}/content` | `ArtifactCreateRequest`, `ArtifactResponse`, `ArtifactListResponse`, `ArtifactDownloadReferenceResponse`, `ArtifactContentRequest` |
| Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` |
| Audit events | `GET /api/v1/audit-events`, `POST /api/v1/audit-events` | `GET /api/v1/audit-events/{id}` | `AuditEventCreateRequest`, `AuditEventResponse`, `AuditEventListResponse` |
## Implemented Query Filters
- `GET /api/v1/users?status=active`
- `GET /api/v1/ai-providers?kind=openai&status=active`
- `GET /api/v1/game-plugins?serverType=scum&status=installed`
- `GET /api/v1/plugin-marketplace/plugins?serverType=scum&status=installed&capability=logs.read&keyword=scum`
- `GET /api/v1/server-instances?pluginId=server.scum&runEndpointId=run-local&state=draft`
- `GET /api/v1/metrics/server-instances`
- `GET /api/v1/run/endpoints?status=online`
- `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued`
- `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading`
- `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout`
- `GET /api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success`
## Implemented Authentication And Current User Actions
- `POST /api/v1/auth/register`: accept `RegisterRequest`; the first registered account becomes an active platform administrator with an authenticated session, while later registrations create pending low-privilege users and return `AuthSessionResponse` with `status=pending` and no session token.
- `POST /api/v1/auth/login`: accept `LoginRequest`, authenticate an active user by ID or email, and return `AuthSessionResponse` with a bearer session token.
- `POST /api/v1/auth/logout`: invalidate the active bearer session token and return `204`.
- `GET /api/v1/users/current`: return `CurrentUserResponse` for the bearer session.
- `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`.
- `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`.
Auth responses never expose password hashes or raw credentials. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. Tests and local fixtures may seed one explicit platform administrator account for manual login: `operator.local@example.test` / `operator-local`.
## Implemented Role-Scoped Server Access
- User-facing server instance list, detail, create, and lifecycle routes require a bearer session.
- Platform administrators can view and manage all server instances.
- Server owners and server administrators can only view and manage server instances they own or administer.
- Server instance responses include bounded `ownerUserId` and `adminUserIds` membership metadata.
- `GET /api/v1/server-instances/{id}/administrators/candidates`: lets the server owner list active non-platform-admin users that can be invited.
- `POST /api/v1/server-instances/{id}/administrators`: lets the server owner invite an active non-platform-admin user using `ServerMemberRequest`.
- `DELETE /api/v1/server-instances/{id}/administrators/{userId}`: lets the server owner remove a server-scoped administrator. The route never deletes the user account.
Server owner membership actions hide and reject platform administrators. Server administrators cannot invite or remove administrators unless they also own the target server.
## Implemented Observability And Config Read Actions
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
- `GET /api/v1/server-instances/{id}/config`: returns logical server config content, format, key, config version, and update timestamp for an authorized server instance.
Observability and config read responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
## Implemented Config Write And File Dispatch Actions
- `POST /api/v1/server-instances/{id}/config/diff`: accepts `ServerConfigDiffPreviewRequest`, validates server access, expected config version, logical config key, bounded proposed content, and returns a platform-computed `ServerConfigDiffPreviewResponse` without creating a run job.
- `POST /api/v1/server-instances/{id}/config/approve`: accepts `ServerConfigWriteApprovalRequest`, revalidates the reviewed diff, rejects stale/no-change/unsafe writes, and queues a scoped `config.write` job using `ServerConfigWriteDispatchResponse`.
- `POST /api/v1/file-operations/dispatch`: accepts `FileOperationDispatchRequest`, validates server visibility plus optional plugin permissions, rejects unsafe targets, and queues `files.read` or `files.write` jobs using logical keys and refs.
Config write and file dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents.
## Implemented AI Provider Management Actions
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
- `POST /api/v1/ai-providers/{id}/test`: run local metadata validation using `AIProviderTestResponse`; this does not call external AI services.
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly.
## Implemented Game Plugin Registry Actions
- `POST /api/v1/game-plugins/register-manifest`: accept `GamePluginManifestRegistrationRequest`, validate a game management plugin manifest, and persist installed registry metadata using `GamePluginResponse`.
Plugin registry responses include identity, description, version, server type/display metadata, manifest and create-form schema references, required run capabilities, declared scoped permissions, aggregate platform permissions, lifecycle action references, plugin pages, tags, AI purposes, validation violations for invalid records, and install status. They do not expose raw host paths, raw credentials, direct run sockets, or raw AI provider keys.
## Implemented Plugin Marketplace Actions
- `GET /api/v1/plugin-marketplace/plugins`: list bounded marketplace plugin summaries projected from installed registry metadata. Optional filters include `status`, `serverType`, `capability`, and `keyword`.
- `GET /api/v1/plugin-marketplace/plugins/{id}`: return one marketplace plugin detail using manifest-backed registry metadata.
- `POST /api/v1/plugin-marketplace/plugins/{id}/state`: accept `MarketplacePluginStateRequest` with `install`, `enable`, or `disable` and update registry install state only.
Marketplace responses include game management plugin identity, version, display metadata, server type, installed state, capabilities, pages, permissions, tags, AI purposes, and validation violations. They are a platform registry projection, not a commerce catalog, and they do not include billing, pricing, ratings, reviews, cloud host sales, raw credentials, host paths, direct run sockets, package bytes, or raw AI provider keys.
Marketplace state actions are metadata-only in this change. `install` and `enable` mark the registered plugin `installed`; `disable` marks it `disabled`. These actions do not download external packages, create run jobs, execute plugin code, write files, or contact external services.
## Implemented Plugin Bridge Actions
- `POST /api/v1/plugin-bridge/authorize`: accepts `PluginBridgeAuthorizeRequest` and returns whether an installed plugin page may use one declared bridge action with the effective route permissions.
- `POST /api/v1/plugin-bridge/execute`: accepts `PluginBridgeExecuteRequest`, repeats backend validation and authorization, and executes only mapped platform-mediated actions. Supported execution currently includes server context reads, lifecycle job dispatch for declared run capabilities, log cursor metadata queries, scoped file dispatch, artifact open references, and platform-mediated AI invocation.
Bridge execution responses are typed envelopes with `requestId`, plugin/page/server scope, action, status, result refs, and safe error codes. They do not expose bearer tokens, run sockets, host filesystem paths, raw credentials, storage backend credentials, provider base URLs, raw AI provider keys, or unbounded file/log contents.
Artifact bridge execution returns safe metadata and platform content routes only. It does not return artifact bytes through the bridge message and does not expose run endpoints, storage adapter paths, presigned backend URLs, host paths, or credentials.
## Implemented Server Lifecycle Actions
- `POST /api/v1/server-instances/workflows/create`: accept `ServerLifecycleCreateRequest`, validate plugin/run dependencies and idempotency, create an `installing` server instance, and queue a `process.install` job using `ServerLifecycleResponse`.
- `POST /api/v1/server-instances/{id}/start`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.start` job using `ServerLifecycleResponse`.
- `POST /api/v1/server-instances/{id}/stop`: accept `ServerLifecycleCommandRequest`, validate state/config version/run capability, and queue a `process.stop` job using `ServerLifecycleResponse`.
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
## Implemented Run Control Actions
- `POST /api/v1/run/control/hello`: accept `RunControlHelloRequest`, create or update run endpoint metadata, and return `RunControlHelloResponse` with a platform-issued session token.
- `POST /api/v1/run/control/heartbeat`: accept `RunControlHeartbeatRequest`, require the active session token, update heartbeat metadata, and return `RunControlHeartbeatResponse` with the next heartbeat hint and optional capability refresh request.
Run control actions carry only lightweight metadata: endpoint ID, display name, version, status, capability fingerprint/list, capacity, session token, and timing hints. They do not carry job bodies, logs, artifact chunks, host paths, raw credentials, or direct sockets.
Control is the highest-priority run-facing channel; artifact/file transfer pressure must not delay heartbeat processing or mutate endpoint capacity through heavy payload fields.
## Implemented Run Job Actions
- `POST /api/v1/run/jobs/claim`: accept `RunJobClaimRequest`, validate the active run session, lease one queued job for that endpoint, and return `RunJobClaimResponse`.
- `POST /api/v1/run/jobs/ack`: accept `RunJobAckRequest` and move an active leased job into running state.
- `POST /api/v1/run/jobs/progress`: accept `RunJobProgressRequest` and update bounded progress metadata.
- `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal job result.
- `POST /api/v1/run/jobs/cancel`: accept `RunJobCancelPollRequest` and return pending cancellation metadata for active leases.
- `POST /api/v1/run/jobs/reconcile`: accept `RunJobReconcileRequest` and return platform-known active jobs plus unknown run-reported job IDs.
- `POST /api/v1/jobs/{id}/cancel`: accept `RunJobCancelRequestBody` and record a platform cancellation request for run polling.
Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt, progress, terminal state, message, error code, result reference, and timing hints. They do not carry logs, artifact chunks, host paths, raw credentials, direct sockets, or large inline result bodies.
Job ack/progress/result/cancel/reconcile calls remain lightweight and independently valid while log batches or artifact/file chunks are queued, slow, or retrying. Equivalent duplicate terminal results remain idempotent under channel pressure.
## Implemented Log Ingest Actions
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and audit trails. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments.
## Implemented Run Artifact Actions
- `POST /api/v1/run/artifacts/open`: accept `ArtifactTransferOpenRequest`, validate active run session and scoped artifact owner, create or reuse uploading artifact metadata, and return `ArtifactTransferOpenResponse` with transfer resume state.
- `POST /api/v1/run/artifacts/chunks`: accept `ArtifactChunkUploadRequest`, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
- `POST /api/v1/run/artifacts/status`: accept `ArtifactTransferStatusRequest` and return `ArtifactTransferStatusResponse` with received chunks and next missing chunk index.
- `POST /api/v1/run/artifacts/complete`: accept `ArtifactTransferCompleteRequest`, verify all chunks and final checksum, mark the artifact available, and return `ArtifactTransferCompleteResponse`.
Run artifact actions carry bounded upload metadata and chunk payloads only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and JSON chunk payload bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
Artifact/file transfer is lower priority than control, job lifecycle metadata, and durable log ingest. Slow or retrying chunks must not block heartbeat, job ack/result delivery, cancellation/reconcile calls, or log batch acknowledgement; lightweight routes reject heavy transfer payloads rather than storing them.
## Implemented Browser Artifact Download Actions
- `GET /api/v1/artifacts/{id}`: returns authorized artifact metadata for the current bearer session.
- `POST /api/v1/artifacts/{id}/download`: returns `ArtifactDownloadReferenceResponse` with filename, content type, size, checksum, expiry, supported chunk size, and a platform-owned `downloadUrl`.
- `GET /api/v1/artifacts/{id}/content`: returns a bounded byte range using `offset`/`limit` query parameters or a `Range: bytes=start-end` header. Responses include `Content-Length`, `Accept-Ranges`, optional `Content-Range`, `X-Artifact-Checksum`, `X-Artifact-Content-Checksum`, and `X-Artifact-Storage` headers.
Browser artifact downloads require an available artifact plus user access to the owning job/server context. Platform/plugin-owned artifacts are limited to platform administrators until a future storage policy adds narrower ownership. Current content reads reconstruct completed upload chunks from the in-memory platform transfer session; durable external storage adapters are deferred behind the same service contract. Browser and plugin pages receive only platform routes and integrity metadata, never raw storage backend URLs, host paths, direct run sockets, run tokens, bearer tokens, or storage credentials.
## Error Contract
API errors use `dto.ErrorResponse`:
- `400`: malformed JSON or validation failure.
- `401`: missing or invalid bearer session token.
- `403`: valid credentials for an account that is pending, disabled, or otherwise forbidden.
- `404`: missing resource or missing dependency reported by the service layer.
- `409`: duplicate resource ID.
- `405`: unsupported method on an implemented route.
- `500`: unexpected platform error.
## Deferred Route Groups
These route groups remain documented future work beyond the currently implemented routes:
- Authorization policy routes beyond role-scoped navigation and bearer session identity.
- Run control transport beyond hello and heartbeat, including heartbeat reconciliation policies.
- External metrics collectors, browser tail transport, external log body backends, and AI log analysis windows.
- Browser artifact upload, external artifact storage backends, presigned URLs, and production throttling policies.
- Plugin page iframe packaging and remote hosting policies beyond SDK-mediated bridge contracts.
- Live AI provider connectivity tests and remote model discovery.
- Server restart/update/delete routes.
## Core Service Boundary
- `platform/service.Core` owns create/list/get workflows and cross-resource invariants.
- `platform/repo.Store` owns repository access and currently has a durable file-backed implementation for local startup plus an in-memory implementation for tests.
- `platform/validator` owns local resource validation and dependency compatibility checks.
- Handlers must never expose run credentials, host paths, or raw AI provider keys.
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// serverInstanceCreateWorkflow godoc
// @Summary Create server instance workflow
// @Description Creates a server instance through the platform-mediated lifecycle workflow and queues an install job for the selected run endpoint.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param body body dto.ServerLifecycleCreateRequest true "Server lifecycle create request"
// @Success 200 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 409 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/workflows/create [post]
func (h *coreHandlers) serverInstanceCreateWorkflow(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerLifecycleCreateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.CreateServerInstanceWorkflowForSession(bearerToken(r), request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceStart godoc
// @Summary Start server instance
// @Description Validates lifecycle state and config version, then queues a start job through the platform job channel.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
// @Success 200 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/start [post]
func (h *coreHandlers) serverInstanceStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.StartServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
// serverInstanceStop godoc
// @Summary Stop server instance
// @Description Validates lifecycle state and config version, then queues a stop job through the platform job channel.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerLifecycleCommandRequest true "Server lifecycle command request"
// @Success 200 {object} dto.ServerLifecycleResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/stop [post]
func (h *coreHandlers) serverInstanceStop(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ServerLifecycleCommandRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.StopServerInstanceForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ServerLifecycleFromDomain(result))
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"errors"
"log"
"net/http"
"time"
"browser.local/platform/api"
"browser.local/platform/config"
)
const defaultReadHeaderTimeout = 5 * time.Second
func main() {
cfg := config.Load()
router, err := api.NewRouterFromConfig(cfg)
if err != nil {
log.Fatalf("platform storage initialization failed: %v", err)
}
server := &http.Server{
Addr: cfg.Addr,
Handler: router,
ReadHeaderTimeout: defaultReadHeaderTimeout,
}
log.Printf("platform listening on %s", cfg.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("platform server failed: %v", err)
}
}
+130
View File
@@ -0,0 +1,130 @@
package config
import (
"bufio"
"os"
"path/filepath"
"strings"
)
const defaultAddr = ":8080"
const defaultDataDir = ".platform-data"
const defaultStorageBackend = "file"
type Config struct {
Addr string
StorageBackend string
MySQLDSN string
DataDir string
MetadataPath string
LogDir string
LogBodyBackend string
}
func Load() Config {
loadLocalEnvFiles()
addr := os.Getenv("PLATFORM_ADDR")
if addr == "" {
addr = defaultAddr
}
dataDir := strings.TrimSpace(os.Getenv("PLATFORM_DATA_DIR"))
if dataDir == "" {
dataDir = defaultDataDir
}
metadataPath := strings.TrimSpace(os.Getenv("PLATFORM_METADATA_PATH"))
if metadataPath == "" {
metadataPath = filepath.Join(dataDir, "metadata.json")
}
logDir := strings.TrimSpace(os.Getenv("PLATFORM_LOG_DIR"))
if logDir == "" {
logDir = filepath.Join(dataDir, "logs")
}
storageBackend := strings.TrimSpace(os.Getenv("PLATFORM_STORAGE_BACKEND"))
if storageBackend == "" {
storageBackend = defaultStorageBackend
}
logBodyBackend := strings.TrimSpace(os.Getenv("PLATFORM_LOG_BODY_BACKEND"))
return Config{
Addr: addr,
StorageBackend: storageBackend,
MySQLDSN: strings.TrimSpace(os.Getenv("PLATFORM_MYSQL_DSN")),
DataDir: dataDir,
MetadataPath: metadataPath,
LogDir: logDir,
LogBodyBackend: logBodyBackend,
}
}
func loadLocalEnvFiles() {
candidates := []string{".env", filepath.Join("platform", ".env")}
for _, path := range candidates {
loadEnvFile(path)
}
}
func loadEnvFile(path string) {
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
key, value, ok := parseEnvLine(scanner.Text())
if !ok {
continue
}
if _, exists := os.LookupEnv(key); exists {
continue
}
_ = os.Setenv(key, value)
}
}
func parseEnvLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
key, value, found := strings.Cut(line, "=")
if !found {
return "", "", false
}
key = strings.TrimSpace(key)
if key == "" || strings.ContainsAny(key, " \t") {
return "", "", false
}
value = strings.TrimSpace(stripInlineComment(strings.TrimSpace(value)))
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
return key, value, true
}
func stripInlineComment(value string) string {
inSingleQuote := false
inDoubleQuote := false
for index, char := range value {
switch char {
case '\'':
if !inDoubleQuote {
inSingleQuote = !inSingleQuote
}
case '"':
if !inSingleQuote {
inDoubleQuote = !inDoubleQuote
}
case '#':
if !inSingleQuote && !inDoubleQuote && index > 0 && value[index-1] == ' ' {
return strings.TrimSpace(value[:index])
}
}
}
return value
}
+122
View File
@@ -0,0 +1,122 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadUsesDefaultAddress(t *testing.T) {
t.Setenv("PLATFORM_ADDR", "")
t.Setenv("PLATFORM_STORAGE_BACKEND", "")
t.Setenv("PLATFORM_MYSQL_DSN", "")
t.Setenv("PLATFORM_DATA_DIR", "")
t.Setenv("PLATFORM_METADATA_PATH", "")
t.Setenv("PLATFORM_LOG_DIR", "")
t.Setenv("PLATFORM_LOG_BODY_BACKEND", "")
cfg := Load()
if cfg.Addr != defaultAddr {
t.Fatalf("expected default addr %q, got %q", defaultAddr, cfg.Addr)
}
if cfg.StorageBackend != "file" || cfg.MySQLDSN != "" || cfg.DataDir != ".platform-data" || cfg.LogBodyBackend != "" {
t.Fatalf("unexpected default storage config: %+v", cfg)
}
if cfg.MetadataPath != filepath.Join(".platform-data", "metadata.json") || cfg.LogDir != filepath.Join(".platform-data", "logs") {
t.Fatalf("unexpected default storage paths: %+v", cfg)
}
}
func TestLoadUsesConfiguredAddress(t *testing.T) {
t.Setenv("PLATFORM_ADDR", ":18080")
t.Setenv("PLATFORM_STORAGE_BACKEND", "mysql")
t.Setenv("PLATFORM_MYSQL_DSN", "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true")
t.Setenv("PLATFORM_DATA_DIR", "/tmp/platform-data")
t.Setenv("PLATFORM_METADATA_PATH", "/tmp/platform-metadata.json")
t.Setenv("PLATFORM_LOG_DIR", "/tmp/platform-logs")
t.Setenv("PLATFORM_LOG_BODY_BACKEND", "file")
cfg := Load()
if cfg.Addr != ":18080" {
t.Fatalf("expected configured addr, got %q", cfg.Addr)
}
if cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.DataDir != "/tmp/platform-data" || cfg.MetadataPath != "/tmp/platform-metadata.json" || cfg.LogDir != "/tmp/platform-logs" || cfg.LogBodyBackend != "file" {
t.Fatalf("unexpected configured storage: %+v", cfg)
}
}
func TestLoadReadsPlatformEnvFile(t *testing.T) {
clearPlatformEnv(t)
chdirTemp(t)
if err := os.Mkdir("platform", 0o755); err != nil {
t.Fatalf("create platform dir: %v", err)
}
env := strings.Join([]string{
"PLATFORM_ADDR=:19090",
"PLATFORM_STORAGE_BACKEND=mysql",
"PLATFORM_MYSQL_DSN='platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true'",
"PLATFORM_LOG_BODY_BACKEND=file",
}, "\n")
if err := os.WriteFile(filepath.Join("platform", ".env"), []byte(env), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
cfg := Load()
if cfg.Addr != ":19090" || cfg.StorageBackend != "mysql" || cfg.MySQLDSN != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" || cfg.LogBodyBackend != "file" {
t.Fatalf("expected config from platform/.env, got %+v", cfg)
}
}
func TestLoadKeepsProcessEnvOverEnvFile(t *testing.T) {
clearPlatformEnv(t)
chdirTemp(t)
t.Setenv("PLATFORM_STORAGE_BACKEND", "memory")
if err := os.WriteFile(".env", []byte("PLATFORM_STORAGE_BACKEND=mysql\nPLATFORM_MYSQL_DSN=file-dsn\n"), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
cfg := Load()
if cfg.StorageBackend != "memory" {
t.Fatalf("expected process env storage backend, got %+v", cfg)
}
if cfg.MySQLDSN != "file-dsn" {
t.Fatalf("expected missing process DSN to come from env file, got %+v", cfg)
}
}
func clearPlatformEnv(t *testing.T) {
t.Helper()
for _, key := range []string{
"PLATFORM_ADDR",
"PLATFORM_STORAGE_BACKEND",
"PLATFORM_MYSQL_DSN",
"PLATFORM_DATA_DIR",
"PLATFORM_METADATA_PATH",
"PLATFORM_LOG_DIR",
"PLATFORM_LOG_BODY_BACKEND",
} {
t.Setenv(key, "")
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
func chdirTemp(t *testing.T) {
t.Helper()
previous, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir temp: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(previous); err != nil {
t.Fatalf("restore working directory: %v", err)
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package domain
type AIInvocationRequest struct {
RequestID string
PluginID string
RouteKey string
ServerInstanceID string
Purpose string
ProviderID string
Model string
Prompt string
CurrentConfig string
ContextRefs map[string]string
}
type AIInvocationUsage struct {
ProviderID string
Model string
InputTokens int
OutputTokens int
Mocked bool
}
type AIConfigRecommendation struct {
Key string
SuggestedConfig string
DiffSummary string
}
type AIInvocationSafeError struct {
Code string
Message string
Details []string
}
type AIInvocationResponse struct {
RequestID string
Purpose string
ProviderID string
Model string
Status string
Recommendation string
ConfigRecommendation *AIConfigRecommendation
Usage AIInvocationUsage
Error *AIInvocationSafeError
}
type AIProviderInvocationResult struct {
Recommendation string
SuggestedConfig string
Usage AIInvocationUsage
Error *AIInvocationSafeError
}
func CopyAIInvocationRequest(request AIInvocationRequest) AIInvocationRequest {
request.ContextRefs = CopyStringMap(request.ContextRefs)
return request
}
func CopyAIInvocationResponse(response AIInvocationResponse) AIInvocationResponse {
if response.ConfigRecommendation != nil {
recommendation := *response.ConfigRecommendation
response.ConfigRecommendation = &recommendation
}
if response.Error != nil {
errorCopy := *response.Error
errorCopy.Details = CopyStringSlice(errorCopy.Details)
response.Error = &errorCopy
}
return response
}
+84
View File
@@ -0,0 +1,84 @@
package domain
import "time"
type ArtifactDownloadReferenceRequest struct {
ArtifactID string
}
type ArtifactDownloadReference struct {
ArtifactID string
OwnerKind ArtifactOwnerKind
OwnerID string
Filename string
ContentType string
SizeBytes int64
Checksum string
State ArtifactState
DownloadURL string
ExpiresAt time.Time
RangeSupported bool
ChunkSizeBytes int
StorageBehavior string
}
type ArtifactContentRequest struct {
ArtifactID string
Offset int64
Limit int
}
type ArtifactContent struct {
ArtifactID string
Filename string
ContentType string
Offset int64
SizeBytes int64
TotalSizeBytes int64
Checksum string
ContentChecksum string
Partial bool
RangeSupported bool
Payload []byte
StorageBehavior string
ServedAt time.Time
}
type ArtifactTransferProgress struct {
ArtifactID string
BytesRead int64
TotalSizeBytes int64
Complete bool
}
type ArtifactDownloadSafeError struct {
Code string
Message string
Details []string
}
func CopyArtifactDownloadReferenceRequest(request ArtifactDownloadReferenceRequest) ArtifactDownloadReferenceRequest {
return request
}
func CopyArtifactDownloadReference(reference ArtifactDownloadReference) ArtifactDownloadReference {
return reference
}
func CopyArtifactContentRequest(request ArtifactContentRequest) ArtifactContentRequest {
return request
}
func CopyArtifactContent(content ArtifactContent) ArtifactContent {
content.Payload = CopyBytes(content.Payload)
return content
}
func CopyArtifactTransferProgress(progress ArtifactTransferProgress) ArtifactTransferProgress {
return progress
}
func CopyArtifactDownloadSafeError(safeError ArtifactDownloadSafeError) ArtifactDownloadSafeError {
safeError.Details = CopyStringSlice(safeError.Details)
return safeError
}
+187
View File
@@ -0,0 +1,187 @@
package domain
import "time"
type ArtifactTransferDirection string
const (
ArtifactTransferDirectionUpload ArtifactTransferDirection = "upload"
)
type ArtifactTransferOpen struct {
RunEndpointID string
SessionToken string
ArtifactID string
Direction ArtifactTransferDirection
OwnerKind ArtifactOwnerKind
OwnerID string
SizeBytes int64
ChunkSizeBytes int
Checksum string
IdempotencyKey string
}
type ArtifactTransferOpenResult struct {
Accepted bool
TransferID string
Direction ArtifactTransferDirection
Artifact Artifact
TotalChunks int
ChunkSizeBytes int
ReceivedChunkIndexes []int
NextMissingChunkIndex int
Completed bool
Duplicate bool
ServerTime time.Time
}
type ArtifactChunkUpload struct {
RunEndpointID string
SessionToken string
TransferID string
ArtifactID string
ChunkIndex int
Offset int64
SizeBytes int
Checksum string
Payload []byte
}
type ArtifactChunkUploadResult struct {
Accepted bool
TransferID string
ArtifactID string
ChunkIndex int
ReceivedChunkIndexes []int
NextMissingChunkIndex int
Duplicate bool
ServerTime time.Time
}
type ArtifactTransferStatusQuery struct {
RunEndpointID string
SessionToken string
TransferID string
ArtifactID string
}
type ArtifactTransferStatusResult struct {
Accepted bool
TransferID string
ArtifactID string
Direction ArtifactTransferDirection
TotalChunks int
ChunkSizeBytes int
ReceivedChunkIndexes []int
NextMissingChunkIndex int
Completed bool
ServerTime time.Time
}
type ArtifactTransferComplete struct {
RunEndpointID string
SessionToken string
TransferID string
ArtifactID string
Checksum string
SizeBytes int64
}
type ArtifactTransferCompleteResult struct {
Accepted bool
TransferID string
Artifact Artifact
Completed bool
ServerTime time.Time
}
type ArtifactChunkRecord struct {
ChunkIndex int
Offset int64
SizeBytes int
Checksum string
Payload []byte
ReceivedAt time.Time
}
type ArtifactTransferSession struct {
TransferID string
RunEndpointID string
ArtifactID string
Direction ArtifactTransferDirection
OwnerKind ArtifactOwnerKind
OwnerID string
SizeBytes int64
ChunkSizeBytes int
Checksum string
IdempotencyKey string
TotalChunks int
ReceivedChunks map[int]ArtifactChunkRecord
Completed bool
CreatedAt time.Time
UpdatedAt time.Time
}
func CopyArtifactTransferOpen(open ArtifactTransferOpen) ArtifactTransferOpen {
return open
}
func CopyArtifactChunkUpload(chunk ArtifactChunkUpload) ArtifactChunkUpload {
chunk.Payload = CopyBytes(chunk.Payload)
return chunk
}
func CopyArtifactTransferOpenResult(result ArtifactTransferOpenResult) ArtifactTransferOpenResult {
result.Artifact = CopyArtifact(result.Artifact)
result.ReceivedChunkIndexes = CopyIntSlice(result.ReceivedChunkIndexes)
return result
}
func CopyArtifactChunkUploadResult(result ArtifactChunkUploadResult) ArtifactChunkUploadResult {
result.ReceivedChunkIndexes = CopyIntSlice(result.ReceivedChunkIndexes)
return result
}
func CopyArtifactTransferStatusResult(result ArtifactTransferStatusResult) ArtifactTransferStatusResult {
result.ReceivedChunkIndexes = CopyIntSlice(result.ReceivedChunkIndexes)
return result
}
func CopyArtifactTransferCompleteResult(result ArtifactTransferCompleteResult) ArtifactTransferCompleteResult {
result.Artifact = CopyArtifact(result.Artifact)
return result
}
func CopyArtifactChunkRecord(record ArtifactChunkRecord) ArtifactChunkRecord {
record.Payload = CopyBytes(record.Payload)
return record
}
func CopyArtifactTransferSession(session ArtifactTransferSession) ArtifactTransferSession {
if session.ReceivedChunks != nil {
chunks := make(map[int]ArtifactChunkRecord, len(session.ReceivedChunks))
for index, record := range session.ReceivedChunks {
chunks[index] = CopyArtifactChunkRecord(record)
}
session.ReceivedChunks = chunks
}
return session
}
func CopyBytes(values []byte) []byte {
if values == nil {
return nil
}
out := make([]byte, len(values))
copy(out, values)
return out
}
func CopyIntSlice(values []int) []int {
if values == nil {
return nil
}
out := make([]int, len(values))
copy(out, values)
return out
}
+81
View File
@@ -0,0 +1,81 @@
package domain
import "time"
type RunCapabilityReport struct {
Capabilities []string
Fingerprint string
}
type RunControlHello struct {
RegistrationToken string
RunEndpointID string
DisplayName string
Version string
Status RunEndpointStatus
Platform string
CapabilityReport RunCapabilityReport
Capacity RunCapacity
}
type RunControlHelloResult struct {
Accepted bool
RunEndpointID string
SessionToken string
ServerTime time.Time
HeartbeatIntervalSeconds int
FeatureFlags []string
}
type RunControlHeartbeat struct {
RunEndpointID string
SessionToken string
Version string
Status RunEndpointStatus
CapabilityFingerprint string
Capacity RunCapacity
}
type RunControlHeartbeatResult struct {
Accepted bool
RunEndpointID string
NextHeartbeatSeconds int
RefreshCapabilities bool
ServerTime time.Time
}
type RunControlSession struct {
RunEndpointID string
SessionToken string
CapabilityFingerprint string
HeartbeatIntervalSeconds int
CreatedAt time.Time
UpdatedAt time.Time
}
func CopyRunCapabilityReport(report RunCapabilityReport) RunCapabilityReport {
report.Capabilities = CopyStringSlice(report.Capabilities)
return report
}
func CopyRunControlHello(hello RunControlHello) RunControlHello {
hello.CapabilityReport = CopyRunCapabilityReport(hello.CapabilityReport)
return hello
}
func CopyRunControlHelloResult(result RunControlHelloResult) RunControlHelloResult {
result.FeatureFlags = CopyStringSlice(result.FeatureFlags)
return result
}
func CopyRunControlHeartbeat(heartbeat RunControlHeartbeat) RunControlHeartbeat {
return heartbeat
}
func CopyRunControlHeartbeatResult(result RunControlHeartbeatResult) RunControlHeartbeatResult {
return result
}
func CopyRunControlSession(session RunControlSession) RunControlSession {
return session
}
+193
View File
@@ -0,0 +1,193 @@
package domain
import "time"
type RunJobProgressReport struct {
Percent int
Message string
}
type RunJobAssignment struct {
JobID string
ServerInstanceID string
RunEndpointID string
Capability string
TargetKey string
InputRef string
IdempotencyKey string
State JobState
Progress RunJobProgressReport
ResultRef string
LeaseToken string
Attempt int
CreatedAt time.Time
UpdatedAt time.Time
}
type RunJobClaim struct {
RunEndpointID string
SessionToken string
Capabilities []string
Capacity RunCapacity
}
type RunJobClaimResult struct {
Accepted bool
RunEndpointID string
HasJob bool
Job *RunJobAssignment
NextPollSeconds int
ServerTime time.Time
}
type RunJobAck struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
Message string
}
type RunJobAckResult struct {
Accepted bool
Job RunJobAssignment
ServerTime time.Time
}
type RunJobProgress struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
Progress RunJobProgressReport
Sequence uint64
}
type RunJobProgressResult struct {
Accepted bool
Job RunJobAssignment
ServerTime time.Time
}
type RunJobResult struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
Attempt int
State JobState
Progress RunJobProgressReport
ResultRef string
Message string
ErrorCode string
}
type RunJobResultResult struct {
Accepted bool
Job RunJobAssignment
ServerTime time.Time
}
type RunJobCancelRequest struct {
JobID string
Reason string
}
type RunJobCancelRequestResult struct {
Accepted bool
JobID string
Reason string
RequestedAt time.Time
}
type RunJobCancelPoll struct {
RunEndpointID string
SessionToken string
JobID string
LeaseToken string
}
type RunJobCancelPollResult struct {
Accepted bool
RunEndpointID string
HasCancel bool
JobID string
Reason string
RequestedAt time.Time
ServerTime time.Time
}
type RunJobReconcile struct {
RunEndpointID string
SessionToken string
ActiveJobIDs []string
}
type RunJobReconcileResult struct {
Accepted bool
RunEndpointID string
ActiveJobs []RunJobAssignment
UnknownJobIDs []string
ServerTime time.Time
}
type RunJobLease struct {
JobID string
RunEndpointID string
SessionToken string
LeaseToken string
Attempt int
CancelReason string
CancelRequestedAt time.Time
TerminalFingerprint string
CreatedAt time.Time
UpdatedAt time.Time
}
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
return assignment
}
func CopyRunJobAssignmentPtr(assignment *RunJobAssignment) *RunJobAssignment {
if assignment == nil {
return nil
}
copy := CopyRunJobAssignment(*assignment)
return &copy
}
func CopyRunJobClaim(claim RunJobClaim) RunJobClaim {
claim.Capabilities = CopyStringSlice(claim.Capabilities)
return claim
}
func CopyRunJobClaimResult(result RunJobClaimResult) RunJobClaimResult {
result.Job = CopyRunJobAssignmentPtr(result.Job)
return result
}
func CopyRunJobReconcile(reconcile RunJobReconcile) RunJobReconcile {
reconcile.ActiveJobIDs = CopyStringSlice(reconcile.ActiveJobIDs)
return reconcile
}
func CopyRunJobReconcileResult(result RunJobReconcileResult) RunJobReconcileResult {
result.ActiveJobs = CopyRunJobAssignments(result.ActiveJobs)
result.UnknownJobIDs = CopyStringSlice(result.UnknownJobIDs)
return result
}
func CopyRunJobAssignments(assignments []RunJobAssignment) []RunJobAssignment {
if assignments == nil {
return nil
}
out := make([]RunJobAssignment, len(assignments))
copy(out, assignments)
return out
}
func CopyRunJobLease(lease RunJobLease) RunJobLease {
return lease
}
+93
View File
@@ -0,0 +1,93 @@
package domain
import "time"
type LogEntry struct {
Seq uint64
Timestamp time.Time
Level string
Line string
Fields map[string]string
Redacted bool
}
type LogBatchIngest struct {
RunEndpointID string
SessionToken string
LogStreamID string
ServerInstanceID string
StreamKey string
Source LogStreamSource
FirstSeq uint64
LastSeq uint64
Compression string
Checksum string
Entries []LogEntry
}
type LogBatchIngestResult struct {
Accepted bool
LogStreamID string
AcceptedFrom uint64
AcceptedTo uint64
LatestSeq uint64
Duplicate bool
ServerTime time.Time
}
type LogStreamCursorQuery struct {
LogStreamID string
AfterSeq uint64
Limit int
}
type LogStreamCursorResult struct {
LogStreamID string
Entries []LogEntry
NextSeq uint64
LatestSeq uint64
}
type LogBatchRecord struct {
Checksum string
FirstSeq uint64
LastSeq uint64
Entries []LogEntry
}
func CopyLogEntry(entry LogEntry) LogEntry {
if entry.Fields != nil {
fields := make(map[string]string, len(entry.Fields))
for key, value := range entry.Fields {
fields[key] = value
}
entry.Fields = fields
}
return entry
}
func CopyLogEntries(entries []LogEntry) []LogEntry {
if entries == nil {
return nil
}
out := make([]LogEntry, len(entries))
for i, entry := range entries {
out[i] = CopyLogEntry(entry)
}
return out
}
func CopyLogBatchIngest(batch LogBatchIngest) LogBatchIngest {
batch.Entries = CopyLogEntries(batch.Entries)
return batch
}
func CopyLogStreamCursorResult(result LogStreamCursorResult) LogStreamCursorResult {
result.Entries = CopyLogEntries(result.Entries)
return result
}
func CopyLogBatchRecord(record LogBatchRecord) LogBatchRecord {
record.Entries = CopyLogEntries(record.Entries)
return record
}
+816
View File
@@ -0,0 +1,816 @@
package domain
import "time"
type UserStatus string
const (
UserStatusActive UserStatus = "active"
UserStatusDisabled UserStatus = "disabled"
UserStatusPending UserStatus = "pending"
)
type AIProviderKind string
const (
AIProviderKindOpenAICompatible AIProviderKind = "openai-compatible"
AIProviderKindOpenAI AIProviderKind = "openai"
AIProviderKindClaude AIProviderKind = "claude"
AIProviderKindGemini AIProviderKind = "gemini"
AIProviderKindOllama AIProviderKind = "ollama"
AIProviderKindCustom AIProviderKind = "custom"
)
type AIRelayMode string
const (
AIRelayModeDirect AIRelayMode = "direct"
AIRelayModeRelay AIRelayMode = "relay"
AIRelayModeLocal AIRelayMode = "local"
)
type AIProviderStatus string
const (
AIProviderStatusActive AIProviderStatus = "active"
AIProviderStatusDisabled AIProviderStatus = "disabled"
AIProviderStatusError AIProviderStatus = "error"
)
type GamePluginStatus string
const (
GamePluginStatusInstalled GamePluginStatus = "installed"
GamePluginStatusDisabled GamePluginStatus = "disabled"
GamePluginStatusInvalid GamePluginStatus = "invalid"
GamePluginStatusUpdating GamePluginStatus = "updating"
)
type PluginMarketplaceStateAction string
const (
PluginMarketplaceStateActionInstall PluginMarketplaceStateAction = "install"
PluginMarketplaceStateActionEnable PluginMarketplaceStateAction = "enable"
PluginMarketplaceStateActionDisable PluginMarketplaceStateAction = "disable"
)
type ServerInstanceState string
const (
ServerInstanceStateDraft ServerInstanceState = "draft"
ServerInstanceStateInstalling ServerInstanceState = "installing"
ServerInstanceStateReady ServerInstanceState = "ready"
ServerInstanceStateRunning ServerInstanceState = "running"
ServerInstanceStateStopped ServerInstanceState = "stopped"
ServerInstanceStateFailed ServerInstanceState = "failed"
ServerInstanceStateDeleted ServerInstanceState = "deleted"
)
type RunEndpointStatus string
const (
RunEndpointStatusOnline RunEndpointStatus = "online"
RunEndpointStatusOffline RunEndpointStatus = "offline"
RunEndpointStatusDegraded RunEndpointStatus = "degraded"
RunEndpointStatusDisabled RunEndpointStatus = "disabled"
)
type JobState string
const (
JobStateQueued JobState = "queued"
JobStateAccepted JobState = "accepted"
JobStateRunning JobState = "running"
JobStateSucceeded JobState = "succeeded"
JobStateFailed JobState = "failed"
JobStateCancelled JobState = "cancelled"
)
type ArtifactOwnerKind string
const (
ArtifactOwnerKindPlatform ArtifactOwnerKind = "platform"
ArtifactOwnerKindPlugin ArtifactOwnerKind = "plugin"
ArtifactOwnerKindServerInstance ArtifactOwnerKind = "server-instance"
ArtifactOwnerKindJob ArtifactOwnerKind = "job"
)
type ArtifactState string
const (
ArtifactStateUploading ArtifactState = "uploading"
ArtifactStateAvailable ArtifactState = "available"
ArtifactStateExpired ArtifactState = "expired"
ArtifactStateFailed ArtifactState = "failed"
)
type LogStreamSource string
const (
LogStreamSourceProcess LogStreamSource = "process"
LogStreamSourceFile LogStreamSource = "file"
LogStreamSourcePlugin LogStreamSource = "plugin"
)
type LogStorageBackend string
const (
LogStorageBackendLocalSegments LogStorageBackend = "local-segments"
LogStorageBackendLoki LogStorageBackend = "loki"
LogStorageBackendClickHouse LogStorageBackend = "clickhouse"
LogStorageBackendOpenSearch LogStorageBackend = "opensearch"
LogStorageBackendElasticsearch LogStorageBackend = "elasticsearch"
)
type AuditResult string
const (
AuditResultSuccess AuditResult = "success"
AuditResultDenied AuditResult = "denied"
AuditResultFailed AuditResult = "failed"
AuditResultQueued AuditResult = "queued"
)
type User struct {
ID string
DisplayName string
Email string
Status UserStatus
Roles []string
PasswordHash string
Profile UserProfile
Theme UserThemePreference
CreatedAt time.Time
UpdatedAt time.Time
}
type UserProfile struct {
AvatarURL string
Phone string
QQ string
ContactNote string
}
type UserThemePreference struct {
UserID string
PaletteID string
BackgroundPresetID string
BackgroundImage string
Persistence string
UpdatedAt time.Time
}
type UserRegistration struct {
DisplayName string
Email string
Password string
Profile UserProfile
}
type UserLogin struct {
Account string
Password string
}
type AuthSession struct {
SessionID string
User User
Status string
Message string
}
type AIProvider struct {
ID string
Name string
Kind AIProviderKind
BaseURL string
APIKeyRef string
Models []string
DefaultModel string
RelayMode AIRelayMode
TimeoutMS int
Status AIProviderStatus
RedactionPolicy string
}
type AIProviderTestResult struct {
ProviderID string
Mode string
Success bool
Message string
Violations []string
}
type AIProviderModels struct {
ProviderID string
DefaultModel string
Models []string
}
type PluginPermissions struct {
AI bool
Logs bool
Files bool
Jobs bool
Artifacts bool
}
type PluginLifecycleActions struct {
Install string
Start string
Stop string
Restart string
Status string
}
type GamePluginPage struct {
Key string
Title string
Path string
Permissions []string
BridgeActions []string
}
type GamePluginBridge struct {
Actions []string
}
type GamePluginManifestServer struct {
Type string
DisplayName string
SupportedOS []string
CreateFormSchema string
}
type GamePluginManifestAI struct {
Purposes []string
}
type GamePluginManifest struct {
ID string
Name string
Description string
Version string
Kind string
Tags []string
Server GamePluginManifestServer
Bridge GamePluginBridge
Capabilities []string
Permissions []string
Actions PluginLifecycleActions
Pages []GamePluginPage
AI GamePluginManifestAI
}
type GamePluginManifestRegistration struct {
ManifestRef string
Manifest GamePluginManifest
}
type GamePlugin struct {
ID string
Name string
Description string
Version string
ServerType string
ServerDisplayName string
SupportedOS []string
ManifestRef string
CreateFormSchemaRef string
RequiredRunCapabilities []string
DeclaredPermissions []string
Permissions PluginPermissions
LifecycleActions PluginLifecycleActions
BridgeActions []string
Pages []GamePluginPage
Tags []string
AIPurposes []string
ValidationViolations []string
Status GamePluginStatus
}
type PluginMarketplacePlugin struct {
ID string
Name string
Description string
Version string
ServerType string
ServerDisplayName string
SupportedOS []string
ManifestRef string
CreateFormSchemaRef string
Capabilities []string
DeclaredPermissions []string
Permissions PluginPermissions
LifecycleActions PluginLifecycleActions
BridgeActions []string
Pages []GamePluginPage
Tags []string
AIPurposes []string
ValidationViolations []string
Status GamePluginStatus
Source string
}
type PluginBridgeAction string
const (
PluginBridgeActionServerInstancesRead PluginBridgeAction = "server.instances.read"
PluginBridgeActionJobsDispatch PluginBridgeAction = "jobs.dispatch"
PluginBridgeActionLogsQuery PluginBridgeAction = "logs.query"
PluginBridgeActionArtifactsOpen PluginBridgeAction = "artifacts.open"
PluginBridgeActionFilesRequest PluginBridgeAction = "files.request"
PluginBridgeActionAIInvoke PluginBridgeAction = "ai.invoke"
)
type PluginBridgeAuthorizeRequest struct {
PluginID string
RouteKey string
ServerInstanceID string
Action PluginBridgeAction
AIPurpose string
}
type PluginBridgeAuthorization struct {
PluginID string
RouteKey string
ServerInstanceID string
Action PluginBridgeAction
Allowed bool
RequiredPermissions []string
EffectivePermissions []string
Reason string
}
type PluginBridgeExecuteRequest struct {
RequestID string
PluginID string
RouteKey string
ServerInstanceID string
Action PluginBridgeAction
AIPurpose string
Payload map[string]string
}
type PluginBridgeSafeError struct {
Code string
Message string
Details []string
}
type PluginBridgeExecuteResponse struct {
RequestID string
PluginID string
RouteKey string
ServerInstanceID string
Action PluginBridgeAction
Status string
Result map[string]string
Error *PluginBridgeSafeError
}
type ServerInstance struct {
ID string
PluginID string
PluginVersion string
RunEndpointID string
Name string
OwnerUserID string
AdminUserIDs []string
State ServerInstanceState
ConfigVersion int
CreatedAt time.Time
UpdatedAt time.Time
}
type PlatformResourceUsage struct {
CPUPercent float64
MemoryPercent float64
DiskPercent float64
Source string
CollectedAt time.Time
}
type ServerMetrics struct {
ServerInstanceID string
Online bool
PlayerCount *int
MaxPlayers *int
TPS *float64
LatencyMS *float64
CPUPercent *float64
MemoryPercent *float64
DiskPercent *float64
Source string
CollectedAt time.Time
}
type ServerConfig struct {
ServerInstanceID string
ConfigVersion int
Format string
Key string
Content string
Source string
UpdatedAt time.Time
}
type ConfigDiffLine struct {
Kind string
OldNumber int
NewNumber int
Content string
}
type ServerConfigDiffRequest struct {
ServerInstanceID string
ExpectedConfigVersion int
Key string
ProposedContent string
ProposedContentInputRef string
}
type ServerConfigDiffPreview struct {
ServerInstanceID string
ConfigVersion int
Key string
CurrentContent string
ProposedContent string
ProposedContentInputRef string
Diff []ConfigDiffLine
HasChanges bool
Source string
ReviewedAt time.Time
}
type ServerConfigWriteApproval struct {
ServerInstanceID string
ExpectedConfigVersion int
Key string
ProposedContent string
ProposedContentInputRef string
IdempotencyKey string
}
type ServerConfigWriteDispatch struct {
Preview ServerConfigDiffPreview
Job Job
Status string
}
type FileOperationKind string
const (
FileOperationRead FileOperationKind = "read"
FileOperationWrite FileOperationKind = "write"
)
type FileOperationDispatchRequest struct {
ServerInstanceID string
PluginID string
Operation FileOperationKind
Key string
InputRef string
ExpectedConfigVersion int
IdempotencyKey string
}
type FileOperationDispatchResult struct {
ServerInstanceID string
PluginID string
Operation FileOperationKind
Key string
InputRef string
Job Job
Status string
}
type RunCapacity struct {
MaxJobs int
RunningJobs int
QueuedJobs int
Summary string
}
const (
JobCapabilityConfigWrite = "config.write"
JobCapabilityFilesRead = "files.read"
JobCapabilityFilesWrite = "files.write"
)
type RunEndpoint struct {
ID string
DisplayName string
Version string
Status RunEndpointStatus
Capabilities []string
Capacity RunCapacity
LastHeartbeatAt time.Time
}
type JobProgress struct {
Percent int
Message string
}
type Job struct {
ID string
ServerInstanceID string
RunEndpointID string
Capability string
TargetKey string
InputRef string
IdempotencyKey string
State JobState
Progress JobProgress
ResultRef string
CreatedAt time.Time
UpdatedAt time.Time
}
type Artifact struct {
ID string
OwnerKind ArtifactOwnerKind
OwnerID string
SizeBytes int64
Checksum string
State ArtifactState
CreatedAt time.Time
UpdatedAt time.Time
}
type LogStream struct {
ID string
ServerInstanceID string
Source LogStreamSource
StreamKey string
LatestSeq uint64
StorageBackend LogStorageBackend
RetentionPolicy string
CreatedAt time.Time
UpdatedAt time.Time
}
type AuditEvent struct {
ID string
ActorID string
Action string
ResourceKind string
ResourceID string
Result AuditResult
Summary string
CreatedAt time.Time
}
type UserFilter struct {
Status UserStatus
}
type AIProviderFilter struct {
Kind AIProviderKind
Status AIProviderStatus
}
type GamePluginFilter struct {
ServerType string
Status GamePluginStatus
}
type PluginMarketplaceFilter struct {
ServerType string
Status GamePluginStatus
Capability string
Keyword string
}
type ServerInstanceFilter struct {
PluginID string
RunEndpointID string
State ServerInstanceState
VisibleToUserID string
}
type RunEndpointFilter struct {
Status RunEndpointStatus
}
type JobFilter struct {
ServerInstanceID string
RunEndpointID string
State JobState
}
type ArtifactFilter struct {
OwnerKind ArtifactOwnerKind
OwnerID string
State ArtifactState
}
type LogStreamFilter struct {
ServerInstanceID string
StreamKey string
}
type AuditEventFilter struct {
ActorID string
ResourceKind string
ResourceID string
Result AuditResult
}
func CopyStringSlice(values []string) []string {
if values == nil {
return nil
}
out := make([]string, len(values))
copy(out, values)
return out
}
func CopyStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
out[key] = value
}
return out
}
func CopyUser(user User) User {
user.Roles = CopyStringSlice(user.Roles)
return user
}
func CopyAIProvider(provider AIProvider) AIProvider {
provider.Models = CopyStringSlice(provider.Models)
return provider
}
func CopyAIProviderTestResult(result AIProviderTestResult) AIProviderTestResult {
result.Violations = CopyStringSlice(result.Violations)
return result
}
func CopyAIProviderModels(models AIProviderModels) AIProviderModels {
models.Models = CopyStringSlice(models.Models)
return models
}
func CopyGamePlugin(plugin GamePlugin) GamePlugin {
plugin.RequiredRunCapabilities = CopyStringSlice(plugin.RequiredRunCapabilities)
plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions)
plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS)
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
plugin.Tags = CopyStringSlice(plugin.Tags)
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
return plugin
}
func CopyPluginMarketplacePlugin(plugin PluginMarketplacePlugin) PluginMarketplacePlugin {
plugin.SupportedOS = CopyStringSlice(plugin.SupportedOS)
plugin.Capabilities = CopyStringSlice(plugin.Capabilities)
plugin.DeclaredPermissions = CopyStringSlice(plugin.DeclaredPermissions)
plugin.BridgeActions = CopyStringSlice(plugin.BridgeActions)
plugin.Pages = CopyGamePluginPageSlice(plugin.Pages)
plugin.Tags = CopyStringSlice(plugin.Tags)
plugin.AIPurposes = CopyStringSlice(plugin.AIPurposes)
plugin.ValidationViolations = CopyStringSlice(plugin.ValidationViolations)
return plugin
}
func CopyPluginMarketplacePluginSlice(plugins []PluginMarketplacePlugin) []PluginMarketplacePlugin {
if plugins == nil {
return nil
}
out := make([]PluginMarketplacePlugin, len(plugins))
for i, plugin := range plugins {
out[i] = CopyPluginMarketplacePlugin(plugin)
}
return out
}
func CopyGamePluginManifestRegistration(registration GamePluginManifestRegistration) GamePluginManifestRegistration {
registration.Manifest = CopyGamePluginManifest(registration.Manifest)
return registration
}
func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
manifest.Tags = CopyStringSlice(manifest.Tags)
manifest.Server.SupportedOS = CopyStringSlice(manifest.Server.SupportedOS)
manifest.Bridge.Actions = CopyStringSlice(manifest.Bridge.Actions)
manifest.Capabilities = CopyStringSlice(manifest.Capabilities)
manifest.Permissions = CopyStringSlice(manifest.Permissions)
manifest.Pages = CopyGamePluginPageSlice(manifest.Pages)
manifest.AI.Purposes = CopyStringSlice(manifest.AI.Purposes)
return manifest
}
func CopyGamePluginPageSlice(pages []GamePluginPage) []GamePluginPage {
if pages == nil {
return nil
}
out := make([]GamePluginPage, len(pages))
for i, page := range pages {
out[i] = page
out[i].Permissions = CopyStringSlice(page.Permissions)
out[i].BridgeActions = CopyStringSlice(page.BridgeActions)
}
return out
}
func CopyPluginBridgeAuthorization(result PluginBridgeAuthorization) PluginBridgeAuthorization {
result.RequiredPermissions = CopyStringSlice(result.RequiredPermissions)
result.EffectivePermissions = CopyStringSlice(result.EffectivePermissions)
return result
}
func CopyPluginBridgeExecuteRequest(request PluginBridgeExecuteRequest) PluginBridgeExecuteRequest {
request.Payload = CopyStringMap(request.Payload)
return request
}
func CopyPluginBridgeExecuteResponse(response PluginBridgeExecuteResponse) PluginBridgeExecuteResponse {
response.Result = CopyStringMap(response.Result)
if response.Error != nil {
errorCopy := *response.Error
errorCopy.Details = CopyStringSlice(errorCopy.Details)
response.Error = &errorCopy
}
return response
}
func CopyServerInstance(instance ServerInstance) ServerInstance {
instance.AdminUserIDs = CopyStringSlice(instance.AdminUserIDs)
return instance
}
func CopyPlatformResourceUsage(usage PlatformResourceUsage) PlatformResourceUsage {
return usage
}
func CopyServerMetrics(metrics ServerMetrics) ServerMetrics {
return metrics
}
func CopyServerMetricsSlice(items []ServerMetrics) []ServerMetrics {
if items == nil {
return nil
}
out := make([]ServerMetrics, len(items))
copy(out, items)
return out
}
func CopyServerConfig(config ServerConfig) ServerConfig {
return config
}
func CopyConfigDiffLines(lines []ConfigDiffLine) []ConfigDiffLine {
if lines == nil {
return nil
}
out := make([]ConfigDiffLine, len(lines))
copy(out, lines)
return out
}
func CopyServerConfigDiffPreview(preview ServerConfigDiffPreview) ServerConfigDiffPreview {
preview.Diff = CopyConfigDiffLines(preview.Diff)
return preview
}
func CopyServerConfigWriteDispatch(dispatch ServerConfigWriteDispatch) ServerConfigWriteDispatch {
dispatch.Preview = CopyServerConfigDiffPreview(dispatch.Preview)
dispatch.Job = CopyJob(dispatch.Job)
return dispatch
}
func CopyFileOperationDispatchResult(result FileOperationDispatchResult) FileOperationDispatchResult {
result.Job = CopyJob(result.Job)
return result
}
func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
endpoint.Capabilities = CopyStringSlice(endpoint.Capabilities)
return endpoint
}
func CopyJob(job Job) Job {
return job
}
func CopyArtifact(artifact Artifact) Artifact {
return artifact
}
func CopyLogStream(stream LogStream) LogStream {
return stream
}
func CopyAuditEvent(event AuditEvent) AuditEvent {
return event
}
+130
View File
@@ -0,0 +1,130 @@
# Platform Domain Resources
This file defines the first platform resource contracts. Concrete Go domain structs are implemented in `platform/domain/resources.go`; API DTO projections live in `platform/dto/resources.go`; database model projections live in `platform/model/resources.go`. Do not define these resource shapes inside handlers or service functions.
## Implemented Boundaries
- Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and audit result values.
- DTO responses expose `apiKeyRef` for AI providers but never raw key material.
- Model structs include JSON/database tags and explicit `TableName()` mappings for future persistence work.
- `platform/repo.NewFileStore` provides durable local metadata snapshots for platform startup, while `platform/repo.NewMemoryStore` provides deterministic in-memory repository behavior for unit tests and disposable local runs.
- Log stream metadata records the selected body backend. The current durable local body backend uses `local-segments`; future production adapters should target log-optimized stores such as `clickhouse`, `loki`, `opensearch`, or `elasticsearch` rather than row-per-line relational tables.
- `platform/service.Core` enforces create/list/get workflows and cross-resource invariants before resources are persisted.
## User
- `id`: stable user ID.
- `displayName`: visible user name.
- `email`: optional login email.
- `status`: `active`, `disabled`, or `pending`.
- `roles`: role keys assigned to the user.
- `createdAt`: creation time.
- `updatedAt`: last update time.
## AIProvider
- `id`: stable provider ID.
- `name`: display name.
- `kind`: `openai-compatible`, `openai`, `claude`, `gemini`, `ollama`, or `custom`.
- `baseUrl`: provider or relay base URL.
- `apiKeyRef`: secret reference, never the raw key.
- `models`: allowed model IDs.
- `defaultModel`: optional default model.
- `relayMode`: `direct`, `relay`, or `local`.
- `timeoutMs`: request timeout.
- `status`: `active`, `disabled`, or `error`.
- `redactionPolicy`: policy key for prompt/input/output redaction.
## GamePlugin
- `id`: plugin ID such as `game.example`.
- `name`: display name.
- `description`: bounded marketplace/registry summary.
- `version`: installed version.
- `serverType`: game/server type key.
- `serverDisplayName`: visible server type name.
- `supportedOs`: operating systems declared by the plugin manifest.
- `manifestRef`: immutable manifest artifact reference.
- `createFormSchemaRef`: create form schema reference.
- `requiredRunCapabilities`: run capabilities required by this plugin.
- `declaredPermissions`: scoped manifest permission keys used by plugin bridge and marketplace views.
- `permissions`: aggregate platform ability declarations for AI, logs, files, jobs, and artifacts.
- `lifecycleActions`: manifest action contract references for install/start/stop and optional restart/status.
- `pages`: plugin-local page metadata with scoped permission requirements.
- `tags`: bounded catalog tags.
- `aiPurposes`: platform-mediated AI purposes such as config suggestions or log diagnosis.
- `validationViolations`: safe validation findings for invalid plugin records.
- `status`: `installed`, `disabled`, `invalid`, or `updating`.
Manifest registration uses `GamePluginManifestRegistrationRequest` at `POST /api/v1/game-plugins/register-manifest`. Platform validation repeats plugin workspace safety checks and rejects raw host paths, direct run sockets, raw credentials, and raw AI/provider keys before metadata reaches the registry.
## ServerInstance
- `id`: server instance ID.
- `pluginId`: installed game management plugin ID.
- `pluginVersion`: plugin version used to create or last reconcile the instance.
- `runEndpointId`: selected run endpoint.
- `name`: server display name.
- `state`: `draft`, `installing`, `ready`, `running`, `stopped`, `failed`, or `deleted`.
- `configVersion`: optimistic concurrency version for platform-managed config.
- `createdAt`: creation time.
- `updatedAt`: last update time.
## RunEndpoint
- `id`: run endpoint ID.
- `displayName`: visible executor name.
- `version`: run binary version.
- `status`: `online`, `offline`, `degraded`, or `disabled`.
- `capabilities`: current capability keys.
- `capacity`: current queue and resource summary.
- `lastHeartbeatAt`: last control heartbeat time.
## Job
- `id`: job ID.
- `serverInstanceId`: optional target server.
- `runEndpointId`: target run endpoint.
- `capability`: requested capability key.
- `idempotencyKey`: duplicate detection key.
- `state`: `queued`, `accepted`, `running`, `succeeded`, `failed`, or `cancelled`.
- `progress`: bounded progress summary.
- `resultRef`: optional terminal result reference.
Lifecycle workflow jobs use fixed capabilities:
- `process.install`: dispatched by server create workflow and projects successful terminal results to `ready`.
- `process.start`: dispatched by server start workflow and projects successful terminal results to `running`.
- `process.stop`: dispatched by server stop workflow and projects successful terminal results to `stopped`.
Failed or cancelled lifecycle jobs project the server instance to `failed`. Active start/stop jobs are visible through job metadata; this change does not add separate `starting` or `stopping` server states.
## Artifact
- `id`: artifact ID.
- `ownerKind`: `platform`, `plugin`, `server-instance`, or `job`.
- `ownerId`: owning resource ID.
- `sizeBytes`: expected or final size.
- `checksum`: final checksum.
- `state`: `uploading`, `available`, `expired`, or `failed`.
## LogStream
- `id`: log stream ID.
- `serverInstanceId`: target server.
- `source`: `process`, `file`, `plugin`, or custom source.
- `streamKey`: stable stream key.
- `latestSeq`: latest accepted sequence.
- `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`.
- `retentionPolicy`: retention key.
## AuditEvent
- `id`: audit event ID.
- `actorId`: user or system actor.
- `action`: stable action key.
- `resourceKind`: resource kind.
- `resourceId`: resource ID.
- `result`: `success`, `denied`, `failed`, or `queued`.
- `summary`: bounded redacted summary.
- `createdAt`: event time.
+36
View File
@@ -0,0 +1,36 @@
package domain
import "testing"
func TestCopyHelpersIsolateSlices(t *testing.T) {
plugin := GamePlugin{
ID: "server.scum",
RequiredRunCapabilities: []string{"process.start", "logs.read"},
DeclaredPermissions: []string{"server.logs.read"},
Pages: []GamePluginPage{
{Key: "logs", Permissions: []string{"server.logs.read"}},
},
AIPurposes: []string{"logs.diagnose"},
}
copy := CopyGamePlugin(plugin)
copy.RequiredRunCapabilities[0] = "files.read"
copy.DeclaredPermissions[0] = "ai.invoke"
copy.Pages[0].Permissions[0] = "ai.invoke"
copy.AIPurposes[0] = "config.suggest"
if plugin.RequiredRunCapabilities[0] != "process.start" {
t.Fatalf("expected copied plugin slice mutation not to affect original: %+v", plugin.RequiredRunCapabilities)
}
if plugin.DeclaredPermissions[0] != "server.logs.read" || plugin.Pages[0].Permissions[0] != "server.logs.read" || plugin.AIPurposes[0] != "logs.diagnose" {
t.Fatalf("expected copied plugin registry metadata mutation not to affect original: %+v", plugin)
}
provider := AIProvider{ID: "ai.openai", Models: []string{"gpt-4.1"}}
providerCopy := CopyAIProvider(provider)
providerCopy.Models[0] = "gpt-4.1-mini"
if provider.Models[0] != "gpt-4.1" {
t.Fatalf("expected copied provider slice mutation not to affect original: %+v", provider.Models)
}
}
+64
View File
@@ -0,0 +1,64 @@
package domain
type ServerLifecycleAction string
const (
ServerLifecycleActionCreate ServerLifecycleAction = "create"
ServerLifecycleActionStart ServerLifecycleAction = "start"
ServerLifecycleActionStop ServerLifecycleAction = "stop"
)
const (
LifecycleCapabilityInstall = "process.install"
LifecycleCapabilityStart = "process.start"
LifecycleCapabilityStop = "process.stop"
)
type ServerLifecycleCreate struct {
ID string
PluginID string
RunEndpointID string
Name string
OwnerUserID string
IdempotencyKey string
}
type ServerLifecycleCommand struct {
ServerInstanceID string
ExpectedConfigVersion int
IdempotencyKey string
}
type ServerLifecycleResult struct {
Accepted bool
Action ServerLifecycleAction
Instance ServerInstance
Job Job
}
func LifecycleCapabilityForAction(action ServerLifecycleAction) string {
switch action {
case ServerLifecycleActionCreate:
return LifecycleCapabilityInstall
case ServerLifecycleActionStart:
return LifecycleCapabilityStart
case ServerLifecycleActionStop:
return LifecycleCapabilityStop
default:
return ""
}
}
func CopyServerLifecycleCreate(create ServerLifecycleCreate) ServerLifecycleCreate {
return create
}
func CopyServerLifecycleCommand(command ServerLifecycleCommand) ServerLifecycleCommand {
return command
}
func CopyServerLifecycleResult(result ServerLifecycleResult) ServerLifecycleResult {
result.Instance = CopyServerInstance(result.Instance)
result.Job = CopyJob(result.Job)
return result
}
+15
View File
@@ -0,0 +1,15 @@
# platform/dto
Request and response DTOs live here. Do not define API request or response structs inside handlers.
Initial DTO groups:
- `auth`: login/session payloads.
- `users`: user and role management payloads.
- `game_plugins`: game management plugin marketplace and installation payloads.
- `server_instances`: create server, update config, lifecycle, and detail payloads.
- `ai_providers`: AI provider create/update/test/invoke payloads.
- `run`: run registration, capability, and status payloads.
- `jobs`: job claim, ack, progress, result, cancel, and reconcile payloads.
- `artifacts`: chunk upload/download and metadata payloads.
- `logs`: ingest, query, tail, and analysis-window payloads.
+108
View File
@@ -0,0 +1,108 @@
package dto
import "browser.local/platform/domain"
type AIInvocationRequest struct {
RequestID string `json:"requestId"`
PluginID string `json:"pluginId,omitempty"`
RouteKey string `json:"routeKey,omitempty"`
ServerInstanceID string `json:"serverInstanceId,omitempty"`
Purpose string `json:"purpose"`
ProviderID string `json:"providerId,omitempty"`
Model string `json:"model,omitempty"`
Prompt string `json:"prompt"`
CurrentConfig string `json:"currentConfig,omitempty"`
ContextRefs map[string]string `json:"contextRefs,omitempty"`
}
type LlmConfigSuggestionRequest struct {
ServerInstanceID string `json:"serverInstanceId"`
Prompt string `json:"prompt"`
CurrentConfig string `json:"currentConfig"`
}
type LlmConfigSuggestionResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
Recommendation string `json:"recommendation"`
SuggestedConfig string `json:"suggestedConfig,omitempty"`
}
type AIInvocationUsageResponse struct {
ProviderID string `json:"providerId"`
Model string `json:"model"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
Mocked bool `json:"mocked"`
}
type AIConfigRecommendationResponse struct {
Key string `json:"key"`
SuggestedConfig string `json:"suggestedConfig,omitempty"`
DiffSummary string `json:"diffSummary"`
}
type AIInvocationSafeErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Details []string `json:"details,omitempty"`
}
type AIInvocationResponse struct {
RequestID string `json:"requestId"`
Purpose string `json:"purpose"`
ProviderID string `json:"providerId,omitempty"`
Model string `json:"model,omitempty"`
Status string `json:"status"`
Recommendation string `json:"recommendation,omitempty"`
ConfigRecommendation *AIConfigRecommendationResponse `json:"configRecommendation,omitempty"`
Usage AIInvocationUsageResponse `json:"usage"`
Error *AIInvocationSafeErrorResponse `json:"error,omitempty"`
}
func (request AIInvocationRequest) ToDomain() domain.AIInvocationRequest {
return domain.AIInvocationRequest{
RequestID: request.RequestID,
PluginID: request.PluginID,
RouteKey: request.RouteKey,
ServerInstanceID: request.ServerInstanceID,
Purpose: request.Purpose,
ProviderID: request.ProviderID,
Model: request.Model,
Prompt: request.Prompt,
CurrentConfig: request.CurrentConfig,
ContextRefs: domain.CopyStringMap(request.ContextRefs),
}
}
func AIInvocationFromDomain(response domain.AIInvocationResponse) AIInvocationResponse {
response = domain.CopyAIInvocationResponse(response)
var config *AIConfigRecommendationResponse
if response.ConfigRecommendation != nil {
config = &AIConfigRecommendationResponse{
Key: response.ConfigRecommendation.Key,
SuggestedConfig: response.ConfigRecommendation.SuggestedConfig,
DiffSummary: response.ConfigRecommendation.DiffSummary,
}
}
var safeError *AIInvocationSafeErrorResponse
if response.Error != nil {
safeError = &AIInvocationSafeErrorResponse{Code: response.Error.Code, Message: response.Error.Message, Details: response.Error.Details}
}
return AIInvocationResponse{
RequestID: response.RequestID,
Purpose: response.Purpose,
ProviderID: response.ProviderID,
Model: response.Model,
Status: response.Status,
Recommendation: response.Recommendation,
ConfigRecommendation: config,
Usage: AIInvocationUsageResponse{
ProviderID: response.Usage.ProviderID,
Model: response.Usage.Model,
InputTokens: response.Usage.InputTokens,
OutputTokens: response.Usage.OutputTokens,
Mocked: response.Usage.Mocked,
},
Error: safeError,
}
}
+88
View File
@@ -0,0 +1,88 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type ArtifactDownloadReferenceRequest struct {
ArtifactID string `json:"artifactId"`
}
type ArtifactDownloadReferenceResponse struct {
ArtifactID string `json:"artifactId"`
OwnerKind domain.ArtifactOwnerKind `json:"ownerKind"`
OwnerID string `json:"ownerId"`
Filename string `json:"filename"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
Checksum string `json:"checksum"`
State domain.ArtifactState `json:"state"`
DownloadURL string `json:"downloadUrl"`
ExpiresAt time.Time `json:"expiresAt"`
RangeSupported bool `json:"rangeSupported"`
ChunkSizeBytes int `json:"chunkSizeBytes"`
StorageBehavior string `json:"storageBehavior"`
}
type ArtifactContentRequest struct {
ArtifactID string `json:"artifactId"`
Offset int64 `json:"offset"`
Limit int `json:"limit"`
}
type ArtifactTransferProgressResponse struct {
ArtifactID string `json:"artifactId"`
BytesRead int64 `json:"bytesRead"`
TotalSizeBytes int64 `json:"totalSizeBytes"`
Complete bool `json:"complete"`
}
type ArtifactDownloadSafeErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Details []string `json:"details,omitempty"`
}
func (request ArtifactDownloadReferenceRequest) ToDomain() domain.ArtifactDownloadReferenceRequest {
return domain.ArtifactDownloadReferenceRequest{ArtifactID: request.ArtifactID}
}
func (request ArtifactContentRequest) ToDomain() domain.ArtifactContentRequest {
return domain.ArtifactContentRequest{ArtifactID: request.ArtifactID, Offset: request.Offset, Limit: request.Limit}
}
func ArtifactDownloadReferenceFromDomain(reference domain.ArtifactDownloadReference) ArtifactDownloadReferenceResponse {
reference = domain.CopyArtifactDownloadReference(reference)
return ArtifactDownloadReferenceResponse{
ArtifactID: reference.ArtifactID,
OwnerKind: reference.OwnerKind,
OwnerID: reference.OwnerID,
Filename: reference.Filename,
ContentType: reference.ContentType,
SizeBytes: reference.SizeBytes,
Checksum: reference.Checksum,
State: reference.State,
DownloadURL: reference.DownloadURL,
ExpiresAt: reference.ExpiresAt,
RangeSupported: reference.RangeSupported,
ChunkSizeBytes: reference.ChunkSizeBytes,
StorageBehavior: reference.StorageBehavior,
}
}
func ArtifactTransferProgressFromDomain(progress domain.ArtifactTransferProgress) ArtifactTransferProgressResponse {
progress = domain.CopyArtifactTransferProgress(progress)
return ArtifactTransferProgressResponse{
ArtifactID: progress.ArtifactID,
BytesRead: progress.BytesRead,
TotalSizeBytes: progress.TotalSizeBytes,
Complete: progress.Complete,
}
}
func ArtifactDownloadSafeErrorFromDomain(safeError domain.ArtifactDownloadSafeError) ArtifactDownloadSafeErrorResponse {
safeError = domain.CopyArtifactDownloadSafeError(safeError)
return ArtifactDownloadSafeErrorResponse{Code: safeError.Code, Message: safeError.Message, Details: safeError.Details}
}
+201
View File
@@ -0,0 +1,201 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type ArtifactTransferOpenRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
ArtifactID string `json:"artifactId"`
Direction domain.ArtifactTransferDirection `json:"direction"`
OwnerKind domain.ArtifactOwnerKind `json:"ownerKind"`
OwnerID string `json:"ownerId"`
SizeBytes int64 `json:"sizeBytes"`
ChunkSizeBytes int `json:"chunkSizeBytes"`
Checksum string `json:"checksum"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ArtifactTransferOpenResponse struct {
Accepted bool `json:"accepted"`
TransferID string `json:"transferId"`
Direction domain.ArtifactTransferDirection `json:"direction"`
Artifact ArtifactResponse `json:"artifact"`
TotalChunks int `json:"totalChunks"`
ChunkSizeBytes int `json:"chunkSizeBytes"`
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
Completed bool `json:"completed"`
Duplicate bool `json:"duplicate"`
ServerTime time.Time `json:"serverTime"`
}
type ArtifactChunkUploadRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
TransferID string `json:"transferId"`
ArtifactID string `json:"artifactId"`
ChunkIndex int `json:"chunkIndex"`
Offset int64 `json:"offset"`
SizeBytes int `json:"sizeBytes"`
Checksum string `json:"checksum"`
Payload []byte `json:"payload"`
}
type ArtifactChunkUploadResponse struct {
Accepted bool `json:"accepted"`
TransferID string `json:"transferId"`
ArtifactID string `json:"artifactId"`
ChunkIndex int `json:"chunkIndex"`
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
Duplicate bool `json:"duplicate"`
ServerTime time.Time `json:"serverTime"`
}
type ArtifactTransferStatusRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
TransferID string `json:"transferId"`
ArtifactID string `json:"artifactId"`
}
type ArtifactTransferStatusResponse struct {
Accepted bool `json:"accepted"`
TransferID string `json:"transferId"`
ArtifactID string `json:"artifactId"`
Direction domain.ArtifactTransferDirection `json:"direction"`
TotalChunks int `json:"totalChunks"`
ChunkSizeBytes int `json:"chunkSizeBytes"`
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
Completed bool `json:"completed"`
ServerTime time.Time `json:"serverTime"`
}
type ArtifactTransferCompleteRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
TransferID string `json:"transferId"`
ArtifactID string `json:"artifactId"`
Checksum string `json:"checksum"`
SizeBytes int64 `json:"sizeBytes"`
}
type ArtifactTransferCompleteResponse struct {
Accepted bool `json:"accepted"`
TransferID string `json:"transferId"`
Artifact ArtifactResponse `json:"artifact"`
Completed bool `json:"completed"`
ServerTime time.Time `json:"serverTime"`
}
func (request ArtifactTransferOpenRequest) ToDomain() domain.ArtifactTransferOpen {
return domain.ArtifactTransferOpen{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
ArtifactID: request.ArtifactID,
Direction: request.Direction,
OwnerKind: request.OwnerKind,
OwnerID: request.OwnerID,
SizeBytes: request.SizeBytes,
ChunkSizeBytes: request.ChunkSizeBytes,
Checksum: request.Checksum,
IdempotencyKey: request.IdempotencyKey,
}
}
func (request ArtifactChunkUploadRequest) ToDomain() domain.ArtifactChunkUpload {
return domain.ArtifactChunkUpload{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
TransferID: request.TransferID,
ArtifactID: request.ArtifactID,
ChunkIndex: request.ChunkIndex,
Offset: request.Offset,
SizeBytes: request.SizeBytes,
Checksum: request.Checksum,
Payload: domain.CopyBytes(request.Payload),
}
}
func (request ArtifactTransferStatusRequest) ToDomain() domain.ArtifactTransferStatusQuery {
return domain.ArtifactTransferStatusQuery{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
TransferID: request.TransferID,
ArtifactID: request.ArtifactID,
}
}
func (request ArtifactTransferCompleteRequest) ToDomain() domain.ArtifactTransferComplete {
return domain.ArtifactTransferComplete{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
TransferID: request.TransferID,
ArtifactID: request.ArtifactID,
Checksum: request.Checksum,
SizeBytes: request.SizeBytes,
}
}
func ArtifactTransferOpenFromDomain(result domain.ArtifactTransferOpenResult) ArtifactTransferOpenResponse {
result = domain.CopyArtifactTransferOpenResult(result)
return ArtifactTransferOpenResponse{
Accepted: result.Accepted,
TransferID: result.TransferID,
Direction: result.Direction,
Artifact: ArtifactFromDomain(result.Artifact),
TotalChunks: result.TotalChunks,
ChunkSizeBytes: result.ChunkSizeBytes,
ReceivedChunkIndexes: result.ReceivedChunkIndexes,
NextMissingChunkIndex: result.NextMissingChunkIndex,
Completed: result.Completed,
Duplicate: result.Duplicate,
ServerTime: result.ServerTime,
}
}
func ArtifactChunkUploadFromDomain(result domain.ArtifactChunkUploadResult) ArtifactChunkUploadResponse {
result = domain.CopyArtifactChunkUploadResult(result)
return ArtifactChunkUploadResponse{
Accepted: result.Accepted,
TransferID: result.TransferID,
ArtifactID: result.ArtifactID,
ChunkIndex: result.ChunkIndex,
ReceivedChunkIndexes: result.ReceivedChunkIndexes,
NextMissingChunkIndex: result.NextMissingChunkIndex,
Duplicate: result.Duplicate,
ServerTime: result.ServerTime,
}
}
func ArtifactTransferStatusFromDomain(result domain.ArtifactTransferStatusResult) ArtifactTransferStatusResponse {
result = domain.CopyArtifactTransferStatusResult(result)
return ArtifactTransferStatusResponse{
Accepted: result.Accepted,
TransferID: result.TransferID,
ArtifactID: result.ArtifactID,
Direction: result.Direction,
TotalChunks: result.TotalChunks,
ChunkSizeBytes: result.ChunkSizeBytes,
ReceivedChunkIndexes: result.ReceivedChunkIndexes,
NextMissingChunkIndex: result.NextMissingChunkIndex,
Completed: result.Completed,
ServerTime: result.ServerTime,
}
}
func ArtifactTransferCompleteFromDomain(result domain.ArtifactTransferCompleteResult) ArtifactTransferCompleteResponse {
result = domain.CopyArtifactTransferCompleteResult(result)
return ArtifactTransferCompleteResponse{
Accepted: result.Accepted,
TransferID: result.TransferID,
Artifact: ArtifactFromDomain(result.Artifact),
Completed: result.Completed,
ServerTime: result.ServerTime,
}
}
+98
View File
@@ -0,0 +1,98 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type RunCapabilityReport struct {
Capabilities []string `json:"capabilities"`
Fingerprint string `json:"fingerprint"`
}
type RunControlHelloRequest struct {
RegistrationToken string `json:"registrationToken"`
RunEndpointID string `json:"runEndpointId"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
Status domain.RunEndpointStatus `json:"status"`
Platform string `json:"platform,omitempty"`
CapabilityReport RunCapabilityReport `json:"capabilityReport"`
Capacity RunCapacityResponse `json:"capacity"`
}
type RunControlHelloResponse struct {
Accepted bool `json:"accepted"`
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
ServerTime time.Time `json:"serverTime"`
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
FeatureFlags []string `json:"featureFlags,omitempty"`
}
type RunControlHeartbeatRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
Version string `json:"version"`
Status domain.RunEndpointStatus `json:"status"`
CapabilityFingerprint string `json:"capabilityFingerprint"`
Capacity RunCapacityResponse `json:"capacity"`
}
type RunControlHeartbeatResponse struct {
Accepted bool `json:"accepted"`
RunEndpointID string `json:"runEndpointId"`
NextHeartbeatSeconds int `json:"nextHeartbeatSeconds"`
RefreshCapabilities bool `json:"refreshCapabilities"`
ServerTime time.Time `json:"serverTime"`
}
func (request RunControlHelloRequest) ToDomain() domain.RunControlHello {
return domain.RunControlHello{
RegistrationToken: request.RegistrationToken,
RunEndpointID: request.RunEndpointID,
DisplayName: request.DisplayName,
Version: request.Version,
Status: request.Status,
Platform: request.Platform,
CapabilityReport: domain.RunCapabilityReport{
Capabilities: domain.CopyStringSlice(request.CapabilityReport.Capabilities),
Fingerprint: request.CapabilityReport.Fingerprint,
},
Capacity: capacityToDomain(request.Capacity),
}
}
func (request RunControlHeartbeatRequest) ToDomain() domain.RunControlHeartbeat {
return domain.RunControlHeartbeat{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
Version: request.Version,
Status: request.Status,
CapabilityFingerprint: request.CapabilityFingerprint,
Capacity: capacityToDomain(request.Capacity),
}
}
func RunControlHelloFromDomain(result domain.RunControlHelloResult) RunControlHelloResponse {
result = domain.CopyRunControlHelloResult(result)
return RunControlHelloResponse{
Accepted: result.Accepted,
RunEndpointID: result.RunEndpointID,
SessionToken: result.SessionToken,
ServerTime: result.ServerTime,
HeartbeatIntervalSeconds: result.HeartbeatIntervalSeconds,
FeatureFlags: result.FeatureFlags,
}
}
func RunControlHeartbeatFromDomain(result domain.RunControlHeartbeatResult) RunControlHeartbeatResponse {
return RunControlHeartbeatResponse{
Accepted: result.Accepted,
RunEndpointID: result.RunEndpointID,
NextHeartbeatSeconds: result.NextHeartbeatSeconds,
RefreshCapabilities: result.RefreshCapabilities,
ServerTime: result.ServerTime,
}
}
+8
View File
@@ -0,0 +1,8 @@
package dto
type HealthResponse struct {
Service string `json:"service"`
Status string `json:"status"`
Version string `json:"version"`
Time string `json:"time"`
}
+317
View File
@@ -0,0 +1,317 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type RunJobAssignmentResponse struct {
JobID string `json:"jobId"`
ServerInstanceID string `json:"serverInstanceId,omitempty"`
RunEndpointID string `json:"runEndpointId"`
Capability string `json:"capability"`
TargetKey string `json:"targetKey,omitempty"`
InputRef string `json:"inputRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
State domain.JobState `json:"state"`
Progress JobProgressBody `json:"progress"`
ResultRef string `json:"resultRef,omitempty"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type RunJobClaimRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
Capabilities []string `json:"capabilities"`
Capacity RunCapacityResponse `json:"capacity"`
}
type RunJobClaimResponse struct {
Accepted bool `json:"accepted"`
RunEndpointID string `json:"runEndpointId"`
HasJob bool `json:"hasJob"`
Job *RunJobAssignmentResponse `json:"job,omitempty"`
NextPollSeconds int `json:"nextPollSeconds"`
ServerTime time.Time `json:"serverTime"`
}
type RunJobAckRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
Message string `json:"message,omitempty"`
}
type RunJobAckResponse struct {
Accepted bool `json:"accepted"`
Job RunJobAssignmentResponse `json:"job"`
ServerTime time.Time `json:"serverTime"`
}
type RunJobProgressRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
Progress JobProgressBody `json:"progress"`
Sequence uint64 `json:"sequence,omitempty"`
}
type RunJobProgressResponse struct {
Accepted bool `json:"accepted"`
Job RunJobAssignmentResponse `json:"job"`
ServerTime time.Time `json:"serverTime"`
}
type RunJobResultRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId"`
LeaseToken string `json:"leaseToken"`
Attempt int `json:"attempt"`
State domain.JobState `json:"state"`
Progress JobProgressBody `json:"progress"`
ResultRef string `json:"resultRef,omitempty"`
Message string `json:"message,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
}
type RunJobResultResponse struct {
Accepted bool `json:"accepted"`
Job RunJobAssignmentResponse `json:"job"`
ServerTime time.Time `json:"serverTime"`
}
type RunJobCancelRequestBody struct {
JobID string `json:"jobId"`
Reason string `json:"reason"`
}
type RunJobCancelRequestResponse struct {
Accepted bool `json:"accepted"`
JobID string `json:"jobId"`
Reason string `json:"reason"`
RequestedAt time.Time `json:"requestedAt"`
}
type RunJobCancelPollRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
JobID string `json:"jobId,omitempty"`
LeaseToken string `json:"leaseToken,omitempty"`
}
type RunJobCancelPollResponse struct {
Accepted bool `json:"accepted"`
RunEndpointID string `json:"runEndpointId"`
HasCancel bool `json:"hasCancel"`
JobID string `json:"jobId,omitempty"`
Reason string `json:"reason,omitempty"`
RequestedAt time.Time `json:"requestedAt,omitempty"`
ServerTime time.Time `json:"serverTime"`
}
type RunJobReconcileRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
ActiveJobIDs []string `json:"activeJobIds"`
}
type RunJobReconcileResponse struct {
Accepted bool `json:"accepted"`
RunEndpointID string `json:"runEndpointId"`
ActiveJobs []RunJobAssignmentResponse `json:"activeJobs"`
UnknownJobIDs []string `json:"unknownJobIds"`
ServerTime time.Time `json:"serverTime"`
}
func (request RunJobClaimRequest) ToDomain() domain.RunJobClaim {
return domain.RunJobClaim{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
Capabilities: domain.CopyStringSlice(request.Capabilities),
Capacity: capacityToDomain(request.Capacity),
}
}
func (request RunJobAckRequest) ToDomain() domain.RunJobAck {
return domain.RunJobAck{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
JobID: request.JobID,
LeaseToken: request.LeaseToken,
Attempt: request.Attempt,
Message: request.Message,
}
}
func (request RunJobProgressRequest) ToDomain() domain.RunJobProgress {
return domain.RunJobProgress{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
JobID: request.JobID,
LeaseToken: request.LeaseToken,
Attempt: request.Attempt,
Progress: progressReportToDomain(request.Progress),
Sequence: request.Sequence,
}
}
func (request RunJobResultRequest) ToDomain() domain.RunJobResult {
return domain.RunJobResult{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
JobID: request.JobID,
LeaseToken: request.LeaseToken,
Attempt: request.Attempt,
State: request.State,
Progress: progressReportToDomain(request.Progress),
ResultRef: request.ResultRef,
Message: request.Message,
ErrorCode: request.ErrorCode,
}
}
func (request RunJobCancelRequestBody) ToDomain() domain.RunJobCancelRequest {
return domain.RunJobCancelRequest{
JobID: request.JobID,
Reason: request.Reason,
}
}
func (request RunJobCancelPollRequest) ToDomain() domain.RunJobCancelPoll {
return domain.RunJobCancelPoll{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
JobID: request.JobID,
LeaseToken: request.LeaseToken,
}
}
func (request RunJobReconcileRequest) ToDomain() domain.RunJobReconcile {
return domain.RunJobReconcile{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
ActiveJobIDs: domain.CopyStringSlice(request.ActiveJobIDs),
}
}
func RunJobClaimFromDomain(result domain.RunJobClaimResult) RunJobClaimResponse {
result = domain.CopyRunJobClaimResult(result)
return RunJobClaimResponse{
Accepted: result.Accepted,
RunEndpointID: result.RunEndpointID,
HasJob: result.HasJob,
Job: RunJobAssignmentPtrFromDomain(result.Job),
NextPollSeconds: result.NextPollSeconds,
ServerTime: result.ServerTime,
}
}
func RunJobAckFromDomain(result domain.RunJobAckResult) RunJobAckResponse {
return RunJobAckResponse{
Accepted: result.Accepted,
Job: RunJobAssignmentFromDomain(result.Job),
ServerTime: result.ServerTime,
}
}
func RunJobProgressFromDomain(result domain.RunJobProgressResult) RunJobProgressResponse {
return RunJobProgressResponse{
Accepted: result.Accepted,
Job: RunJobAssignmentFromDomain(result.Job),
ServerTime: result.ServerTime,
}
}
func RunJobResultFromDomain(result domain.RunJobResultResult) RunJobResultResponse {
return RunJobResultResponse{
Accepted: result.Accepted,
Job: RunJobAssignmentFromDomain(result.Job),
ServerTime: result.ServerTime,
}
}
func RunJobCancelRequestFromDomain(result domain.RunJobCancelRequestResult) RunJobCancelRequestResponse {
return RunJobCancelRequestResponse{
Accepted: result.Accepted,
JobID: result.JobID,
Reason: result.Reason,
RequestedAt: result.RequestedAt,
}
}
func RunJobCancelPollFromDomain(result domain.RunJobCancelPollResult) RunJobCancelPollResponse {
return RunJobCancelPollResponse{
Accepted: result.Accepted,
RunEndpointID: result.RunEndpointID,
HasCancel: result.HasCancel,
JobID: result.JobID,
Reason: result.Reason,
RequestedAt: result.RequestedAt,
ServerTime: result.ServerTime,
}
}
func RunJobReconcileFromDomain(result domain.RunJobReconcileResult) RunJobReconcileResponse {
result = domain.CopyRunJobReconcileResult(result)
items := make([]RunJobAssignmentResponse, len(result.ActiveJobs))
for i, assignment := range result.ActiveJobs {
items[i] = RunJobAssignmentFromDomain(assignment)
}
return RunJobReconcileResponse{
Accepted: result.Accepted,
RunEndpointID: result.RunEndpointID,
ActiveJobs: items,
UnknownJobIDs: result.UnknownJobIDs,
ServerTime: result.ServerTime,
}
}
func RunJobAssignmentPtrFromDomain(assignment *domain.RunJobAssignment) *RunJobAssignmentResponse {
if assignment == nil {
return nil
}
response := RunJobAssignmentFromDomain(*assignment)
return &response
}
func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssignmentResponse {
return RunJobAssignmentResponse{
JobID: assignment.JobID,
ServerInstanceID: assignment.ServerInstanceID,
RunEndpointID: assignment.RunEndpointID,
Capability: assignment.Capability,
TargetKey: assignment.TargetKey,
InputRef: assignment.InputRef,
IdempotencyKey: assignment.IdempotencyKey,
State: assignment.State,
Progress: progressReportFromDomain(assignment.Progress),
ResultRef: assignment.ResultRef,
LeaseToken: assignment.LeaseToken,
Attempt: assignment.Attempt,
CreatedAt: assignment.CreatedAt,
UpdatedAt: assignment.UpdatedAt,
}
}
func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport {
return domain.RunJobProgressReport{
Percent: progress.Percent,
Message: progress.Message,
}
}
func progressReportFromDomain(progress domain.RunJobProgressReport) JobProgressBody {
return JobProgressBody{
Percent: progress.Percent,
Message: progress.Message,
}
}
+147
View File
@@ -0,0 +1,147 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type LogEntryBody struct {
Seq uint64 `json:"seq"`
Timestamp time.Time `json:"timestamp"`
Level string `json:"level,omitempty"`
Line string `json:"line"`
Fields map[string]string `json:"fields,omitempty"`
Redacted bool `json:"redacted"`
}
type LogBatchIngestRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
LogStreamID string `json:"logStreamId"`
ServerInstanceID string `json:"serverInstanceId"`
StreamKey string `json:"streamKey"`
Source domain.LogStreamSource `json:"source"`
FirstSeq uint64 `json:"firstSeq"`
LastSeq uint64 `json:"lastSeq"`
Compression string `json:"compression"`
Checksum string `json:"checksum"`
Entries []LogEntryBody `json:"entries"`
}
type LogBatchIngestResponse struct {
Accepted bool `json:"accepted"`
LogStreamID string `json:"logStreamId"`
AcceptedFrom uint64 `json:"acceptedFrom"`
AcceptedTo uint64 `json:"acceptedTo"`
LatestSeq uint64 `json:"latestSeq"`
Duplicate bool `json:"duplicate"`
RetryAfterSec int `json:"retryAfterSec,omitempty"`
ServerTime time.Time `json:"serverTime"`
}
type LogStreamCursorRequest struct {
LogStreamID string `json:"logStreamId"`
AfterSeq uint64 `json:"afterSeq"`
Limit int `json:"limit"`
}
type LogStreamCursorResponse struct {
LogStreamID string `json:"logStreamId"`
Entries []LogEntryBody `json:"entries"`
NextSeq uint64 `json:"nextSeq"`
LatestSeq uint64 `json:"latestSeq"`
}
func (request LogBatchIngestRequest) ToDomain() domain.LogBatchIngest {
return domain.LogBatchIngest{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
LogStreamID: request.LogStreamID,
ServerInstanceID: request.ServerInstanceID,
StreamKey: request.StreamKey,
Source: request.Source,
FirstSeq: request.FirstSeq,
LastSeq: request.LastSeq,
Compression: request.Compression,
Checksum: request.Checksum,
Entries: logEntriesToDomain(request.Entries),
}
}
func (request LogStreamCursorRequest) ToDomain() domain.LogStreamCursorQuery {
return domain.LogStreamCursorQuery{
LogStreamID: request.LogStreamID,
AfterSeq: request.AfterSeq,
Limit: request.Limit,
}
}
func LogBatchIngestFromDomain(result domain.LogBatchIngestResult) LogBatchIngestResponse {
return LogBatchIngestResponse{
Accepted: result.Accepted,
LogStreamID: result.LogStreamID,
AcceptedFrom: result.AcceptedFrom,
AcceptedTo: result.AcceptedTo,
LatestSeq: result.LatestSeq,
Duplicate: result.Duplicate,
ServerTime: result.ServerTime,
}
}
func LogStreamCursorFromDomain(result domain.LogStreamCursorResult) LogStreamCursorResponse {
result = domain.CopyLogStreamCursorResult(result)
return LogStreamCursorResponse{
LogStreamID: result.LogStreamID,
Entries: logEntriesFromDomain(result.Entries),
NextSeq: result.NextSeq,
LatestSeq: result.LatestSeq,
}
}
func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
if entries == nil {
return nil
}
out := make([]domain.LogEntry, len(entries))
for i, entry := range entries {
out[i] = domain.LogEntry{
Seq: entry.Seq,
Timestamp: entry.Timestamp,
Level: entry.Level,
Line: entry.Line,
Fields: copyStringMap(entry.Fields),
Redacted: entry.Redacted,
}
}
return out
}
func logEntriesFromDomain(entries []domain.LogEntry) []LogEntryBody {
if entries == nil {
return nil
}
out := make([]LogEntryBody, len(entries))
for i, entry := range entries {
out[i] = LogEntryBody{
Seq: entry.Seq,
Timestamp: entry.Timestamp,
Level: entry.Level,
Line: entry.Line,
Fields: copyStringMap(entry.Fields),
Redacted: entry.Redacted,
}
}
return out
}
func copyStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
out[key] = value
}
return out
}
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
package dto
import (
"reflect"
"testing"
"browser.local/platform/domain"
)
func TestAIProviderResponseExposesOnlyKeyReference(t *testing.T) {
responseType := reflect.TypeOf(AIProviderResponse{})
if _, ok := responseType.FieldByName("APIKey"); ok {
t.Fatal("AI provider response must not expose raw API key")
}
if _, ok := responseType.FieldByName("RawAPIKey"); ok {
t.Fatal("AI provider response must not expose raw API key")
}
if _, ok := responseType.FieldByName("APIKeyRef"); !ok {
t.Fatal("AI provider response must expose API key reference")
}
}
func TestAIProviderFromDomainCopiesModels(t *testing.T) {
provider := domain.AIProvider{
ID: "ai.openai",
Name: "OpenAI",
Kind: domain.AIProviderKindOpenAI,
BaseURL: "https://api.openai.com/v1",
APIKeyRef: "secret://providers/openai",
Models: []string{"gpt-4.1"},
DefaultModel: "gpt-4.1",
RelayMode: domain.AIRelayModeDirect,
TimeoutMS: 30000,
Status: domain.AIProviderStatusActive,
RedactionPolicy: "default",
}
response := AIProviderFromDomain(provider)
response.Models[0] = "mutated"
if provider.Models[0] != "gpt-4.1" {
t.Fatalf("expected response models to be copied, got source models %+v", provider.Models)
}
if response.APIKeyRef != provider.APIKeyRef {
t.Fatalf("expected API key reference to be preserved, got %q", response.APIKeyRef)
}
}
func TestGamePluginManifestRegistrationToDomainCopiesSlices(t *testing.T) {
request := GamePluginManifestRegistrationRequest{
ManifestRef: "artifact://manifests/game.example/0.1.0",
Manifest: GamePluginManifestBody{
ID: "game.example",
Name: "Example Server",
Version: "0.1.0",
Kind: "game-plugin",
Tags: []string{"example"},
Capabilities: []string{"process.start"},
Permissions: []string{"server.lifecycle"},
Server: GamePluginManifestServerBody{
Type: "example",
DisplayName: "Example Server",
SupportedOS: []string{"linux"},
CreateFormSchema: "schemas/create-form.schema.json",
},
Actions: PluginLifecycleActionsBody{Install: "actions/install.json", Start: "actions/start.json", Stop: "actions/stop.json"},
Pages: []GamePluginPageBody{
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
},
AI: GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
},
}
domainRegistration := request.ToDomain()
domainRegistration.Manifest.Tags[0] = "mutated"
domainRegistration.Manifest.Server.SupportedOS[0] = "darwin"
domainRegistration.Manifest.Pages[0].Permissions[0] = "ai.invoke"
domainRegistration.Manifest.AI.Purposes[0] = "config.suggest"
if request.Manifest.Tags[0] != "example" || request.Manifest.Server.SupportedOS[0] != "linux" || request.Manifest.Pages[0].Permissions[0] != "server.logs.read" || request.Manifest.AI.Purposes[0] != "logs.diagnose" {
t.Fatalf("expected manifest request slices to be copied, got %+v", request)
}
}
func TestGamePluginFromDomainCopiesRegistryMetadata(t *testing.T) {
plugin := domain.GamePlugin{
ID: "game.example",
Name: "Example Server",
Version: "0.1.0",
ServerType: "example",
RequiredRunCapabilities: []string{"process.start"},
DeclaredPermissions: []string{"server.lifecycle"},
SupportedOS: []string{"linux"},
Pages: []domain.GamePluginPage{
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
},
Tags: []string{"example"},
AIPurposes: []string{"logs.diagnose"},
}
response := GamePluginFromDomain(plugin)
response.RequiredRunCapabilities[0] = "files.read"
response.DeclaredPermissions[0] = "ai.invoke"
response.SupportedOS[0] = "darwin"
response.Pages[0].Permissions[0] = "ai.invoke"
response.Tags[0] = "mutated"
response.AIPurposes[0] = "config.suggest"
if plugin.RequiredRunCapabilities[0] != "process.start" || plugin.DeclaredPermissions[0] != "server.lifecycle" || plugin.SupportedOS[0] != "linux" || plugin.Pages[0].Permissions[0] != "server.logs.read" || plugin.Tags[0] != "example" || plugin.AIPurposes[0] != "logs.diagnose" {
t.Fatalf("expected plugin response registry metadata to be copied, got %+v", plugin)
}
}
+53
View File
@@ -0,0 +1,53 @@
package dto
import "browser.local/platform/domain"
type ServerLifecycleCreateRequest struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
RunEndpointID string `json:"runEndpointId"`
Name string `json:"name"`
OwnerUserID string `json:"ownerUserId,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerLifecycleCommandRequest struct {
ExpectedConfigVersion int `json:"expectedConfigVersion"`
IdempotencyKey string `json:"idempotencyKey"`
}
type ServerLifecycleResponse struct {
Accepted bool `json:"accepted"`
Action domain.ServerLifecycleAction `json:"action"`
Instance ServerInstanceResponse `json:"instance"`
Job JobResponse `json:"job"`
}
func (request ServerLifecycleCreateRequest) ToDomain() domain.ServerLifecycleCreate {
return domain.ServerLifecycleCreate{
ID: request.ID,
PluginID: request.PluginID,
RunEndpointID: request.RunEndpointID,
Name: request.Name,
OwnerUserID: request.OwnerUserID,
IdempotencyKey: request.IdempotencyKey,
}
}
func (request ServerLifecycleCommandRequest) ToDomain(serverInstanceID string) domain.ServerLifecycleCommand {
return domain.ServerLifecycleCommand{
ServerInstanceID: serverInstanceID,
ExpectedConfigVersion: request.ExpectedConfigVersion,
IdempotencyKey: request.IdempotencyKey,
}
}
func ServerLifecycleFromDomain(result domain.ServerLifecycleResult) ServerLifecycleResponse {
result = domain.CopyServerLifecycleResult(result)
return ServerLifecycleResponse{
Accepted: result.Accepted,
Action: result.Action,
Instance: ServerInstanceFromDomain(result.Instance),
Job: JobFromDomain(result.Job),
}
}
+8
View File
@@ -0,0 +1,8 @@
module browser.local/platform
go 1.25.1
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
)
+4
View File
@@ -0,0 +1,4 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
Executable
BIN
View File
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
# platform/model
Database models live here and are the source of truth for table structure. Migrations must reference these models or be kept in sync with them.
Required model groups:
- users and roles.
- game management plugins and installed plugin versions.
- server instances and config versions.
- AI providers and secret references.
- run endpoints and capabilities.
- jobs and job events.
- artifacts and chunks.
- log streams and ingestion cursors.
- audit events.
Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack.
+677
View File
@@ -0,0 +1,677 @@
package model
import (
"time"
"browser.local/platform/domain"
)
type User struct {
// ID is the stable platform user identifier.
ID string `json:"id" db:"id"`
// DisplayName is the user-visible account name.
DisplayName string `json:"displayName" db:"display_name"`
// Email is the optional login email.
Email string `json:"email,omitempty" db:"email"`
// Status is the user lifecycle status.
Status domain.UserStatus `json:"status" db:"status"`
// Roles stores assigned role keys.
Roles []string `json:"roles" db:"roles"`
// PasswordHash stores a platform-owned password verifier.
PasswordHash string `json:"passwordHash" db:"password_hash"`
// Profile stores bounded user contact metadata.
Profile domain.UserProfile `json:"profile" db:"profile"`
// Theme stores the user's persisted console theme preference.
Theme domain.UserThemePreference `json:"theme" db:"theme"`
// CreatedAt is the record creation timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"`
// UpdatedAt is the last update timestamp.
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (User) TableName() string { return "users" }
type AIProvider struct {
// ID is the stable AI provider identifier.
ID string `json:"id" db:"id"`
// Name is the display name shown to operators.
Name string `json:"name" db:"name"`
// Kind identifies the provider protocol family.
Kind domain.AIProviderKind `json:"kind" db:"kind"`
// BaseURL is the provider or relay endpoint.
BaseURL string `json:"baseUrl" db:"base_url"`
// APIKeyRef references secret storage and never stores raw key material.
APIKeyRef string `json:"apiKeyRef" db:"api_key_ref"`
// Models lists allowed model identifiers.
Models []string `json:"models" db:"models"`
// DefaultModel is the optional default model identifier.
DefaultModel string `json:"defaultModel,omitempty" db:"default_model"`
// RelayMode controls direct, relay, or local routing.
RelayMode domain.AIRelayMode `json:"relayMode" db:"relay_mode"`
// TimeoutMS is the provider request timeout in milliseconds.
TimeoutMS int `json:"timeoutMs" db:"timeout_ms"`
// Status is the provider lifecycle status.
Status domain.AIProviderStatus `json:"status" db:"status"`
// RedactionPolicy identifies prompt/input/output redaction behavior.
RedactionPolicy string `json:"redactionPolicy" db:"redaction_policy"`
}
func (AIProvider) TableName() string { return "ai_providers" }
type PluginPermissions struct {
// AI allows platform-mediated AI requests.
AI bool `json:"ai" db:"ai"`
// Logs allows scoped log queries.
Logs bool `json:"logs" db:"logs"`
// Files allows scoped file/artifact operations.
Files bool `json:"files" db:"files"`
// Jobs allows lifecycle job dispatch.
Jobs bool `json:"jobs" db:"jobs"`
// Artifacts allows artifact metadata and transfer references.
Artifacts bool `json:"artifacts" db:"artifacts"`
}
type PluginLifecycleActions struct {
// Install references the install action contract.
Install string `json:"install" db:"install"`
// Start references the start action contract.
Start string `json:"start" db:"start"`
// Stop references the stop action contract.
Stop string `json:"stop" db:"stop"`
// Restart references the optional restart action contract.
Restart string `json:"restart,omitempty" db:"restart"`
// Status references the optional status action contract.
Status string `json:"status,omitempty" db:"status"`
}
type GamePluginPage struct {
// Key is stable within the plugin manifest.
Key string `json:"key" db:"key"`
// Title is the page label shown by platform clients.
Title string `json:"title" db:"title"`
// Path is the plugin-local page route.
Path string `json:"path" db:"path"`
// Permissions lists scoped platform bridge permissions required by the page.
Permissions []string `json:"permissions" db:"permissions"`
}
type GamePlugin struct {
// ID is the installed game management plugin identifier.
ID string `json:"id" db:"id"`
// Name is the plugin display name.
Name string `json:"name" db:"name"`
// Description is bounded marketplace metadata from the manifest.
Description string `json:"description,omitempty" db:"description"`
// Version is the installed plugin version.
Version string `json:"version" db:"version"`
// ServerType is the game/server type key this plugin manages.
ServerType string `json:"serverType" db:"server_type"`
// ServerDisplayName is the user-visible server type name.
ServerDisplayName string `json:"serverDisplayName,omitempty" db:"server_display_name"`
// SupportedOS lists run operating systems declared by the plugin.
SupportedOS []string `json:"supportedOs" db:"supported_os"`
// ManifestRef points to the immutable manifest artifact.
ManifestRef string `json:"manifestRef" db:"manifest_ref"`
// CreateFormSchemaRef points to the create form schema artifact.
CreateFormSchemaRef string `json:"createFormSchemaRef" db:"create_form_schema_ref"`
// RequiredRunCapabilities lists run capabilities needed by this plugin.
RequiredRunCapabilities []string `json:"requiredRunCapabilities" db:"required_run_capabilities"`
// DeclaredPermissions lists scoped manifest permission keys.
DeclaredPermissions []string `json:"declaredPermissions" db:"declared_permissions"`
// Permissions declares platform-mediated plugin abilities.
Permissions PluginPermissions `json:"permissions" db:"permissions"`
// LifecycleActions stores manifest lifecycle action references.
LifecycleActions PluginLifecycleActions `json:"lifecycleActions" db:"lifecycle_actions"`
// Pages stores plugin-local page metadata.
Pages []GamePluginPage `json:"pages" db:"pages"`
// Tags stores bounded catalog tags.
Tags []string `json:"tags" db:"tags"`
// AIPurposes stores platform-mediated AI usage purposes.
AIPurposes []string `json:"aiPurposes" db:"ai_purposes"`
// ValidationViolations stores safe validation findings for invalid plugins.
ValidationViolations []string `json:"validationViolations" db:"validation_violations"`
// Status is the plugin lifecycle status.
Status domain.GamePluginStatus `json:"status" db:"status"`
}
func (GamePlugin) TableName() string { return "game_plugins" }
type ServerInstance struct {
// ID is the stable server instance identifier.
ID string `json:"id" db:"id"`
// PluginID references the installed game management plugin.
PluginID string `json:"pluginId" db:"plugin_id"`
// PluginVersion records the plugin version used for creation or reconcile.
PluginVersion string `json:"pluginVersion" db:"plugin_version"`
// RunEndpointID references the selected run endpoint.
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
// Name is the server display name.
Name string `json:"name" db:"name"`
// OwnerUserID identifies the server owner account.
OwnerUserID string `json:"ownerUserId" db:"owner_user_id"`
// AdminUserIDs identifies server-scoped administrator accounts.
AdminUserIDs []string `json:"adminUserIds" db:"admin_user_ids"`
// State is the server lifecycle state.
State domain.ServerInstanceState `json:"state" db:"state"`
// ConfigVersion is the platform-managed optimistic concurrency version.
ConfigVersion int `json:"configVersion" db:"config_version"`
// CreatedAt is the record creation timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"`
// UpdatedAt is the last update timestamp.
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (ServerInstance) TableName() string { return "server_instances" }
type RunCapacity struct {
// MaxJobs is the advertised job concurrency.
MaxJobs int `json:"maxJobs" db:"max_jobs"`
// RunningJobs is the current running job count.
RunningJobs int `json:"runningJobs" db:"running_jobs"`
// QueuedJobs is the current queued job count.
QueuedJobs int `json:"queuedJobs" db:"queued_jobs"`
// Summary is a bounded human-readable capacity summary.
Summary string `json:"summary,omitempty" db:"summary"`
}
type RunEndpoint struct {
// ID is the stable run endpoint identifier.
ID string `json:"id" db:"id"`
// DisplayName is the visible executor name.
DisplayName string `json:"displayName" db:"display_name"`
// Version is the run binary version.
Version string `json:"version" db:"version"`
// Status is the current endpoint status.
Status domain.RunEndpointStatus `json:"status" db:"status"`
// Capabilities lists advertised run capability keys.
Capabilities []string `json:"capabilities" db:"capabilities"`
// Capacity stores current queue and resource summary.
Capacity RunCapacity `json:"capacity" db:"capacity"`
// LastHeartbeatAt is the latest control heartbeat timestamp.
LastHeartbeatAt time.Time `json:"lastHeartbeatAt" db:"last_heartbeat_at"`
}
func (RunEndpoint) TableName() string { return "run_endpoints" }
type JobProgress struct {
// Percent is bounded from 0 to 100.
Percent int `json:"percent" db:"percent"`
// Message is a bounded progress summary.
Message string `json:"message,omitempty" db:"message"`
}
type Job struct {
// ID is the stable job identifier.
ID string `json:"id" db:"id"`
// ServerInstanceID optionally references the target server.
ServerInstanceID string `json:"serverInstanceId,omitempty" db:"server_instance_id"`
// RunEndpointID references the target run endpoint.
RunEndpointID string `json:"runEndpointId" db:"run_endpoint_id"`
// Capability is the requested run capability key.
Capability string `json:"capability" db:"capability"`
// TargetKey is a logical config/file key, never a host path.
TargetKey string `json:"targetKey,omitempty" db:"target_key"`
// InputRef points to a platform-scoped write payload or artifact.
InputRef string `json:"inputRef,omitempty" db:"input_ref"`
// IdempotencyKey detects duplicate job requests per run endpoint.
IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"`
// State is the job lifecycle state.
State domain.JobState `json:"state" db:"state"`
// Progress stores bounded progress metadata.
Progress JobProgress `json:"progress" db:"progress"`
// ResultRef references the terminal result artifact or summary.
ResultRef string `json:"resultRef,omitempty" db:"result_ref"`
// CreatedAt is the record creation timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"`
// UpdatedAt is the last update timestamp.
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (Job) TableName() string { return "jobs" }
type Artifact struct {
// ID is the stable artifact identifier.
ID string `json:"id" db:"id"`
// OwnerKind identifies the owning resource class.
OwnerKind domain.ArtifactOwnerKind `json:"ownerKind" db:"owner_kind"`
// OwnerID identifies the owning resource.
OwnerID string `json:"ownerId" db:"owner_id"`
// SizeBytes stores the expected or final artifact size.
SizeBytes int64 `json:"sizeBytes" db:"size_bytes"`
// Checksum stores the final checksum.
Checksum string `json:"checksum" db:"checksum"`
// State is the artifact lifecycle state.
State domain.ArtifactState `json:"state" db:"state"`
// CreatedAt is the record creation timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"`
// UpdatedAt is the last update timestamp.
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (Artifact) TableName() string { return "artifacts" }
type LogStream struct {
// ID is the stable log stream identifier.
ID string `json:"id" db:"id"`
// ServerInstanceID references the target server.
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
// Source identifies process, file, plugin, or custom source.
Source domain.LogStreamSource `json:"source" db:"source"`
// StreamKey is stable within the server instance.
StreamKey string `json:"streamKey" db:"stream_key"`
// LatestSeq is the latest accepted sequence number.
LatestSeq uint64 `json:"latestSeq" db:"latest_seq"`
// StorageBackend identifies the log body backend.
StorageBackend domain.LogStorageBackend `json:"storageBackend" db:"storage_backend"`
// RetentionPolicy identifies retention behavior.
RetentionPolicy string `json:"retentionPolicy" db:"retention_policy"`
// CreatedAt is the record creation timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"`
// UpdatedAt is the last update timestamp.
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (LogStream) TableName() string { return "log_streams" }
type AuditEvent struct {
// ID is the stable audit event identifier.
ID string `json:"id" db:"id"`
// ActorID references the user or system actor.
ActorID string `json:"actorId" db:"actor_id"`
// Action is the stable action key.
Action string `json:"action" db:"action"`
// ResourceKind identifies the audited resource type.
ResourceKind string `json:"resourceKind" db:"resource_kind"`
// ResourceID identifies the audited resource.
ResourceID string `json:"resourceId" db:"resource_id"`
// Result is the audit outcome.
Result domain.AuditResult `json:"result" db:"result"`
// Summary is a bounded redacted summary.
Summary string `json:"summary" db:"summary"`
// CreatedAt is the audit timestamp.
CreatedAt time.Time `json:"createdAt" db:"created_at"`
}
func (AuditEvent) TableName() string { return "audit_events" }
func UserFromDomain(user domain.User) User {
user = domain.CopyUser(user)
return User{
ID: user.ID,
DisplayName: user.DisplayName,
Email: user.Email,
Status: user.Status,
Roles: user.Roles,
PasswordHash: user.PasswordHash,
Profile: user.Profile,
Theme: user.Theme,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
func (user User) ToDomain() domain.User {
return domain.User{
ID: user.ID,
DisplayName: user.DisplayName,
Email: user.Email,
Status: user.Status,
Roles: domain.CopyStringSlice(user.Roles),
PasswordHash: user.PasswordHash,
Profile: user.Profile,
Theme: user.Theme,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
func AIProviderFromDomain(provider domain.AIProvider) AIProvider {
provider = domain.CopyAIProvider(provider)
return AIProvider{
ID: provider.ID,
Name: provider.Name,
Kind: provider.Kind,
BaseURL: provider.BaseURL,
APIKeyRef: provider.APIKeyRef,
Models: provider.Models,
DefaultModel: provider.DefaultModel,
RelayMode: provider.RelayMode,
TimeoutMS: provider.TimeoutMS,
Status: provider.Status,
RedactionPolicy: provider.RedactionPolicy,
}
}
func (provider AIProvider) ToDomain() domain.AIProvider {
return domain.AIProvider{
ID: provider.ID,
Name: provider.Name,
Kind: provider.Kind,
BaseURL: provider.BaseURL,
APIKeyRef: provider.APIKeyRef,
Models: domain.CopyStringSlice(provider.Models),
DefaultModel: provider.DefaultModel,
RelayMode: provider.RelayMode,
TimeoutMS: provider.TimeoutMS,
Status: provider.Status,
RedactionPolicy: provider.RedactionPolicy,
}
}
func GamePluginFromDomain(plugin domain.GamePlugin) GamePlugin {
plugin = domain.CopyGamePlugin(plugin)
return GamePlugin{
ID: plugin.ID,
Name: plugin.Name,
Description: plugin.Description,
Version: plugin.Version,
ServerType: plugin.ServerType,
ServerDisplayName: plugin.ServerDisplayName,
SupportedOS: plugin.SupportedOS,
ManifestRef: plugin.ManifestRef,
CreateFormSchemaRef: plugin.CreateFormSchemaRef,
RequiredRunCapabilities: plugin.RequiredRunCapabilities,
DeclaredPermissions: plugin.DeclaredPermissions,
Permissions: permissionsFromDomain(plugin.Permissions),
LifecycleActions: lifecycleActionsFromDomain(plugin.LifecycleActions),
Pages: pagesFromDomain(plugin.Pages),
Tags: plugin.Tags,
AIPurposes: plugin.AIPurposes,
ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status,
}
}
func (plugin GamePlugin) ToDomain() domain.GamePlugin {
return domain.GamePlugin{
ID: plugin.ID,
Name: plugin.Name,
Description: plugin.Description,
Version: plugin.Version,
ServerType: plugin.ServerType,
ServerDisplayName: plugin.ServerDisplayName,
SupportedOS: domain.CopyStringSlice(plugin.SupportedOS),
ManifestRef: plugin.ManifestRef,
CreateFormSchemaRef: plugin.CreateFormSchemaRef,
RequiredRunCapabilities: domain.CopyStringSlice(plugin.RequiredRunCapabilities),
DeclaredPermissions: domain.CopyStringSlice(plugin.DeclaredPermissions),
Permissions: plugin.Permissions.ToDomain(),
LifecycleActions: plugin.LifecycleActions.ToDomain(),
Pages: pagesToDomain(plugin.Pages),
Tags: domain.CopyStringSlice(plugin.Tags),
AIPurposes: domain.CopyStringSlice(plugin.AIPurposes),
ValidationViolations: domain.CopyStringSlice(plugin.ValidationViolations),
Status: plugin.Status,
}
}
func (actions PluginLifecycleActions) ToDomain() domain.PluginLifecycleActions {
return domain.PluginLifecycleActions{
Install: actions.Install,
Start: actions.Start,
Stop: actions.Stop,
Restart: actions.Restart,
Status: actions.Status,
}
}
func lifecycleActionsFromDomain(actions domain.PluginLifecycleActions) PluginLifecycleActions {
return PluginLifecycleActions{
Install: actions.Install,
Start: actions.Start,
Stop: actions.Stop,
Restart: actions.Restart,
Status: actions.Status,
}
}
func pagesToDomain(pages []GamePluginPage) []domain.GamePluginPage {
if pages == nil {
return nil
}
out := make([]domain.GamePluginPage, len(pages))
for i, page := range pages {
out[i] = domain.GamePluginPage{
Key: page.Key,
Title: page.Title,
Path: page.Path,
Permissions: domain.CopyStringSlice(page.Permissions),
}
}
return out
}
func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPage {
if pages == nil {
return nil
}
out := make([]GamePluginPage, len(pages))
for i, page := range pages {
out[i] = GamePluginPage{
Key: page.Key,
Title: page.Title,
Path: page.Path,
Permissions: domain.CopyStringSlice(page.Permissions),
}
}
return out
}
func (permissions PluginPermissions) ToDomain() domain.PluginPermissions {
return domain.PluginPermissions{
AI: permissions.AI,
Logs: permissions.Logs,
Files: permissions.Files,
Jobs: permissions.Jobs,
Artifacts: permissions.Artifacts,
}
}
func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissions {
return PluginPermissions{
AI: permissions.AI,
Logs: permissions.Logs,
Files: permissions.Files,
Jobs: permissions.Jobs,
Artifacts: permissions.Artifacts,
}
}
func ServerInstanceFromDomain(instance domain.ServerInstance) ServerInstance {
return ServerInstance{
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
State: instance.State,
ConfigVersion: instance.ConfigVersion,
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
}
}
func (instance ServerInstance) ToDomain() domain.ServerInstance {
return domain.ServerInstance{
ID: instance.ID,
PluginID: instance.PluginID,
PluginVersion: instance.PluginVersion,
RunEndpointID: instance.RunEndpointID,
Name: instance.Name,
State: instance.State,
ConfigVersion: instance.ConfigVersion,
CreatedAt: instance.CreatedAt,
UpdatedAt: instance.UpdatedAt,
}
}
func RunEndpointFromDomain(endpoint domain.RunEndpoint) RunEndpoint {
endpoint = domain.CopyRunEndpoint(endpoint)
return RunEndpoint{
ID: endpoint.ID,
DisplayName: endpoint.DisplayName,
Version: endpoint.Version,
Status: endpoint.Status,
Capabilities: endpoint.Capabilities,
Capacity: capacityFromDomain(endpoint.Capacity),
LastHeartbeatAt: endpoint.LastHeartbeatAt,
}
}
func (endpoint RunEndpoint) ToDomain() domain.RunEndpoint {
return domain.RunEndpoint{
ID: endpoint.ID,
DisplayName: endpoint.DisplayName,
Version: endpoint.Version,
Status: endpoint.Status,
Capabilities: domain.CopyStringSlice(endpoint.Capabilities),
Capacity: endpoint.Capacity.ToDomain(),
LastHeartbeatAt: endpoint.LastHeartbeatAt,
}
}
func (capacity RunCapacity) ToDomain() domain.RunCapacity {
return domain.RunCapacity{
MaxJobs: capacity.MaxJobs,
RunningJobs: capacity.RunningJobs,
QueuedJobs: capacity.QueuedJobs,
Summary: capacity.Summary,
}
}
func capacityFromDomain(capacity domain.RunCapacity) RunCapacity {
return RunCapacity{
MaxJobs: capacity.MaxJobs,
RunningJobs: capacity.RunningJobs,
QueuedJobs: capacity.QueuedJobs,
Summary: capacity.Summary,
}
}
func JobFromDomain(job domain.Job) Job {
return Job{
ID: job.ID,
ServerInstanceID: job.ServerInstanceID,
RunEndpointID: job.RunEndpointID,
Capability: job.Capability,
TargetKey: job.TargetKey,
InputRef: job.InputRef,
IdempotencyKey: job.IdempotencyKey,
State: job.State,
Progress: progressFromDomain(job.Progress),
ResultRef: job.ResultRef,
CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt,
}
}
func (job Job) ToDomain() domain.Job {
return domain.Job{
ID: job.ID,
ServerInstanceID: job.ServerInstanceID,
RunEndpointID: job.RunEndpointID,
Capability: job.Capability,
TargetKey: job.TargetKey,
InputRef: job.InputRef,
IdempotencyKey: job.IdempotencyKey,
State: job.State,
Progress: job.Progress.ToDomain(),
ResultRef: job.ResultRef,
CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt,
}
}
func (progress JobProgress) ToDomain() domain.JobProgress {
return domain.JobProgress{
Percent: progress.Percent,
Message: progress.Message,
}
}
func progressFromDomain(progress domain.JobProgress) JobProgress {
return JobProgress{
Percent: progress.Percent,
Message: progress.Message,
}
}
func ArtifactFromDomain(artifact domain.Artifact) Artifact {
return Artifact{
ID: artifact.ID,
OwnerKind: artifact.OwnerKind,
OwnerID: artifact.OwnerID,
SizeBytes: artifact.SizeBytes,
Checksum: artifact.Checksum,
State: artifact.State,
CreatedAt: artifact.CreatedAt,
UpdatedAt: artifact.UpdatedAt,
}
}
func (artifact Artifact) ToDomain() domain.Artifact {
return domain.Artifact{
ID: artifact.ID,
OwnerKind: artifact.OwnerKind,
OwnerID: artifact.OwnerID,
SizeBytes: artifact.SizeBytes,
Checksum: artifact.Checksum,
State: artifact.State,
CreatedAt: artifact.CreatedAt,
UpdatedAt: artifact.UpdatedAt,
}
}
func LogStreamFromDomain(stream domain.LogStream) LogStream {
return LogStream{
ID: stream.ID,
ServerInstanceID: stream.ServerInstanceID,
Source: stream.Source,
StreamKey: stream.StreamKey,
LatestSeq: stream.LatestSeq,
StorageBackend: stream.StorageBackend,
RetentionPolicy: stream.RetentionPolicy,
CreatedAt: stream.CreatedAt,
UpdatedAt: stream.UpdatedAt,
}
}
func (stream LogStream) ToDomain() domain.LogStream {
return domain.LogStream{
ID: stream.ID,
ServerInstanceID: stream.ServerInstanceID,
Source: stream.Source,
StreamKey: stream.StreamKey,
LatestSeq: stream.LatestSeq,
StorageBackend: stream.StorageBackend,
RetentionPolicy: stream.RetentionPolicy,
CreatedAt: stream.CreatedAt,
UpdatedAt: stream.UpdatedAt,
}
}
func AuditEventFromDomain(event domain.AuditEvent) AuditEvent {
return AuditEvent{
ID: event.ID,
ActorID: event.ActorID,
Action: event.Action,
ResourceKind: event.ResourceKind,
ResourceID: event.ResourceID,
Result: event.Result,
Summary: event.Summary,
CreatedAt: event.CreatedAt,
}
}
func (event AuditEvent) ToDomain() domain.AuditEvent {
return domain.AuditEvent{
ID: event.ID,
ActorID: event.ActorID,
Action: event.Action,
ResourceKind: event.ResourceKind,
ResourceID: event.ResourceID,
Result: event.Result,
Summary: event.Summary,
CreatedAt: event.CreatedAt,
}
}
+100
View File
@@ -0,0 +1,100 @@
package model
import (
"testing"
"browser.local/platform/domain"
)
func TestTableNames(t *testing.T) {
tests := map[string]string{
User{}.TableName(): "users",
AIProvider{}.TableName(): "ai_providers",
GamePlugin{}.TableName(): "game_plugins",
ServerInstance{}.TableName(): "server_instances",
RunEndpoint{}.TableName(): "run_endpoints",
Job{}.TableName(): "jobs",
Artifact{}.TableName(): "artifacts",
LogStream{}.TableName(): "log_streams",
AuditEvent{}.TableName(): "audit_events",
}
for got, want := range tests {
if got != want {
t.Fatalf("expected table name %q, got %q", want, got)
}
}
}
func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) {
source := domain.GamePlugin{
ID: "server.scum",
Name: "SCUM",
Version: "1.0.0",
ServerType: "scum",
ManifestRef: "artifact://manifest",
CreateFormSchemaRef: "artifact://schema",
RequiredRunCapabilities: []string{"process.start", "logs.read"},
DeclaredPermissions: []string{"server.logs.read"},
SupportedOS: []string{"linux"},
Pages: []domain.GamePluginPage{
{Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}},
},
Tags: []string{"survival"},
AIPurposes: []string{"logs.diagnose"},
Permissions: domain.PluginPermissions{
Jobs: true,
Logs: true,
},
Status: domain.GamePluginStatusInstalled,
}
row := GamePluginFromDomain(source)
roundTrip := row.ToDomain()
roundTrip.RequiredRunCapabilities[0] = "files.read"
roundTrip.DeclaredPermissions[0] = "ai.invoke"
roundTrip.SupportedOS[0] = "darwin"
roundTrip.Pages[0].Permissions[0] = "ai.invoke"
roundTrip.Tags[0] = "mutated"
roundTrip.AIPurposes[0] = "config.suggest"
if source.RequiredRunCapabilities[0] != "process.start" {
t.Fatalf("expected source plugin capabilities to remain unchanged, got %+v", source.RequiredRunCapabilities)
}
if row.RequiredRunCapabilities[0] != "process.start" {
t.Fatalf("expected model plugin capabilities to remain unchanged, got %+v", row.RequiredRunCapabilities)
}
if source.DeclaredPermissions[0] != "server.logs.read" || source.Pages[0].Permissions[0] != "server.logs.read" || source.Tags[0] != "survival" || source.AIPurposes[0] != "logs.diagnose" {
t.Fatalf("expected source plugin registry metadata to remain unchanged, got %+v", source)
}
if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" {
t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row)
}
}
func TestAIProviderModelUsesKeyReference(t *testing.T) {
source := domain.AIProvider{
ID: "ai.openai",
Name: "OpenAI",
Kind: domain.AIProviderKindOpenAI,
BaseURL: "https://api.openai.com/v1",
APIKeyRef: "secret://providers/openai",
Models: []string{"gpt-4.1"},
DefaultModel: "gpt-4.1",
RelayMode: domain.AIRelayModeDirect,
TimeoutMS: 30000,
Status: domain.AIProviderStatusActive,
RedactionPolicy: "default",
}
row := AIProviderFromDomain(source)
if row.APIKeyRef != source.APIKeyRef {
t.Fatalf("expected API key reference %q, got %q", source.APIKeyRef, row.APIKeyRef)
}
roundTrip := row.ToDomain()
roundTrip.Models[0] = "mutated"
if row.Models[0] != "gpt-4.1" {
t.Fatalf("expected model provider models to remain unchanged, got %+v", row.Models)
}
}
@@ -0,0 +1 @@
v1.2.0
@@ -0,0 +1 @@
{"Version":"v1.2.0","Time":"2026-02-17T17:23:26Z","Origin":{"VCS":"git","URL":"https://github.com/FiloSottile/edwards25519","Hash":"b182a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7","Ref":"refs/tags/v1.2.0"}}
@@ -0,0 +1,3 @@
module filippo.io/edwards25519
go 1.24.0
@@ -0,0 +1 @@
h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
@@ -0,0 +1 @@
v1.10.0
@@ -0,0 +1 @@
{"Version":"v1.10.0","Time":"2026-04-29T13:28:57Z","Origin":{"VCS":"git","URL":"https://github.com/go-sql-driver/mysql","Hash":"a065b60ab6d0c8e15468e7709c7f76acf4431647","Ref":"refs/tags/v1.10.0"}}
@@ -0,0 +1,5 @@
module github.com/go-sql-driver/mysql
go 1.24.0
require filippo.io/edwards25519 v1.2.0
@@ -0,0 +1 @@
h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
@@ -0,0 +1 @@
git3:https://github.com/FiloSottile/edwards25519
@@ -0,0 +1 @@
b182a6575cfd9f4fbb1d1d4e487a6b00a3ec06f7 tag 'v1.2.0' of https://github.com/FiloSottile/edwards25519
@@ -0,0 +1 @@
ref: refs/heads/main
@@ -0,0 +1,9 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/FiloSottile/edwards25519
fetch = +refs/heads/*:refs/remotes/origin/*
@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.
@@ -0,0 +1,15 @@
#!/bin/sh
#
# An example hook script to check the commit log message taken by
# applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit. The hook is
# allowed to edit the commit message file.
#
# To enable this hook, rename this file to "applypatch-msg".
. git-sh-setup
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
:
@@ -0,0 +1,24 @@
#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message. The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit. The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".
# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
# This example catches duplicate Signed-off-by lines.
test "" = "$(grep '^Signed-off-by: ' "$1" |
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
echo >&2 Duplicate Signed-off-by lines.
exit 1
}
@@ -0,0 +1,174 @@
#!/usr/bin/perl
use strict;
use warnings;
use IPC::Open2;
# An example hook script to integrate Watchman
# (https://facebook.github.io/watchman/) with git to speed up detecting
# new and modified files.
#
# The hook is passed a version (currently 2) and last update token
# formatted as a string and outputs to stdout a new update token and
# all files that have been modified since the update token. Paths must
# be relative to the root of the working tree and separated by a single NUL.
#
# To enable this hook, rename this file to "query-watchman" and set
# 'git config core.fsmonitor .git/hooks/query-watchman'
#
my ($version, $last_update_token) = @ARGV;
# Uncomment for debugging
# print STDERR "$0 $version $last_update_token\n";
# Check the hook interface version
if ($version ne 2) {
die "Unsupported query-fsmonitor hook version '$version'.\n" .
"Falling back to scanning...\n";
}
my $git_work_tree = get_working_dir();
my $retry = 1;
my $json_pkg;
eval {
require JSON::XS;
$json_pkg = "JSON::XS";
1;
} or do {
require JSON::PP;
$json_pkg = "JSON::PP";
};
launch_watchman();
sub launch_watchman {
my $o = watchman_query();
if (is_work_tree_watched($o)) {
output_result($o->{clock}, @{$o->{files}});
}
}
sub output_result {
my ($clockid, @files) = @_;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# binmode $fh, ":utf8";
# print $fh "$clockid\n@files\n";
# close $fh;
binmode STDOUT, ":utf8";
print $clockid;
print "\0";
local $, = "\0";
print @files;
}
sub watchman_clock {
my $response = qx/watchman clock "$git_work_tree"/;
die "Failed to get clock id on '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
return $json_pkg->new->utf8->decode($response);
}
sub watchman_query {
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
or die "open2() failed: $!\n" .
"Falling back to scanning...\n";
# In the query expression below we're asking for names of files that
# changed since $last_update_token but not from the .git folder.
#
# To accomplish this, we're using the "since" generator to use the
# recency index to select candidate nodes and "fields" to limit the
# output to file names only. Then we're using the "expression" term to
# further constrain the results.
my $last_update_line = "";
if (substr($last_update_token, 0, 1) eq "c") {
$last_update_token = "\"$last_update_token\"";
$last_update_line = qq[\n"since": $last_update_token,];
}
my $query = <<" END";
["query", "$git_work_tree", {$last_update_line
"fields": ["name"],
"expression": ["not", ["dirname", ".git"]]
}]
END
# Uncomment for debugging the watchman query
# open (my $fh, ">", ".git/watchman-query.json");
# print $fh $query;
# close $fh;
print CHLD_IN $query;
close CHLD_IN;
my $response = do {local $/; <CHLD_OUT>};
# Uncomment for debugging the watch response
# open ($fh, ">", ".git/watchman-response.json");
# print $fh $response;
# close $fh;
die "Watchman: command returned no output.\n" .
"Falling back to scanning...\n" if $response eq "";
die "Watchman: command returned invalid output: $response\n" .
"Falling back to scanning...\n" unless $response =~ /^\{/;
return $json_pkg->new->utf8->decode($response);
}
sub is_work_tree_watched {
my ($output) = @_;
my $error = $output->{error};
if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
$retry--;
my $response = qx/watchman watch "$git_work_tree"/;
die "Failed to make watchman watch '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
$output = $json_pkg->new->utf8->decode($response);
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# close $fh;
# Watchman will always return all files on the first query so
# return the fast "everything is dirty" flag to git and do the
# Watchman query just to get it over with now so we won't pay
# the cost in git to look up each individual file.
my $o = watchman_clock();
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
output_result($o->{clock}, ("/"));
$last_update_token = $o->{clock};
eval { launch_watchman() };
return 0;
}
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
return 1;
}
sub get_working_dir {
my $working_dir;
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
$working_dir = Win32::GetCwd();
$working_dir =~ tr/\\/\//;
} else {
require Cwd;
$working_dir = Cwd::cwd();
}
return $working_dir;
}
@@ -0,0 +1,8 @@
#!/bin/sh
#
# An example hook script to prepare a packed repository for use over
# dumb transports.
#
# To enable this hook, rename this file to "post-update".
exec git update-server-info
@@ -0,0 +1,14 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed
# by applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-applypatch".
. git-sh-setup
precommit="$(git rev-parse --git-path hooks/pre-commit)"
test -x "$precommit" && exec "$precommit" ${1+"$@"}
:
@@ -0,0 +1,49 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)
# Redirect output to stderr.
exec 1>&2
# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.
This can cause problems if you want to work with people on other platforms.
To be portable it is advisable to rename the file.
If you know what you are doing you can disable this check using:
git config hooks.allownonascii true
EOF
exit 1
fi
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --
@@ -0,0 +1,13 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git merge" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message to
# stderr if it wants to stop the merge commit.
#
# To enable this hook, rename this file to "pre-merge-commit".
. git-sh-setup
test -x "$GIT_DIR/hooks/pre-commit" &&
exec "$GIT_DIR/hooks/pre-commit"
:
@@ -0,0 +1,53 @@
#!/bin/sh
# An example hook script to verify what is about to be pushed. Called by "git
# push" after it has checked the remote status, but before anything has been
# pushed. If this script exits with a non-zero status nothing will be pushed.
#
# This hook is called with the following parameters:
#
# $1 -- Name of the remote to which the push is being done
# $2 -- URL to which the push is being done
#
# If pushing without using a named remote those arguments will be equal.
#
# Information about the commits which are being pushed is supplied as lines to
# the standard input in the form:
#
# <local ref> <local oid> <remote ref> <remote oid>
#
# This sample shows how to prevent push of commits where the log message starts
# with "WIP" (work in progress).
remote="$1"
url="$2"
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
while read local_ref local_oid remote_ref remote_oid
do
if test "$local_oid" = "$zero"
then
# Handle delete
:
else
if test "$remote_oid" = "$zero"
then
# New branch, examine all commits
range="$local_oid"
else
# Update to existing branch, examine new commits
range="$remote_oid..$local_oid"
fi
# Check for WIP commit
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
if test -n "$commit"
then
echo >&2 "Found WIP commit in $local_ref, not pushing"
exit 1
fi
fi
done
exit 0
@@ -0,0 +1,169 @@
#!/bin/sh
#
# Copyright (c) 2006, 2008 Junio C Hamano
#
# The "pre-rebase" hook is run just before "git rebase" starts doing
# its job, and can prevent the command from running by exiting with
# non-zero status.
#
# The hook is called with the following parameters:
#
# $1 -- the upstream the series was forked from.
# $2 -- the branch being rebased (or empty when rebasing the current branch).
#
# This sample shows how to prevent topic branches that are already
# merged to 'next' branch from getting rebased, because allowing it
# would result in rebasing already published history.
publish=next
basebranch="$1"
if test "$#" = 2
then
topic="refs/heads/$2"
else
topic=`git symbolic-ref HEAD` ||
exit 0 ;# we do not interrupt rebasing detached HEAD
fi
case "$topic" in
refs/heads/??/*)
;;
*)
exit 0 ;# we do not interrupt others.
;;
esac
# Now we are dealing with a topic branch being rebased
# on top of master. Is it OK to rebase it?
# Does the topic really exist?
git show-ref -q "$topic" || {
echo >&2 "No such branch $topic"
exit 1
}
# Is topic fully merged to master?
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
if test -z "$not_in_master"
then
echo >&2 "$topic is fully merged to master; better remove it."
exit 1 ;# we could allow it, but there is no point.
fi
# Is topic ever merged to next? If so you should not be rebasing it.
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
only_next_2=`git rev-list ^master ${publish} | sort`
if test "$only_next_1" = "$only_next_2"
then
not_in_topic=`git rev-list "^$topic" master`
if test -z "$not_in_topic"
then
echo >&2 "$topic is already up to date with master"
exit 1 ;# we could allow it, but there is no point.
else
exit 0
fi
else
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
/usr/bin/perl -e '
my $topic = $ARGV[0];
my $msg = "* $topic has commits already merged to public branch:\n";
my (%not_in_next) = map {
/^([0-9a-f]+) /;
($1 => 1);
} split(/\n/, $ARGV[1]);
for my $elem (map {
/^([0-9a-f]+) (.*)$/;
[$1 => $2];
} split(/\n/, $ARGV[2])) {
if (!exists $not_in_next{$elem->[0]}) {
if ($msg) {
print STDERR $msg;
undef $msg;
}
print STDERR " $elem->[1]\n";
}
}
' "$topic" "$not_in_next" "$not_in_master"
exit 1
fi
<<\DOC_END
This sample hook safeguards topic branches that have been
published from being rewound.
The workflow assumed here is:
* Once a topic branch forks from "master", "master" is never
merged into it again (either directly or indirectly).
* Once a topic branch is fully cooked and merged into "master",
it is deleted. If you need to build on top of it to correct
earlier mistakes, a new topic branch is created by forking at
the tip of the "master". This is not strictly necessary, but
it makes it easier to keep your history simple.
* Whenever you need to test or publish your changes to topic
branches, merge them into "next" branch.
The script, being an example, hardcodes the publish branch name
to be "next", but it is trivial to make it configurable via
$GIT_DIR/config mechanism.
With this workflow, you would want to know:
(1) ... if a topic branch has ever been merged to "next". Young
topic branches can have stupid mistakes you would rather
clean up before publishing, and things that have not been
merged into other branches can be easily rebased without
affecting other people. But once it is published, you would
not want to rewind it.
(2) ... if a topic branch has been fully merged to "master".
Then you can delete it. More importantly, you should not
build on top of it -- other people may already want to
change things related to the topic as patches against your
"master", so if you need further changes, it is better to
fork the topic (perhaps with the same name) afresh from the
tip of "master".
Let's look at this example:
o---o---o---o---o---o---o---o---o---o "next"
/ / / /
/ a---a---b A / /
/ / / /
/ / c---c---c---c B /
/ / / \ /
/ / / b---b C \ /
/ / / / \ /
---o---o---o---o---o---o---o---o---o---o---o "master"
A, B and C are topic branches.
* A has one fix since it was merged up to "next".
* B has finished. It has been fully merged up to "master" and "next",
and is ready to be deleted.
* C has not merged to "next" at all.
We would want to allow C to be rebased, refuse A, and encourage
B to be deleted.
To compute (1):
git rev-list ^master ^topic next
git rev-list ^master next
if these match, topic has not merged in next at all.
To compute (2):
git rev-list master..topic
if this is empty, it is fully merged to "master".
DOC_END
@@ -0,0 +1,24 @@
#!/bin/sh
#
# An example hook script to make use of push options.
# The example simply echoes all push options that start with 'echoback='
# and rejects all pushes when the "reject" push option is used.
#
# To enable this hook, rename this file to "pre-receive".
if test -n "$GIT_PUSH_OPTION_COUNT"
then
i=0
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
do
eval "value=\$GIT_PUSH_OPTION_$i"
case "$value" in
echoback=*)
echo "echo from the pre-receive-hook: ${value#*=}" >&2
;;
reject)
exit 1
esac
i=$((i + 1))
done
fi
@@ -0,0 +1,42 @@
#!/bin/sh
#
# An example hook script to prepare the commit log message.
# Called by "git commit" with the name of the file that has the
# commit message, followed by the description of the commit
# message's source. The hook's purpose is to edit the commit
# message file. If the hook fails with a non-zero status,
# the commit is aborted.
#
# To enable this hook, rename this file to "prepare-commit-msg".
# This hook includes three examples. The first one removes the
# "# Please enter the commit message..." help message.
#
# The second includes the output of "git diff --name-status -r"
# into the message, just before the "git status" output. It is
# commented because it doesn't cope with --amend or with squashed
# commits.
#
# The third example adds a Signed-off-by line to the message, that can
# still be edited. This is rarely a good idea.
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
# case "$COMMIT_SOURCE,$SHA1" in
# ,|template,)
# /usr/bin/perl -i.bak -pe '
# print "\n" . `git diff --cached --name-status -r`
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
# *) ;;
# esac
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
# if test -z "$COMMIT_SOURCE"
# then
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
# fi
@@ -0,0 +1,78 @@
#!/bin/sh
# An example hook script to update a checked-out tree on a git push.
#
# This hook is invoked by git-receive-pack(1) when it reacts to git
# push and updates reference(s) in its repository, and when the push
# tries to update the branch that is currently checked out and the
# receive.denyCurrentBranch configuration variable is set to
# updateInstead.
#
# By default, such a push is refused if the working tree and the index
# of the remote repository has any difference from the currently
# checked out commit; when both the working tree and the index match
# the current commit, they are updated to match the newly pushed tip
# of the branch. This hook is to be used to override the default
# behaviour; however the code below reimplements the default behaviour
# as a starting point for convenient modification.
#
# The hook receives the commit with which the tip of the current
# branch is going to be updated:
commit=$1
# It can exit with a non-zero status to refuse the push (when it does
# so, it must not modify the index or the working tree).
die () {
echo >&2 "$*"
exit 1
}
# Or it can make any necessary changes to the working tree and to the
# index to bring them to the desired state when the tip of the current
# branch is updated to the new commit, and exit with a zero status.
#
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
# in order to emulate git fetch that is run in the reverse direction
# with git push, as the two-tree form of git read-tree -u -m is
# essentially the same as git switch or git checkout that switches
# branches while keeping the local changes in the working tree that do
# not interfere with the difference between the branches.
# The below is a more-or-less exact translation to shell of the C code
# for the default behaviour for git's push-to-checkout hook defined in
# the push_to_deploy() function in builtin/receive-pack.c.
#
# Note that the hook will be executed from the repository directory,
# not from the working tree, so if you want to perform operations on
# the working tree, you will have to adapt your code accordingly, e.g.
# by adding "cd .." or using relative paths.
if ! git update-index -q --ignore-submodules --refresh
then
die "Up-to-date check failed"
fi
if ! git diff-files --quiet --ignore-submodules --
then
die "Working directory has unstaged changes"
fi
# This is a rough translation of:
#
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
if git cat-file -e HEAD 2>/dev/null
then
head=HEAD
else
head=$(git hash-object -t tree --stdin </dev/null)
fi
if ! git diff-index --quiet --cached --ignore-submodules $head --
then
die "Working directory has staged changes"
fi
if ! git read-tree -u -m "$commit"
then
die "Could not update working tree to new HEAD"
fi
@@ -0,0 +1,77 @@
#!/bin/sh
# An example hook script to validate a patch (and/or patch series) before
# sending it via email.
#
# The hook should exit with non-zero status after issuing an appropriate
# message if it wants to prevent the email(s) from being sent.
#
# To enable this hook, rename this file to "sendemail-validate".
#
# By default, it will only check that the patch(es) can be applied on top of
# the default upstream branch without conflicts in a secondary worktree. After
# validation (successful or not) of the last patch of a series, the worktree
# will be deleted.
#
# The following config variables can be set to change the default remote and
# remote ref that are used to apply the patches against:
#
# sendemail.validateRemote (default: origin)
# sendemail.validateRemoteRef (default: HEAD)
#
# Replace the TODO placeholders with appropriate checks according to your
# needs.
validate_cover_letter () {
file="$1"
# TODO: Replace with appropriate checks (e.g. spell checking).
true
}
validate_patch () {
file="$1"
# Ensure that the patch applies without conflicts.
git am -3 "$file" || return
# TODO: Replace with appropriate checks for this patch
# (e.g. checkpatch.pl).
true
}
validate_series () {
# TODO: Replace with appropriate checks for the whole series
# (e.g. quick build, coding style checks, etc.).
true
}
# main -------------------------------------------------------------------------
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
then
remote=$(git config --default origin --get sendemail.validateRemote) &&
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
git config --replace-all sendemail.validateWorktree "$worktree"
else
worktree=$(git config --get sendemail.validateWorktree)
fi || {
echo "sendemail-validate: error: failed to prepare worktree" >&2
exit 1
}
unset GIT_DIR GIT_WORK_TREE
cd "$worktree" &&
if grep -q "^diff --git " "$1"
then
validate_patch "$1"
else
validate_cover_letter "$1"
fi &&
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
then
git config --unset-all sendemail.validateWorktree &&
trap 'git worktree remove -ff "$worktree"' EXIT &&
validate_series
fi
@@ -0,0 +1,128 @@
#!/bin/sh
#
# An example hook script to block unannotated tags from entering.
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
#
# To enable this hook, rename this file to "update".
#
# Config
# ------
# hooks.allowunannotated
# This boolean sets whether unannotated tags will be allowed into the
# repository. By default they won't be.
# hooks.allowdeletetag
# This boolean sets whether deleting tags will be allowed in the
# repository. By default they won't be.
# hooks.allowmodifytag
# This boolean sets whether a tag may be modified after creation. By default
# it won't be.
# hooks.allowdeletebranch
# This boolean sets whether deleting branches will be allowed in the
# repository. By default they won't be.
# hooks.denycreatebranch
# This boolean sets whether remotely creating branches will be denied
# in the repository. By default this is allowed.
#
# --- Command line
refname="$1"
oldrev="$2"
newrev="$3"
# --- Safety check
if [ -z "$GIT_DIR" ]; then
echo "Don't run this script from the command line." >&2
echo " (if you want, you could supply GIT_DIR then run" >&2
echo " $0 <ref> <oldrev> <newrev>)" >&2
exit 1
fi
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
exit 1
fi
# --- Config
allowunannotated=$(git config --type=bool hooks.allowunannotated)
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
# check for no description
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
case "$projectdesc" in
"Unnamed repository"* | "")
echo "*** Project description file hasn't been set" >&2
exit 1
;;
esac
# --- Check types
# if $newrev is 0000...0000, it's a commit to delete a ref.
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
if [ "$newrev" = "$zero" ]; then
newrev_type=delete
else
newrev_type=$(git cat-file -t $newrev)
fi
case "$refname","$newrev_type" in
refs/tags/*,commit)
# un-annotated tag
short_refname=${refname##refs/tags/}
if [ "$allowunannotated" != "true" ]; then
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
exit 1
fi
;;
refs/tags/*,delete)
# delete tag
if [ "$allowdeletetag" != "true" ]; then
echo "*** Deleting a tag is not allowed in this repository" >&2
exit 1
fi
;;
refs/tags/*,tag)
# annotated tag
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
then
echo "*** Tag '$refname' already exists." >&2
echo "*** Modifying a tag is not allowed in this repository." >&2
exit 1
fi
;;
refs/heads/*,commit)
# branch
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
echo "*** Creating a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/heads/*,delete)
# delete branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/remotes/*,commit)
# tracking branch
;;
refs/remotes/*,delete)
# delete tracking branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
exit 1
fi
;;
*)
# Anything else (is there anything else?)
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
exit 1
;;
esac
# --- Finished
exit 0
@@ -0,0 +1,2 @@
* -export-subst -export-ignore
@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~
@@ -0,0 +1,2 @@
x…ŹËJAE]÷W\w&î…0ŕĘÉBł0űĐŹšf~Śý@ü{§␍¨»âręž*ĺ˘ÂŐÝÍ™x†`)P’… Ô;tô^ł†ŤH5 ŁŁôćöú(łçS¶Šµ€sń•óŚU.UĺoaÇÁ˘ëÉŽÍĎű¶›Ý3&„ŤkU{gp>ÖD626J=HKóBC&ńSuČT2šňK(Ž]Á[LC†ë‡‰oÔc{ ×|Z›ËC¤¬ĺH¬«AĎĚE+Yny
岕ťfušźć—×*ý”Ëßňü×?cž°`ŠŃt
@@ -0,0 +1,2 @@
x]ÌÍNÃ0`Î~ŠáÖ R@HåVJT!—À8ö&±p½Á?åÝq[Ç™oË␍®æ‹“Ù +Foº>b¢
\^Ìxé kÆ2Åž}(±´‡I€§@~Kº™¾·ˆ½ œ¼"(Ö„|v¼%ïH£!qWߟ‡8ZÚ+k¹,c/#”th-'§a\ «ê¹®ÐK¥È¤ãÛ&«q*7ú滆ä©c!©Þd—²Zˆ69…–ž’l§ø˜bÄYeiC.ø<6kräúøúuõ{’žÍ?wlþÒ½ûÈÕj

Some files were not shown because too many files have changed in this diff Show More