Implement platform management features

This commit is contained in:
npc0-hue
2026-08-17 08:48:45 +08:00
parent 65353cf269
commit 302f1f64b7
38 changed files with 2185 additions and 110 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14
@@ -0,0 +1,75 @@
## Context
Run currently persists a supervised process identity and forwards plugin-declared `process.stdout` and `process.stderr` through its durable spool. Autonomous lifecycle stream IDs are stable per Run endpoint, server, and stream key, so Platform cannot separate a later process start from the previous generation. The terminal then lists and replays all server streams, including old jobs, before it listens for SSE appends.
Platform remains the authorization and persistence boundary. Run remains the sole authority for the supervised process. The design must expose only a logical session identifier and timestamps, never output paths, PIDs, command lines, credentials, or direct process handles.
## Goals / Non-Goals
**Goals:**
- Follow only the current plugin-declared supervised process output in the live terminal.
- Preserve the same session when Run restarts and resumes an already-running supervised process.
- Switch an open terminal atomically when the supervised process starts a new generation.
- Keep bounded recent replay for the selected session and keep all older streams readable as explicit history.
- Keep RCON and any future plugin command transport independent of the log data path.
**Non-Goals:**
- Expose browser shell access, host paths, raw sockets, or process stdin by default.
- Infer game logs from arbitrary files or special-case SCUM, Minecraft, or another game.
- Delete, migrate, or reinterpret legacy log bodies.
## Decisions
### Process-generation session is generated and persisted by Run
`ProcessIdentity` receives a random/logical session ID and its start timestamp when a new managed process is started. The identity is journaled, so a Run restart resumes tailing and continues using the identical session for the still-running process. Starting a replacement process generates a new session.
The session ID is added to process log batches and autonomous process stream IDs. This avoids sequence collisions and gives Platform a durable grouping key. A stable stream ID with an inferred time was rejected because delayed spool upload could make an old process appear newer.
### Platform selects the current session only from supervised process streams
Platform persists the logical session ID and process-start timestamp with each stream. Run identifies an observed generation as `log-session:<session-id>`, and a persisted session is eligible as current only while the bound Run endpoint is online, Platform's latest Run-reported supervised-process fact is `running`, and that fact names the same session. This generation binding prevents a delayed batch from an older or not-yet-observed process from becoming current merely because it has a newer timestamp. Among the matching session's process streams, timestamp and deterministic ID ordering remain useful only for stable replay ordering. Only `source=process` streams with a non-empty session ID participate. Legacy, job, file-tail, and management-program logs remain historical and cannot displace a current terminal session.
Run reports observations for every process it supervises, including servers handled by a general worker rather than only a generated single-server Run. Platform publishes process-state changes into the existing server log subscription. A stopped, exited, or failed fact emits an empty session boundary; a later running fact re-evaluates the persisted streams. Run endpoint disconnect alone does not end a session because the game process may survive a Run restart, but a newly opened terminal treats an offline endpoint as having no proven current session until Run reconnects and reports the process fact.
Dispatching a new start clears Platform's previous managed-process binding before the job is queued. Historical output from the prior generation therefore cannot reappear during the interval between desired start intent and Run's first observation for the replacement generation.
Using the newest arbitrary stream was rejected because a file backfill or command result is not evidence of the process that the terminal must follow.
### SSE carries an explicit session boundary
The server log event endpoint begins with a `session` event, then exposes only the selected session's streams and a bounded replay. On each incoming process event Platform re-evaluates the active session. When it changed, Platform emits a new `session` event, its stream metadata, and a new selected-session replay before normal appends. The client clears the live buffer on the session boundary and remains connected; it never has to guess based on timestamps.
Re-opening a new EventSource on each restart was rejected because it races with output and leaves the old terminal contents visible while reconnecting.
### Durable source cursors make resumed file reads idempotent
Each process-output append carries its generation-local source-file cursor into the durable spool. The spool commits sequence allocation and the source cursor with the batch, restores both from pending segments, and treats a replayed cursor as already committed. This closes the crash window between a durable spool append and the process journal offset checkpoint.
The process journal retains superseded generations until both stdout and stderr files are fully drained. Starting generation B therefore cannot erase generation A recovery state if Run exits before A's final output is spooled. Existing journals without a session ID are upgraded in place for a still-running process so deployment of the new Run does not require restarting the game process.
### History is explicitly requested
The existing log-stream list/cursor contracts remain the history source. The terminal includes a deliberate history mode that reads selected historical stream cursors; it is not fed by the live SSE endpoint. This retains operator access without contaminating the live view.
## Risks / Trade-offs
- [Old Run versions emit no session ID] → Platform keeps their logs accessible in history but excludes them from live-follow selection; deployment is backward-safe and becomes live when Run is updated.
- [A process emits before the initial SSE replay completes] → line identity is stream ID plus sequence and client-side merge de-duplicates replay/live overlap.
- [Run exits after spooling a line but before checkpointing its file offset] → the durable source cursor makes the repeated read idempotent.
- [A new process starts before the old output files finish draining] → Run journals the retired generation until both channels are complete.
- [Session metadata tampering] → Platform accepts it only through the existing authenticated Run ingest channel and requires it to match the stream's immutable metadata.
- [Unbounded current logs] → live replay remains bounded while the full selected stream remains available through explicit history cursors.
## Migration Plan
1. Deploy Platform so it accepts session metadata and treats old streams as history.
2. Deploy Run so new managed process starts generate session-scoped stream IDs and resumptions preserve the persisted session.
3. Deploy the frontend terminal that understands `session` SSE boundaries and offers explicit history.
4. Rollback is safe: historical streams and batches remain immutable; an older frontend ignores the additional SSE event and Run can continue uploading session-scoped streams to the compatible Platform.
## Open Questions
None. The session scope is the plugin-declared process output, and the terminal's default is the current session.
@@ -0,0 +1,31 @@
## Why
The server terminal currently replays recent entries across every log stream ever created for an instance. That makes an open terminal appear stuck on stale startup output and leaves it unable to distinguish the process currently supervised by Run from a prior Run or process generation.
Operators need a terminal that continuously follows the current managed process even when Run or the process restarts, while keeping historical logs available deliberately and keeping game command execution separate from log transport.
## What Changes
- Define a current supervised-log session identity for each Run-managed process generation and attach it to process stdout/stderr log streams.
- Make the live terminal subscribe to the active supervised-log session by default, including a bounded recent replay from that session only.
- Notify terminal subscribers when the active session changes so an already-open terminal automatically replaces the old session output with the new process generation and continues following it.
- Clear the live terminal when Run reports that the supervised process stopped or exited, and restore the same session when a restarted Run confirms that the process survived.
- Retain historical logs as an explicit history view instead of mixing them into the live terminal.
- Preserve RCON and other plugin-declared command transports solely for command dispatch; they are not log sources.
- Extend the local end-to-end smoke coverage with a process/Run generation switch and output markers proving that the terminal feed follows the new generation.
## Capabilities
### New Capabilities
- `current-supervised-log-session`: identifies and follows the current plugin-declared supervised process stdout/stderr session, including generation changes and separate explicit history access.
### Modified Capabilities
<!-- No existing OpenSpec capability covers runtime log sessions. -->
## Impact
- Affected code: Run process supervision and log metadata, Platform log ingest/domain/API/SSE handling, the server-management terminal API client and drawer, and the local debug smoke path.
- Affected API: Run log ingest, supervised-process observations, and server log SSE gain a bounded session identity/filtering contract; a history-only query is exposed or retained separately.
- Compatibility: existing persisted log streams remain readable through history, but no longer appear by default in the live terminal.
@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: Live terminal follows the current supervised process session
The system SHALL identify each newly started Run-supervised process generation with a durable logical session ID, and SHALL associate its plugin-declared `process.stdout` and `process.stderr` streams with that session. A Run restart that resumes the same running process SHALL retain that session ID.
#### Scenario: Process output is collected independently of the terminal
- **WHEN** a plugin-declared supervised process writes stdout or stderr while no browser terminal is open
- **THEN** Run SHALL durably spool and upload that output under the process's logical session without opening an RCON connection or depending on browser state
#### Scenario: Run resumes a running process after restart
- **WHEN** Run restarts while its persisted supervised process is still running
- **THEN** Run SHALL resume collecting output with the persisted process session ID and the terminal SHALL continue following that session
#### Scenario: Run crashes between durable append and output checkpoint
- **WHEN** a process line is already durable in the local spool but Run restarts before its output-file offset checkpoint is persisted
- **THEN** Run SHALL recognize the repeated source cursor and SHALL NOT allocate or upload a duplicate log entry
#### Scenario: A replacement starts before the prior output drain completes
- **WHEN** Run starts a replacement process while the prior generation still has unread stdout or stderr
- **THEN** Run SHALL retain and resume the prior generation's drain state independently of the replacement generation
#### Scenario: Existing process journal predates session metadata
- **WHEN** an upgraded Run loads a legacy journal for a supervised process that is still alive
- **THEN** Run SHALL assign and persist a session for that same process without requiring the game process to restart
### Requirement: Live SSE exposes only the active supervised session
The server log SSE endpoint SHALL select the supervised-process session named by the current `running` process fact from an online bound Run endpoint, publish an explicit session boundary, and replay only a bounded set of entries from that selected session. The process fact SHALL identify the generation as `log-session:<session-id>`. The endpoint SHALL exclude legacy, completed-job, file-tail, management-program, unbound, and older supervised-session entries from the default live feed.
#### Scenario: Terminal opens after prior process generations
- **WHEN** an instance has historical job streams and older process sessions and an operator opens the terminal
- **THEN** the endpoint SHALL publish only the active session metadata and its bounded recent output before live appends
#### Scenario: No current session exists
- **WHEN** no session-scoped supervised process stream has been accepted for an instance
- **THEN** the endpoint SHALL publish a ready empty live session and SHALL NOT replay unrelated historical streams
#### Scenario: Persisted session belongs to a stopped process
- **WHEN** the newest persisted session belongs to a process that Run reported stopped, exited, or failed
- **THEN** a newly opened terminal SHALL receive an empty live session and SHALL expose the persisted output only through history
#### Scenario: Output arrives before its generation observation
- **WHEN** session-scoped process output is accepted before Run reports a matching `log-session:<session-id>` running observation
- **THEN** the endpoint SHALL retain the output as history and SHALL NOT select it as the current live session until that matching observation arrives
#### Scenario: A new start is dispatched from a stopped instance
- **WHEN** Platform accepts a new start request for an instance with a previous process-session binding
- **THEN** Platform SHALL clear the previous binding before dispatch so historical output cannot become current while the replacement generation is unobserved
### Requirement: Open terminal changes session without reconnection
The SSE endpoint SHALL detect a newer supervised-process session on accepted process output and SHALL emit a new session boundary, its stream metadata, and the new session replay to existing subscribers before sending subsequent output for that session.
#### Scenario: Process restarts while terminal is open
- **WHEN** a replacement supervised process writes its first stdout or stderr entry while an operator's terminal is open
- **THEN** the terminal SHALL discard the old live buffer, label the new current session, and display the new generation's output without the operator reopening the terminal
#### Scenario: Current process exits while terminal is open
- **WHEN** Run reports that the current supervised process stopped, exited, or failed
- **THEN** the same SSE connection SHALL emit an empty session boundary and the terminal SHALL remove the ended session from its live buffer
#### Scenario: Run reconnects to a surviving process
- **WHEN** a Run endpoint reconnects and reports that its persisted supervised process is still running
- **THEN** the same SSE connection SHALL reselect that process's existing session without creating a replacement session
### Requirement: Historical logs remain explicit and separate
The system SHALL retain persisted log streams and cursor queries for historical inspection. The terminal SHALL expose history only through an explicit operator action or view and SHALL NOT merge it into the current live buffer.
#### Scenario: Operator reviews an older stream
- **WHEN** an operator explicitly selects a historical log stream
- **THEN** the terminal SHALL fetch and display that stream's retained cursor entries separately from the live session
### Requirement: Command transports are not log sources
The system SHALL use RCON or another plugin-declared command transport only to execute a requested command. It SHALL collect live terminal output only through plugin-declared log sources, prioritizing the supervised process stdout/stderr channels.
#### Scenario: Operator sends an RCON command
- **WHEN** an operator submits a SCUM management command
- **THEN** the command SHALL use the protected RCON command path while live terminal output continues to arrive independently from the supervised process stream
@@ -0,0 +1,28 @@
## 1. Run process-session protocol
- [x] 1.1 Persist a new logical session ID and start timestamp for each newly supervised process while retaining it during Run output resumption.
- [x] 1.2 Include session metadata in durable process log batches and make autonomous process stream IDs generation-scoped without changing non-process/job log behavior.
- [x] 1.3 Add Run tests for new process generations and Run restart/resumption retaining the session.
- [x] 1.4 Make source-file replay idempotent across crashes, retain undrained retired generations, atomically replace aggregated spool segments, and upgrade live legacy journal entries.
## 2. Platform active-session feed
- [x] 2.1 Persist and validate immutable session metadata on log streams, including session-scoped Run stream recognition.
- [x] 2.2 Select active supervised-process streams and add session-boundary SSE events with selected-session-only replay and live filtering.
- [x] 2.3 Add Platform service/API tests covering stale history exclusion, new-session switches, and command/log separation.
- [x] 2.4 Gate initial current-session replay on online Run process facts and publish empty/recovered session boundaries on supervised process observations.
## 3. Terminal live and history views
- [x] 3.1 Extend frontend log contracts and SSE parsing for session boundaries.
- [x] 3.2 Make the terminal clear and follow a switched session automatically, and provide an explicit historical-stream view.
- [x] 3.3 Add frontend tests for current-session replay, live session switch, and separated history.
## 4. End-to-end verification
- [x] 4.1 Extend local debug smoke to prove first-generation output, process/Run generation switch, and post-switch terminal output.
- [ ] 4.2 Run OpenSpec validation, focused unit tests, structural validation, and the complete local smoke; record any external-environment blocker precisely.
- 2026-08-15 verification passed: `openspec validate follow-current-supervised-log-session --strict`; `go test ./api ./service ./domain ./dto ./validator` in `platform/`; `go test ./runtime ./spool ./protocol` in `run/`; `npm --prefix platform_web test -- ServerManagementTerminalDrawer client schemas/serverManagement`; `scripts/check-structure.sh`; `bash -n scripts/local-debug/smoke.sh`.
- 2026-08-15 complete local smoke command attempted: `LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-current-session-smoke LOCAL_DEBUG_PLATFORM_PORT=18198 LOCAL_DEBUG_WEB_PORT=5192 LOCAL_DEBUG_SELF_START=true scripts/dev-smoke.sh`.
- Current supervised log-session proof passed inside that smoke, including generation A replay, Run restart/resumption, generation B switch on the same SSE connection, and explicit-history exclusion; evidence file: `/private/tmp/browser-local-debug-current-session-smoke/smoke/current-log-session-verification.json`.
- External blocker: the complete smoke later failed in the platform Docker distribution builder at the host-native Run generation step because `go mod download` timed out fetching `github.com/dustin/go-humanize@v1.0.1` from `proxy.golang.org` (`dial tcp 142.251.33.209:443: i/o timeout`).
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
@@ -67,6 +68,8 @@ func TestRunChannelAPIInterleavedRequestsMutateIndependentState(t *testing.T) {
logBatch := validLogBatchRequest(t, hello.SessionToken, 1, 1)
logBatch.LogStreamID = "log-channel-isolation"
logBatch.LogSessionID = ""
logBatch.SessionStartedAt = time.Time{}
logRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", logBatch)
assertStatus(t, logRecorder, http.StatusOK)
logAck := decodeBody[dto.LogBatchIngestResponse](t, logRecorder)
+219 -31
View File
@@ -18,6 +18,7 @@ const (
defaultLogEventHistoryLimit = 100
maxLogEventHistoryLimit = 10000
logEventHeartbeatInterval = 15 * time.Second
managedLogSessionIDPrefix = "log-session:"
)
// serverLogEvents streams platform-accepted server log history and live append events for the terminal drawer.
@@ -26,7 +27,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
writeMethodNotAllowed(w, http.MethodGet)
return
}
instance, streams, subscription, err := h.openLogEventSubscription(r)
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
if err != nil {
writeServiceError(w, err)
return
@@ -46,25 +47,17 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
historyLimit := parseLogEventHistoryLimit(r.URL.Query().Get("historyLimit"))
for _, stream := range streams {
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
return
}
active := supervisedLogSession{}
if liveEligible {
active = activeSupervisedLogSession(streams)
}
if historyLimit > 0 {
history, err := h.loadLogEventHistory(streams, historyLimit)
if err != nil {
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
flusher.Flush()
return
}
for _, event := range history {
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
return
}
}
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
if err != nil {
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
flusher.Flush()
return
}
if err := writeSSEJSON(w, "ready", "", dto.LogStreamEventsReadyResponse{ServerInstanceID: instance.ID, StreamCount: len(streams), ServerTime: time.Now().UTC()}); err != nil {
if err := writeSSEJSON(w, "ready", "", dto.LogStreamEventsReadyResponse{ServerInstanceID: instance.ID, StreamCount: len(active.streams), ServerTime: time.Now().UTC()}); err != nil {
return
}
flusher.Flush()
@@ -75,13 +68,85 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
return
case event, ok := <-subscription.Events:
case subscriptionEvent, ok := <-subscription.Events:
if !ok {
return
}
if subscriptionEvent.Kind == service.LogEventSubscriptionEventProcessState {
if subscriptionEvent.ServerInstanceID != instance.ID {
continue
}
if subscriptionEvent.ProcessState != domain.ServerInstanceStateRunning {
liveEligible = false
if active.sessionID == "" {
continue
}
active = supervisedLogSession{}
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
if err != nil {
return
}
flusher.Flush()
continue
}
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
if err != nil {
return
}
next := supervisedLogSession{}
if liveEligible {
next = activeSupervisedLogSession(streams)
}
if sameSupervisedLogSession(active, next) {
continue
}
active = next
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
if err != nil {
return
}
flusher.Flush()
continue
}
if subscriptionEvent.Kind != service.LogEventSubscriptionEventLog || !liveEligible {
continue
}
event := subscriptionEvent.LogEvent
candidate := activeSupervisedLogSession([]domain.LogStream{event.Stream})
if candidate.sessionID != "" && newerLogSession(candidate, active) {
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
if err != nil {
return
}
next := supervisedLogSession{}
if liveEligible {
next = activeSupervisedLogSession(streams)
}
if !sameSupervisedLogSession(active, next) {
active = next
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
if err != nil {
return
}
flusher.Flush()
}
}
if !active.contains(event.Stream) {
continue
}
if !active.hasStream(event.Stream.ID) {
active.streams = append(active.streams, event.Stream)
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil {
return
}
}
if event.Entry.Seq <= emittedThrough[event.Stream.ID] {
continue
}
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
return
}
emittedThrough[event.Stream.ID] = event.Entry.Seq
flusher.Flush()
case <-heartbeat.C:
if _, err := fmt.Fprintf(w, ": heartbeat %s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
@@ -92,34 +157,157 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
}
}
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
type supervisedLogSession struct {
sessionID string
startedAt time.Time
streams []domain.LogStream
}
func activeSupervisedLogSession(streams []domain.LogStream) supervisedLogSession {
active := supervisedLogSession{}
for _, stream := range streams {
if stream.Source != domain.LogStreamSourceProcess || strings.TrimSpace(stream.LogSessionID) == "" || stream.SessionStartedAt.IsZero() {
continue
}
candidate := supervisedLogSession{sessionID: stream.LogSessionID, startedAt: stream.SessionStartedAt}
if active.sessionID == "" || newerLogSession(candidate, active) {
active = candidate
}
}
if active.sessionID == "" {
return active
}
for _, stream := range streams {
if stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == active.sessionID && stream.SessionStartedAt.Equal(active.startedAt) {
active.streams = append(active.streams, stream)
}
}
return active
}
func newerLogSession(candidate supervisedLogSession, current supervisedLogSession) bool {
if candidate.sessionID == "" || candidate.sessionID == current.sessionID {
return false
}
if current.sessionID == "" {
return true
}
if !candidate.startedAt.Equal(current.startedAt) {
return candidate.startedAt.After(current.startedAt)
}
return candidate.sessionID > current.sessionID
}
func sameSupervisedLogSession(left supervisedLogSession, right supervisedLogSession) bool {
return left.sessionID == right.sessionID && left.startedAt.Equal(right.startedAt)
}
func (session supervisedLogSession) contains(stream domain.LogStream) bool {
return session.sessionID != "" && stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == session.sessionID && stream.SessionStartedAt.Equal(session.startedAt)
}
func (session supervisedLogSession) hasStream(streamID string) bool {
for _, stream := range session.streams {
if stream.ID == streamID {
return true
}
}
return false
}
func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInstanceID string, active supervisedLogSession, historyLimit int) (map[string]uint64, error) {
emittedThrough := make(map[string]uint64, len(active.streams))
if err := writeSSEJSON(w, "session", "", dto.LogStreamEventsSessionResponse{ServerInstanceID: serverInstanceID, LogSessionID: active.sessionID, SessionStartedAt: active.startedAt, StreamCount: len(active.streams), ServerTime: time.Now().UTC()}); err != nil {
return nil, err
}
for _, stream := range active.streams {
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
return nil, err
}
}
if historyLimit == 0 || len(active.streams) == 0 {
return emittedThrough, nil
}
history, err := h.loadLogEventHistory(active.streams, historyLimit)
if err != nil {
return nil, err
}
for _, event := range history {
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
return nil, err
}
if event.Entry.Seq > emittedThrough[event.Stream.ID] {
emittedThrough[event.Stream.ID] = event.Entry.Seq
}
}
return emittedThrough, nil
}
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, bool, service.LogEventSubscription, error) {
var instance domain.ServerInstance
var streams []domain.LogStream
var liveEligible bool
var subscription service.LogEventSubscription
var err error
if h.enforceAuthorization {
sessionID := bearerToken(r)
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
streams, err = h.core.ListLogStreamsForSession(sessionID, domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
return domain.ServerInstance{}, nil, false, subscription, err
}
subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID)
if err != nil {
return domain.ServerInstance{}, nil, false, subscription, err
}
} else {
instance, err = h.core.GetServerInstance(r.PathValue("id"))
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
}
streams, err = h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.ServerInstance{}, nil, subscription, err
return domain.ServerInstance{}, nil, false, subscription, err
}
subscription, err = h.core.SubscribeLogEvents(instance.ID)
if err != nil {
return domain.ServerInstance{}, nil, false, subscription, err
}
}
return instance, streams, subscription, err
if err == nil {
streams, liveEligible, err = h.loadLiveLogSnapshot(instance.ID)
}
if err != nil && subscription.Close != nil {
subscription.Close()
}
return instance, streams, liveEligible, subscription, err
}
func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string) ([]domain.LogStream, bool, error) {
instance, err := h.core.GetServerInstance(serverInstanceID)
if err != nil {
return nil, false, err
}
if instance.State != domain.ServerInstanceStateRunning || strings.TrimSpace(instance.RunEndpointID) == "" {
return nil, false, nil
}
endpoint, err := h.core.GetRunEndpoint(instance.RunEndpointID)
if err != nil {
return nil, false, err
}
if endpoint.Status != domain.RunEndpointStatusOnline {
return nil, false, nil
}
logSessionID := strings.TrimPrefix(instance.LifecycleProcessID, managedLogSessionIDPrefix)
if logSessionID == instance.LifecycleProcessID || strings.TrimSpace(logSessionID) == "" {
return nil, false, nil
}
streams, err := h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
return nil, false, err
}
current := make([]domain.LogStream, 0, len(streams))
for _, stream := range streams {
if stream.Source == domain.LogStreamSourceProcess && stream.LogSessionID == logSessionID {
current = append(current, stream)
}
}
return current, true, nil
}
func (h *coreHandlers) loadLogEventHistory(streams []domain.LogStream, limit int) ([]domain.LogStreamEvent, error) {
+261 -1
View File
@@ -1,18 +1,61 @@
package api
import (
"bufio"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
"browser.local/platform/service"
"browser.local/platform/validator"
)
type logStreamListHookCore struct {
service.Core
once sync.Once
hook func()
}
type ssePipeResponseWriter struct {
header http.Header
pipe *io.PipeWriter
status chan int
once sync.Once
}
func newSSEPipeResponseWriter() (*ssePipeResponseWriter, *io.PipeReader) {
reader, writer := io.Pipe()
return &ssePipeResponseWriter{header: make(http.Header), pipe: writer, status: make(chan int, 1)}, reader
}
func (writer *ssePipeResponseWriter) Header() http.Header { return writer.header }
func (writer *ssePipeResponseWriter) WriteHeader(status int) {
writer.once.Do(func() { writer.status <- status })
}
func (writer *ssePipeResponseWriter) Write(payload []byte) (int, error) {
writer.WriteHeader(http.StatusOK)
return writer.pipe.Write(payload)
}
func (writer *ssePipeResponseWriter) Flush() {}
func (writer *ssePipeResponseWriter) Close() error { return writer.pipe.Close() }
func (core *logStreamListHookCore) ListLogStreams(filter domain.LogStreamFilter) ([]domain.LogStream, error) {
core.once.Do(core.hook)
return core.Core.ListLogStreams(filter)
}
func TestLogIngestAPIWorkflow(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
@@ -57,6 +100,7 @@ func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
hello := createLogIngestAPIFixtures(t, router)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-2", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stderr",
LogSessionID: "session-current", SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default",
})
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK)
@@ -70,6 +114,213 @@ func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
}
}
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
oldStream := dto.LogStreamCreateRequest{ID: "log-old", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stdout", LogSessionID: "session-old", SessionStartedAt: time.Date(2026, 7, 3, 11, 0, 0, 0, time.UTC), StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"}
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", oldStream)
oldBatch := validLogBatchRequestForStream(t, hello.SessionToken, "log-old", "stdout", 1, 1, 0)
oldBatch.LogSessionID = "session-old"
oldBatch.SessionStartedAt = oldStream.SessionStartedAt
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", oldBatch), http.StatusOK)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")
body := recorder.Body.String()
if !strings.Contains(body, `"logSessionId":"session-current"`) || strings.Contains(body, `"streamId":"log-old"`) {
t.Fatalf("expected only current session in live SSE, body=%s", body)
}
}
func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
stopped := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}}
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", stopped), http.StatusOK)
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10"))
running := stopped
running.ManagedProcessID = "log-session:session-missing"
running.ObservationSeq = 1
running.ObservedAt = time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", running), http.StatusOK)
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10"))
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.0", Status: domain.RunEndpointStatusOffline, CapabilityFingerprint: "cap-logs", Capacity: dto.RunCapacityResponse{MaxJobs: 1}}), http.StatusOK)
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10"))
}
func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconnect(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", nil).WithContext(ctx)
streamWriter, streamReader := newSSEPipeResponseWriter()
done := make(chan struct{})
go func() {
router.ServeHTTP(streamWriter, request)
_ = streamWriter.Close()
close(done)
}()
t.Cleanup(func() {
cancel()
_ = streamReader.Close()
<-done
})
if status := <-streamWriter.status; status != http.StatusOK {
t.Fatalf("unexpected SSE status: %d", status)
}
reader := bufio.NewReader(streamReader)
assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`)
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
assertSSEEvent(t, reader, "log", `"seq":1`)
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
statusReport := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}}
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK)
name, data := readSSEEvent(t, reader)
if name != "session" || strings.Contains(data, `"logSessionId"`) || !strings.Contains(data, `"streamCount":0`) {
t.Fatalf("expected stopped process to emit an empty session boundary, name=%q data=%s", name, data)
}
nextStartedAt := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
next := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stdout", "stdout", 1, 1, 20)
next.LogSessionID = "session-next"
next.SessionStartedAt = nextStartedAt
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", next), http.StatusOK)
statusReport.ManagedProcessID = "log-session:session-next"
statusReport.ObservationSeq = 1
statusReport.ObservedAt = nextStartedAt
statusReport.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK)
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
}
func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicates(t *testing.T) {
core := service.NewCoreService(repo.NewMemoryStore())
if err := core.SeedLocalPlatformAdmin(); err != nil {
t.Fatalf("seed platform admin: %v", err)
}
setupRouter := NewTestRouterWithCore(core)
hello := createLogIngestAPIFixtures(t, setupRouter)
initial := validLogBatchRequest(t, hello.SessionToken, 1, 1)
hookResult := make(chan error, 1)
hooked := &logStreamListHookCore{Core: core, hook: func() {
_, err := core.IngestLogBatch(initial.ToDomain())
hookResult <- err
}}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", nil).WithContext(ctx)
streamWriter, streamReader := newSSEPipeResponseWriter()
done := make(chan struct{})
go func() {
NewTestRouterWithCore(hooked).ServeHTTP(streamWriter, request)
_ = streamWriter.Close()
close(done)
}()
t.Cleanup(func() {
cancel()
_ = streamReader.Close()
<-done
})
if status := <-streamWriter.status; status != http.StatusOK {
t.Fatalf("unexpected SSE status: %d", status)
}
reader := bufio.NewReader(streamReader)
assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`)
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
assertSSEEvent(t, reader, "log", `"seq":1`)
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
if err := <-hookResult; err != nil {
t.Fatalf("ingest during stream snapshot: %v", err)
}
nextStartedAt := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
next := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stdout", "stdout", 1, 1, 20)
next.LogSessionID = "session-next"
next.SessionStartedAt = nextStartedAt
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
t.Fatalf("ingest next session stdout: %v", err)
}
if _, err := core.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, ManagedProcessID: "log-session:session-next", ObservationSeq: 1, ObservedAt: nextStartedAt, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}}); err != nil {
t.Fatalf("report next managed process: %v", err)
}
assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`)
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`)
assertSSEEvent(t, reader, "log", `"seq":1`)
stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21)
stderr.LogSessionID = "session-next"
stderr.SessionStartedAt = nextStartedAt
if _, err := core.IngestLogBatch(stderr.ToDomain()); err != nil {
t.Fatalf("ingest next session stderr: %v", err)
}
assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stderr"`)
assertSSEEvent(t, reader, "log", `"streamKey":"stderr"`)
delayedOld := validLogBatchRequest(t, hello.SessionToken, 2, 2)
if _, err := core.IngestLogBatch(delayedOld.ToDomain()); err != nil {
t.Fatalf("ingest delayed old session batch: %v", err)
}
nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 22)
nextLive.LogSessionID = "session-next"
nextLive.SessionStartedAt = nextStartedAt
if _, err := core.IngestLogBatch(nextLive.ToDomain()); err != nil {
t.Fatalf("ingest next session live append: %v", err)
}
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
}
func assertSSEEvent(t *testing.T, reader *bufio.Reader, wantName string, wantData string) {
t.Helper()
name, data := readSSEEvent(t, reader)
if name != wantName || !strings.Contains(data, wantData) {
t.Fatalf("unexpected SSE event: name=%q data=%s; want name=%q containing %s", name, data, wantName, wantData)
}
}
func assertEmptyInitialLogSession(t *testing.T, recorder *httptest.ResponseRecorder) {
t.Helper()
assertStatus(t, recorder, http.StatusOK)
body := recorder.Body.String()
if !strings.Contains(body, "event: session") || strings.Contains(body, `"logSessionId"`) || strings.Contains(body, "event: log") || !strings.Contains(body, `"streamCount":0`) {
t.Fatalf("expected an empty initial live session, body=%s", body)
}
}
func readSSEEvent(t *testing.T, reader *bufio.Reader) (string, string) {
t.Helper()
for {
name, data := "", ""
for {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read SSE event: %v", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
if name != "" {
return name, data
}
break
}
if strings.HasPrefix(line, "event: ") {
name = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") {
data = strings.TrimPrefix(line, "data: ")
}
}
}
}
func performCancelledSSE(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
@@ -110,15 +361,22 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro
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)
postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1", State: domain.ServerInstanceStateRunning}, adminSession)
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
ID: "log-1",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
LogSessionID: "session-current",
SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded,
ManagedProcessID: "log-session:session-current", ObservationSeq: 1, ObservedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"},
}), http.StatusOK)
return hello
}
@@ -146,6 +404,8 @@ func validLogBatchRequestForStream(t *testing.T, sessionToken string, streamID s
ServerInstanceID: "server-1",
StreamKey: streamKey,
Source: domain.LogStreamSourceProcess,
LogSessionID: "session-current",
SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
FirstSeq: firstSeq,
LastSeq: lastSeq,
Compression: "none",
+2
View File
@@ -18,6 +18,8 @@ type LogBatchIngest struct {
ServerInstanceID string
StreamKey string
Source LogStreamSource
LogSessionID string
SessionStartedAt time.Time
FirstSeq uint64
LastSeq uint64
Compression string
+2
View File
@@ -1507,6 +1507,8 @@ type LogStream struct {
ServerInstanceID string
Source LogStreamSource
StreamKey string
LogSessionID string
SessionStartedAt time.Time
LatestSeq uint64
StorageBackend LogStorageBackend
RetentionPolicy string
+16
View File
@@ -22,6 +22,8 @@ type LogBatchIngestRequest struct {
ServerInstanceID string `json:"serverInstanceId"`
StreamKey string `json:"streamKey"`
Source domain.LogStreamSource `json:"source"`
LogSessionID string `json:"logSessionId,omitempty"`
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
FirstSeq uint64 `json:"firstSeq"`
LastSeq uint64 `json:"lastSeq"`
Compression string `json:"compression"`
@@ -74,6 +76,8 @@ type LogStreamEventResponse struct {
StreamID string `json:"streamId"`
Source domain.LogStreamSource `json:"source"`
StreamKey string `json:"streamKey"`
LogSessionID string `json:"logSessionId,omitempty"`
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
LatestSeq uint64 `json:"latestSeq"`
Entry LogEntryBody `json:"entry"`
}
@@ -84,6 +88,14 @@ type LogStreamEventsReadyResponse struct {
ServerTime time.Time `json:"serverTime"`
}
type LogStreamEventsSessionResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
LogSessionID string `json:"logSessionId,omitempty"`
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
StreamCount int `json:"streamCount"`
ServerTime time.Time `json:"serverTime"`
}
func (request LogBatchIngestRequest) ToDomain() domain.LogBatchIngest {
return domain.LogBatchIngest{
RunEndpointID: request.RunEndpointID,
@@ -92,6 +104,8 @@ func (request LogBatchIngestRequest) ToDomain() domain.LogBatchIngest {
ServerInstanceID: request.ServerInstanceID,
StreamKey: request.StreamKey,
Source: request.Source,
LogSessionID: request.LogSessionID,
SessionStartedAt: request.SessionStartedAt,
FirstSeq: request.FirstSeq,
LastSeq: request.LastSeq,
Compression: request.Compression,
@@ -145,6 +159,8 @@ func LogStreamEventFromDomain(event domain.LogStreamEvent) LogStreamEventRespons
StreamID: event.Stream.ID,
Source: event.Stream.Source,
StreamKey: event.Stream.StreamKey,
LogSessionID: event.Stream.LogSessionID,
SessionStartedAt: event.Stream.SessionStartedAt,
LatestSeq: event.LatestSeq,
Entry: logEntryFromDomain(event.Entry),
}
+8
View File
@@ -893,6 +893,8 @@ type LogStreamCreateRequest struct {
ServerInstanceID string `json:"serverInstanceId"`
Source domain.LogStreamSource `json:"source"`
StreamKey string `json:"streamKey"`
LogSessionID string `json:"logSessionId,omitempty"`
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
StorageBackend domain.LogStorageBackend `json:"storageBackend"`
RetentionPolicy string `json:"retentionPolicy"`
}
@@ -902,6 +904,8 @@ type LogStreamResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
Source domain.LogStreamSource `json:"source"`
StreamKey string `json:"streamKey"`
LogSessionID string `json:"logSessionId,omitempty"`
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
LatestSeq uint64 `json:"latestSeq"`
StorageBackend domain.LogStorageBackend `json:"storageBackend"`
RetentionPolicy string `json:"retentionPolicy"`
@@ -1363,6 +1367,8 @@ func (request LogStreamCreateRequest) ToDomain() domain.LogStream {
ServerInstanceID: request.ServerInstanceID,
Source: request.Source,
StreamKey: request.StreamKey,
LogSessionID: request.LogSessionID,
SessionStartedAt: request.SessionStartedAt,
StorageBackend: request.StorageBackend,
RetentionPolicy: request.RetentionPolicy,
}
@@ -1938,6 +1944,8 @@ func LogStreamFromDomain(stream domain.LogStream) LogStreamResponse {
ServerInstanceID: stream.ServerInstanceID,
Source: stream.Source,
StreamKey: stream.StreamKey,
LogSessionID: stream.LogSessionID,
SessionStartedAt: stream.SessionStartedAt,
LatestSeq: stream.LatestSeq,
StorageBackend: stream.StorageBackend,
RetentionPolicy: stream.RetentionPolicy,
+4
View File
@@ -415,6 +415,10 @@ type LogStream struct {
Source domain.LogStreamSource `json:"source" db:"source"`
// StreamKey is stable within the server instance.
StreamKey string `json:"streamKey" db:"stream_key"`
// LogSessionID groups plugin-declared supervised-process streams for one process generation.
LogSessionID string `json:"logSessionId,omitempty" db:"log_session_id"`
// SessionStartedAt is Run's persisted start timestamp for the supervised process generation.
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty" db:"session_started_at"`
// LatestSeq is the latest accepted sequence number.
LatestSeq uint64 `json:"latestSeq" db:"latest_seq"`
// StorageBackend identifies the log body backend.
@@ -66,7 +66,17 @@ func (svc *CoreService) enqueueDistributionBuild(job domain.Job) {
delete(svc.distributionBuilds, job.ID)
svc.distributionBuildMu.Unlock()
}()
_ = svc.executeDistributionBuild(job)
defer func() {
if recover() != nil {
_ = svc.failDistributionBuildJob(job, "platform builder failed unexpectedly")
}
}()
if err := svc.executeDistributionBuild(job); err != nil {
// executeDistributionBuild may fail after persisting the running state.
// A second terminalization attempt is idempotent when the specific
// failure path already marked the job failed.
_ = svc.failDistributionBuildJob(job, "platform builder failed before completion")
}
}()
}
@@ -99,7 +109,7 @@ func (svc *CoreService) executeDistributionBuild(job domain.Job) error {
payload, buildErr = builder.Build(input)
}
if buildErr != nil {
return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr))
return svc.failDistributionBuildJob(job, builderJobFailureMessage(buildErr, input.AuthKey))
}
if _, err := svc.platformDistributionBuildInput(job); err != nil {
return svc.failDistributionBuildJob(job, "platform builder discarded output because the component key is no longer current")
@@ -25,6 +25,34 @@ type progressDistributionBuilder struct {
payload []byte
}
type failingBuildProgressStore struct {
repo.Store
buildJobs repo.ClientManagerBuildJobRepository
}
func (store failingBuildProgressStore) ClientManagerBuildJobs() repo.ClientManagerBuildJobRepository {
return store.buildJobs
}
type failWhenDistributionJobRunningRepository struct {
repo.ClientManagerBuildJobRepository
jobs repo.JobRepository
err error
}
func (repository failWhenDistributionJobRunningRepository) List(filter domain.ClientManagerBuildJobFilter) ([]domain.ClientManagerBuildJob, error) {
jobs, err := repository.jobs.List(domain.JobFilter{ServerInstanceID: filter.ServerInstanceID})
if err != nil {
return nil, err
}
for _, job := range jobs {
if job.Capability == domain.JobCapabilityDistributionBuild && job.State == domain.JobStateRunning {
return nil, repository.err
}
}
return repository.ClientManagerBuildJobRepository.List(filter)
}
func (builder captureDistributionBuilder) Readiness() (bool, string) {
return true, ""
}
@@ -379,6 +407,49 @@ func TestCoreServiceProjectsPlatformBuilderProgressBeforeCompletion(t *testing.T
completeDistributionBuild(t, svc, distribution, nil)
}
func TestCoreServiceTerminalizesPlatformBuildWhenRunningProjectionFails(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
const leakedDetail = "sensitive-builder-token /private/platform/build/input/auth-key"
baseStore := svc.store
svc.store = failingBuildProgressStore{
Store: baseStore,
buildJobs: failWhenDistributionJobRunningRepository{
ClientManagerBuildJobRepository: baseStore.ClientManagerBuildJobs(),
jobs: baseStore.Jobs(),
err: errors.New(leakedDetail),
},
}
distribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{
ServerInstanceID: instance.ID,
TargetOS: "linux",
TargetArch: "amd64",
IdempotencyKey: "running-projection-failure-terminalizes",
})
if err != nil {
t.Fatalf("queue platform distribution: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
job, jobErr := svc.GetJob(distribution.BuildJobID)
updated, distributionErr := svc.store.RunDistributions().Get(distribution.ID)
if jobErr != nil || distributionErr != nil {
t.Fatalf("read failed build state: jobErr=%v distributionErr=%v", jobErr, distributionErr)
}
if isTerminalJobState(job.State) {
if job.State != domain.JobStateFailed || updated.Status != domain.DistributionStatusFailed {
t.Fatalf("projection failure did not end in failed state: job=%+v distribution=%+v", job, updated)
}
if strings.Contains(job.Progress.Message, "sensitive-builder-token") || strings.Contains(job.Progress.Message, "/private/") || strings.Contains(job.TerminalFingerprint, "sensitive-builder-token") {
t.Fatalf("terminal build state leaked internal failure details: %+v", job)
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("platform build remained non-terminal after running projection failure")
}
func TestCoreServiceDiscardsBuildCompletedAfterKeyReset(t *testing.T) {
svc, session, instance := newDistributionTestFixture(t)
inputs := make(chan domain.DistributionBuildInput, 1)
+2 -2
View File
@@ -553,11 +553,11 @@ func redactBuilderHostPaths(line string) string {
return strings.Join(fields, " ")
}
func builderJobFailureMessage(err error) string {
func builderJobFailureMessage(err error, sensitiveValues ...string) string {
if err == nil {
return "platform builder failed"
}
message := strings.TrimSpace(err.Error())
message := safeBuilderFailure([]byte(err.Error()), sensitiveValues...)
if message == "" {
return "platform builder failed"
}
@@ -289,6 +289,14 @@ func TestDockerDistributionBuilderRedactsFailureAndTimeout(t *testing.T) {
}
}
func TestBuilderJobFailureMessageRedactsSensitiveValuesAndHostPaths(t *testing.T) {
const secret = "sensitive-component-key"
message := builderJobFailureMessage(errors.New(secret+" /private/platform/build/input/auth-key"), secret)
if strings.Contains(message, secret) || strings.Contains(message, "/private/") || strings.Contains(message, "auth-key") {
t.Fatalf("builder job failure leaked sensitive details: %s", message)
}
}
func TestPackageClientManagerDistributionProducesProtectedArchives(t *testing.T) {
for _, packageFormat := range []string{"zip", "tar.gz"} {
t.Run(packageFormat, func(t *testing.T) {
+43 -11
View File
@@ -8,14 +8,28 @@ import (
const logEventSubscriberBuffer = 512
type LogEventSubscriptionEventKind string
const (
LogEventSubscriptionEventLog LogEventSubscriptionEventKind = "log"
LogEventSubscriptionEventProcessState LogEventSubscriptionEventKind = "process-state"
)
type LogEventSubscriptionEvent struct {
Kind LogEventSubscriptionEventKind
LogEvent domain.LogStreamEvent
ServerInstanceID string
ProcessState domain.ServerInstanceState
}
type LogEventSubscription struct {
Events <-chan domain.LogStreamEvent
Events <-chan LogEventSubscriptionEvent
Close func()
}
type logEventSubscriber struct {
serverInstanceID string
events chan domain.LogStreamEvent
events chan LogEventSubscriptionEvent
}
func (svc *CoreService) SubscribeLogEvents(serverInstanceID string) (LogEventSubscription, error) {
@@ -26,7 +40,7 @@ func (svc *CoreService) SubscribeLogEvents(serverInstanceID string) (LogEventSub
if _, err := svc.store.ServerInstances().Get(serverInstanceID); err != nil {
return LogEventSubscription{}, err
}
events := make(chan domain.LogStreamEvent, logEventSubscriberBuffer)
events := make(chan LogEventSubscriptionEvent, logEventSubscriberBuffer)
svc.logEventMu.Lock()
svc.logEventSubscriberSeq++
id := svc.logEventSubscriberSeq
@@ -55,18 +69,36 @@ func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []doma
if len(entries) == 0 {
return
}
events := make([]domain.LogStreamEvent, len(entries))
events := make([]LogEventSubscriptionEvent, len(entries))
for index, entry := range entries {
events[index] = domain.CopyLogStreamEvent(domain.LogStreamEvent{
ServerInstanceID: stream.ServerInstanceID,
Stream: stream,
Entry: entry,
LatestSeq: stream.LatestSeq,
})
events[index] = LogEventSubscriptionEvent{
Kind: LogEventSubscriptionEventLog,
LogEvent: domain.CopyLogStreamEvent(domain.LogStreamEvent{
ServerInstanceID: stream.ServerInstanceID,
Stream: stream,
Entry: entry,
LatestSeq: stream.LatestSeq,
}),
}
}
svc.publishLogSubscriptionEvents(stream.ServerInstanceID, events)
}
func (svc *CoreService) publishLogProcessState(instance domain.ServerInstance) {
svc.publishLogSubscriptionEvents(instance.ID, []LogEventSubscriptionEvent{{
Kind: LogEventSubscriptionEventProcessState,
ServerInstanceID: instance.ID,
ProcessState: instance.State,
}})
}
func (svc *CoreService) publishLogSubscriptionEvents(serverInstanceID string, events []LogEventSubscriptionEvent) {
if len(events) == 0 {
return
}
svc.logEventMu.Lock()
for id, subscriber := range svc.logEventSubscribers {
if subscriber.serverInstanceID != stream.ServerInstanceID {
if subscriber.serverInstanceID != serverInstanceID {
continue
}
dropped := false
+23 -13
View File
@@ -20,6 +20,9 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
return domain.LogBatchIngestResult{}, err
}
lock := svc.logIngestLock(batch.ServerInstanceID)
lock.Lock()
defer lock.Unlock()
stamp := svc.now()
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
@@ -58,7 +61,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
}
storedBatch := domain.CopyLogBatchIngest(batch)
sanitizeGamePlayerNetworkFields(&storedBatch)
sanitizeLogNetworkFields(&storedBatch)
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
Checksum: batch.Checksum,
FirstSeq: batch.FirstSeq,
@@ -96,7 +99,7 @@ func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest,
if job.ServerInstanceID != batch.ServerInstanceID || job.RunEndpointID != batch.RunEndpointID {
return validationError("log batch job scope does not match stream")
}
return svc.ensureJobLogStreams(job, stamp)
return svc.ensureJobLogStreamsUnlocked(job, stamp)
}
func (svc *CoreService) ensureLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
@@ -107,7 +110,7 @@ func (svc *CoreService) ensureLogStreamForBatch(batch domain.LogBatchIngest, sta
if job.ServerInstanceID != batch.ServerInstanceID || job.RunEndpointID != batch.RunEndpointID {
return validationError("log batch job scope does not match stream")
}
return svc.ensureJobLogStreams(job, stamp)
return svc.ensureJobLogStreamsUnlocked(job, stamp)
}
if !errors.Is(err, repo.ErrNotFound) || !strings.HasPrefix(jobID, "autonomous-") {
return err
@@ -120,8 +123,14 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
if batch.Source != domain.LogStreamSourceProcess && batch.Source != domain.LogStreamSourceFile && batch.Source != domain.LogStreamSourceManagementProgram {
return repo.ErrNotFound
}
if batch.LogStreamID != runLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.StreamKey) && !legacyAutonomousLogStream(batch) {
return repo.ErrNotFound
expectedStreamID := runLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.StreamKey)
if batch.LogSessionID != "" {
expectedStreamID = runSessionLogStreamID(batch.RunEndpointID, batch.ServerInstanceID, batch.LogSessionID, batch.StreamKey)
}
if batch.LogStreamID != expectedStreamID {
if batch.LogSessionID != "" || !legacyAutonomousLogStream(batch) {
return repo.ErrNotFound
}
}
instance, err := svc.store.ServerInstances().Get(batch.ServerInstanceID)
if err != nil {
@@ -130,7 +139,7 @@ func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest,
if instance.RunEndpointID != batch.RunEndpointID {
return validationError("server instance run endpoint must match log batch endpoint")
}
_, err = svc.CreateLogStream(domain.LogStream{ID: batch.LogStreamID, ServerInstanceID: batch.ServerInstanceID, Source: batch.Source, StreamKey: batch.StreamKey, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp})
_, err = svc.createLogStream(domain.LogStream{ID: batch.LogStreamID, ServerInstanceID: batch.ServerInstanceID, Source: batch.Source, StreamKey: batch.StreamKey, LogSessionID: batch.LogSessionID, SessionStartedAt: batch.SessionStartedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp})
if errors.Is(err, repo.ErrDuplicate) {
return nil
}
@@ -166,18 +175,16 @@ func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIn
return false
}
// sanitizeGamePlayerNetworkFields removes raw network material before the durable log body is written.
func sanitizeGamePlayerNetworkFields(batch *domain.LogBatchIngest) {
// sanitizeLogNetworkFields removes raw network material before the durable log body is written.
func sanitizeLogNetworkFields(batch *domain.LogBatchIngest) {
for index := range batch.Entries {
fields := batch.Entries[index].Fields
if fields == nil {
continue
}
if fields["eventType"] == "scum.login" {
delete(fields, "networkFingerprint")
delete(fields, "ip")
delete(fields, "ipAddress")
}
delete(fields, "networkFingerprint")
delete(fields, "ip")
delete(fields, "ipAddress")
}
}
@@ -249,5 +256,8 @@ func validateLogBatchStream(batch domain.LogBatchIngest, stream domain.LogStream
if stream.Source != batch.Source {
return validationError("source must match stream")
}
if stream.LogSessionID != batch.LogSessionID || !stream.SessionStartedAt.Equal(batch.SessionStartedAt) {
return validationError("log session metadata must match stream")
}
return nil
}
@@ -0,0 +1,31 @@
package service
import (
"testing"
"browser.local/platform/domain"
)
func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) {
batch := domain.LogBatchIngest{Entries: []domain.LogEntry{
{Fields: map[string]string{
"eventType": "game.session.opened",
"networkFingerprint": "fingerprint",
"ip": "192.0.2.1",
"ipAddress": "2001:db8::1",
"playerId": "player-1",
}},
}}
sanitizeLogNetworkFields(&batch)
fields := batch.Entries[0].Fields
for _, key := range []string{"networkFingerprint", "ip", "ipAddress"} {
if _, exists := fields[key]; exists {
t.Fatalf("expected %s to be removed", key)
}
}
if fields["playerId"] != "player-1" {
t.Fatal("expected unrelated fields to be preserved")
}
}
+83 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -74,7 +75,7 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
}
select {
case event := <-subscription.Events:
if event.Stream.ID != "log-1" || event.Entry.Seq != 1 || event.LatestSeq != 1 {
if event.Kind != LogEventSubscriptionEventLog || event.LogEvent.Stream.ID != "log-1" || event.LogEvent.Entry.Seq != 1 || event.LogEvent.LatestSeq != 1 {
t.Fatalf("unexpected log event: %+v", event)
}
case <-time.After(time.Second):
@@ -91,6 +92,76 @@ func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
}
}
func TestCoreServicePersistsAndEnforcesImmutableProcessLogSessionMetadata(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
streamID := runSessionLogStreamID("run-local", "server-1", "session-a", "stdout")
batch := validLogBatch(t, sessionToken, 1, 1)
batch.LogStreamID = streamID
batch.LogSessionID = "session-a"
batch.SessionStartedAt = startedAt
if _, err := svc.IngestLogBatch(batch); err != nil {
t.Fatalf("ingest session-scoped process batch: %v", err)
}
stream, err := svc.GetLogStream(streamID)
if err != nil || stream.LogSessionID != "session-a" || !stream.SessionStartedAt.Equal(startedAt) {
t.Fatalf("session metadata was not persisted: stream=%+v err=%v", stream, err)
}
conflict := validLogBatch(t, sessionToken, 2, 2)
conflict.LogStreamID = streamID
conflict.LogSessionID = "session-a"
conflict.SessionStartedAt = startedAt.Add(time.Second)
if _, err := svc.IngestLogBatch(conflict); err == nil || !strings.Contains(err.Error(), "metadata must match") {
t.Fatalf("expected immutable stream metadata rejection, got %v", err)
}
legacy := createLogStreamFixture(t, svc)
legacyBatch := validLogBatch(t, sessionToken, 1, 1)
legacyBatch.LogStreamID = legacy.ID
legacyBatch.LogSessionID = "session-a"
legacyBatch.SessionStartedAt = startedAt
if _, err := svc.IngestLogBatch(legacyBatch); err == nil || !strings.Contains(err.Error(), "metadata must match") {
t.Fatalf("expected legacy stream to reject attached session metadata, got %v", err)
}
}
func TestCoreServiceSerializesConsistentSessionStreamCreation(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
startedAt := time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
streams := []domain.LogStream{
{ID: "session-stream-stdout", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stdout", LogSessionID: "session-a", SessionStartedAt: startedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"},
{ID: "session-stream-stderr", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stderr", LogSessionID: "session-a", SessionStartedAt: startedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"},
}
errorsByStream := make(chan error, len(streams))
var wait sync.WaitGroup
for _, stream := range streams {
stream := stream
wait.Add(1)
go func() {
defer wait.Done()
_, err := svc.CreateLogStream(stream)
errorsByStream <- err
}()
}
wait.Wait()
close(errorsByStream)
for err := range errorsByStream {
if err != nil {
t.Fatalf("create consistent session stream: %v", err)
}
}
_, err := svc.CreateLogStream(domain.LogStream{ID: "session-stream-conflict", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "console", LogSessionID: "session-a", SessionStartedAt: startedAt.Add(time.Second), StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
if err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("expected conflicting session timestamp rejection, got %v", err)
}
_, err = svc.CreateLogStream(domain.LogStream{ID: "session-file-tail", ServerInstanceID: "server-1", Source: domain.LogStreamSourceFile, StreamKey: "file", LogSessionID: "session-file", SessionStartedAt: startedAt, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
if err == nil || !strings.Contains(err.Error(), "only valid for process") {
t.Fatalf("expected file-tail session metadata rejection, got %v", err)
}
}
func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
@@ -346,6 +417,17 @@ func TestCoreServiceAcceptsLegacyAutonomousJobLogStreamWithoutPlatformJob(t *tes
}
}
func TestCoreServiceRejectsSessionMetadataOnLegacyAutonomousStreamID(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
batch := validLogBatch(t, sessionToken, 1, 1)
batch.LogStreamID = jobLogStreamID("autonomous-bootstrap-start", "stdout")
batch.LogSessionID = "session-a"
batch.SessionStartedAt = time.Date(2026, 7, 3, 12, 30, 0, 0, time.UTC)
if _, err := svc.IngestLogBatch(batch); err == nil {
t.Fatal("expected session-scoped batch with legacy autonomous stream ID to be rejected")
}
}
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
+48 -1
View File
@@ -233,6 +233,7 @@ type CoreService struct {
bridgeMu sync.Mutex
bridgeSeq uint64
logStore LogBodyStore
logIngestMu [64]sync.Mutex
logEventMu sync.Mutex
logEventSubscribers map[uint64]logEventSubscriber
logEventSubscriberSeq uint64
@@ -2476,6 +2477,13 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
}
func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) error {
lock := svc.logIngestLock(job.ServerInstanceID)
lock.Lock()
defer lock.Unlock()
return svc.ensureJobLogStreamsUnlocked(job, stamp)
}
func (svc *CoreService) ensureJobLogStreamsUnlocked(job domain.Job, stamp time.Time) error {
if strings.TrimSpace(job.ServerInstanceID) == "" || strings.TrimSpace(job.ID) == "" {
return nil
}
@@ -2524,7 +2532,7 @@ func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) err
CreatedAt: stamp,
UpdatedAt: stamp,
}
if _, err := svc.CreateLogStream(stream); err != nil && !errors.Is(err, repo.ErrDuplicate) {
if _, err := svc.createLogStream(stream); err != nil && !errors.Is(err, repo.ErrDuplicate) {
return err
}
}
@@ -2539,6 +2547,10 @@ func runLogStreamID(runEndpointID string, serverInstanceID string, streamKey str
return fmt.Sprintf("run.%s.%s.%s", runEndpointID, serverInstanceID, streamKey)
}
func runSessionLogStreamID(runEndpointID string, serverInstanceID string, logSessionID string, streamKey string) string {
return fmt.Sprintf("run.%s.%s.%s.%s", runEndpointID, serverInstanceID, logSessionID, streamKey)
}
func (svc *CoreService) GetJob(id string) (domain.Job, error) {
job, err := svc.store.Jobs().Get(id)
if err != nil {
@@ -2587,6 +2599,22 @@ func (svc *CoreService) ListArtifacts(filter domain.ArtifactFilter) ([]domain.Ar
}
func (svc *CoreService) CreateLogStream(stream domain.LogStream) (domain.LogStream, error) {
lock := svc.logIngestLock(stream.ServerInstanceID)
lock.Lock()
defer lock.Unlock()
return svc.createLogStream(stream)
}
func (svc *CoreService) logIngestLock(serverInstanceID string) *sync.Mutex {
hash := uint32(2166136261)
for index := 0; index < len(serverInstanceID); index++ {
hash ^= uint32(serverInstanceID[index])
hash *= 16777619
}
return &svc.logIngestMu[hash%uint32(len(svc.logIngestMu))]
}
func (svc *CoreService) createLogStream(stream domain.LogStream) (domain.LogStream, error) {
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
if err != nil {
return domain.LogStream{}, fmt.Errorf("get server instance dependency: %w", err)
@@ -2604,12 +2632,31 @@ func (svc *CoreService) CreateLogStream(stream domain.LogStream) (domain.LogStre
if err := validator.ValidateLogStream(stream); err != nil {
return domain.LogStream{}, err
}
if err := svc.validateLogStreamSession(stream); err != nil {
return domain.LogStream{}, err
}
if err := svc.store.LogStreams().Create(stream); err != nil {
return domain.LogStream{}, err
}
return domain.CopyLogStream(stream), nil
}
func (svc *CoreService) validateLogStreamSession(stream domain.LogStream) error {
if stream.LogSessionID == "" {
return nil
}
streams, err := svc.store.LogStreams().List(domain.LogStreamFilter{ServerInstanceID: stream.ServerInstanceID})
if err != nil {
return err
}
for _, existing := range streams {
if existing.LogSessionID == stream.LogSessionID && !existing.SessionStartedAt.Equal(stream.SessionStartedAt) {
return validationError("log session metadata conflicts with an existing stream")
}
}
return nil
}
func (svc *CoreService) GetLogStream(id string) (domain.LogStream, error) {
return svc.store.LogStreams().Get(id)
}
+9
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
@@ -255,6 +256,14 @@ func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLif
if err := svc.validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if action == domain.ServerLifecycleActionStart {
instance.LifecycleProcessID = ""
instance.LifecycleObservationSeq = 0
instance.LifecycleObservedAt = time.Time{}
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
}
job, err := svc.dispatchLifecycleJob(instance, action, command.IdempotencyKey)
if err != nil {
@@ -50,6 +50,7 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.RunLifecycleReportResult{}, err
}
svc.publishLogProcessState(instance)
}
auditResult := domain.AuditResultSuccess
if report.State == domain.JobStateFailed || report.State == domain.JobStateCancelled {
@@ -140,6 +141,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
if err := svc.store.ServerInstances().Update(instance); err != nil {
return err
}
svc.publishLogProcessState(instance)
auditResult := domain.AuditResultSuccess
if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled {
auditResult = domain.AuditResultFailed
+56
View File
@@ -3,6 +3,7 @@ package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
@@ -103,6 +104,61 @@ func TestLifecycleProjectedStateUsesRunProcessFacts(t *testing.T) {
}
}
func TestLifecycleJobResultsPublishProcessStateEvents(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{ID: "server-state-events", PluginID: "server.scum", RunEndpointID: "run-local", Name: "State Events", IdempotencyKey: "state-events-create", ProfileKey: "local"})
if err != nil {
t.Fatalf("create lifecycle server: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, created.Instance.ID, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
ready, err := svc.GetServerInstance(created.Instance.ID)
if err != nil {
t.Fatalf("get ready server: %v", err)
}
if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: ready.ID, ExpectedConfigVersion: ready.ConfigVersion, IdempotencyKey: "state-events-start"}); err != nil {
t.Fatalf("dispatch start: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, ready.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
subscription, err := svc.SubscribeLogEvents(ready.ID)
if err != nil {
t.Fatalf("subscribe state events: %v", err)
}
defer subscription.Close()
running, err := svc.GetServerInstance(ready.ID)
if err != nil {
t.Fatalf("get running server: %v", err)
}
if _, err := svc.StopServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: running.ID, ExpectedConfigVersion: running.ConfigVersion, IdempotencyKey: "state-events-stop"}); err != nil {
t.Fatalf("dispatch stop: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, running.ID, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
assertLogProcessStateEvent(t, subscription, domain.ServerInstanceStateStopped)
stopped, err := svc.GetServerInstance(running.ID)
if err != nil {
t.Fatalf("get stopped server: %v", err)
}
if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{ServerInstanceID: stopped.ID, ExpectedConfigVersion: stopped.ConfigVersion, IdempotencyKey: "state-events-restart"}); err != nil {
t.Fatalf("dispatch restart: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, stopped.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
assertLogProcessStateEvent(t, subscription, domain.ServerInstanceStateRunning)
}
func assertLogProcessStateEvent(t *testing.T, subscription LogEventSubscription, want domain.ServerInstanceState) {
t.Helper()
select {
case event := <-subscription.Events:
if event.Kind != LogEventSubscriptionEventProcessState || event.ProcessState != want {
t.Fatalf("unexpected process state event: %+v", event)
}
case <-time.After(time.Second):
t.Fatalf("expected process state event %q", want)
}
}
func TestCoreServiceCreatesTargetBoundDraftAndRequiresDedicatedRunRegistration(t *testing.T) {
svc, _ := newLifecycleRunService(t)
plugin := createLifecyclePlugin(t, svc)
+8
View File
@@ -27,6 +27,14 @@ func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
if !validLogStreamSource(batch.Source) {
violations = append(violations, "source is invalid")
}
hasSessionID := strings.TrimSpace(batch.LogSessionID) != ""
hasSessionStart := !batch.SessionStartedAt.IsZero()
if hasSessionID != hasSessionStart {
violations = append(violations, "logSessionId and sessionStartedAt must be provided together")
}
if (hasSessionID || hasSessionStart) && batch.Source != domain.LogStreamSourceProcess {
violations = append(violations, "log session metadata is only valid for process streams")
}
if batch.FirstSeq == 0 || batch.LastSeq == 0 {
violations = append(violations, "sequence range must be positive")
}
+8
View File
@@ -1667,6 +1667,14 @@ func ValidateLogStream(stream domain.LogStream) error {
if !validLogStorageBackend(stream.StorageBackend) {
violations = append(violations, "storageBackend is invalid")
}
hasSessionID := strings.TrimSpace(stream.LogSessionID) != ""
hasSessionStart := !stream.SessionStartedAt.IsZero()
if hasSessionID != hasSessionStart {
violations = append(violations, "logSessionId and sessionStartedAt must be provided together")
}
if (hasSessionID || hasSessionStart) && stream.Source != domain.LogStreamSourceProcess {
violations = append(violations, "log session metadata is only valid for process streams")
}
return finish(violations)
}
+13 -1
View File
@@ -736,8 +736,18 @@ describe("PlatformApiClient AI providers", () => {
expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events");
});
it("scopes explicit terminal history lists to the selected server", async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify({ items: [], count: 0 }), { status: 200, headers: { "Content-Type": "application/json" } }));
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient("/api/v1", () => "terminal-session");
await expect(client.listLogStreams("server/scum 1")).resolves.toEqual({ items: [], count: 0 });
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/log-streams?serverInstanceId=server%2Fscum%201");
});
it("streams terminal log SSE with bearer authorization and dispatches live events", async () => {
const payload = "event: stream\ndata: {\"id\":\"log-1\",\"serverInstanceId\":\"server-1\"}\n\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":3,\"line\":\"live line\"}}\n\nevent: ready\ndata: {\"serverInstanceId\":\"server-1\"}\n\n";
const payload = "event: session\ndata: {\"serverInstanceId\":\"server-1\",\"logSessionId\":\"session-2\"}\n\nevent: stream\ndata: {\"id\":\"log-1\",\"serverInstanceId\":\"server-1\"}\n\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":3,\"line\":\"live line\"}}\n\nevent: ready\ndata: {\"serverInstanceId\":\"server-1\"}\n\n";
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer terminal-session");
expect(new Headers(init?.headers).get("Accept")).toBe("text/event-stream");
@@ -747,11 +757,13 @@ describe("PlatformApiClient AI providers", () => {
const client = new PlatformApiClient("/api/v1", () => "terminal-session");
const events: string[] = [];
const stream = client.openServerLogEvents("server-1", { historyLimit: 2 });
stream.addEventListener("session", (event) => events.push(`session:${event.data}`));
stream.addEventListener("stream", (event) => events.push(`stream:${event.data}`));
stream.addEventListener("log", (event) => events.push(`log:${event.data}`));
stream.addEventListener("ready", (event) => events.push(`ready:${event.data}`));
await vi.waitFor(() => expect(events).toEqual([
'session:{"serverInstanceId":"server-1","logSessionId":"session-2"}',
'stream:{"id":"log-1","serverInstanceId":"server-1"}',
'log:{"streamId":"log-1","entry":{"seq":3,"line":"live line"}}',
'ready:{"serverInstanceId":"server-1"}'
+3 -2
View File
@@ -602,8 +602,9 @@ export class PlatformApiClient {
});
}
async listLogStreams(): Promise<LogStreamListResponse> {
return this.request<LogStreamListResponse>("/log-streams");
async listLogStreams(serverInstanceId?: string): Promise<LogStreamListResponse> {
const query = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : "";
return this.request<LogStreamListResponse>(`/log-streams${query}`);
}
openServerLogEvents(id: string, options: LogStreamEventOptions = {}): PlatformEventStream {
+12 -1
View File
@@ -600,7 +600,6 @@ export interface RuntimeBindingResponse {
export interface ServerLifecycleCommandRequest {
expectedConfigVersion: number;
expectedChecksum?: string;
idempotencyKey: string;
}
@@ -1478,6 +1477,8 @@ export interface LogStreamResponse {
serverInstanceId: string;
source: string;
streamKey: string;
logSessionId?: string;
sessionStartedAt?: string;
latestSeq: number;
storageBackend: string;
retentionPolicy: string;
@@ -1517,6 +1518,8 @@ export interface LogStreamEventResponse {
streamId: string;
source: string;
streamKey: string;
logSessionId?: string;
sessionStartedAt?: string;
latestSeq: number;
entry: LogEntryBody;
}
@@ -1527,6 +1530,14 @@ export interface LogStreamEventsReadyResponse {
serverTime: string;
}
export interface LogStreamEventsSessionResponse {
serverInstanceId: string;
logSessionId?: string;
sessionStartedAt?: string;
streamCount: number;
serverTime: string;
}
export interface LogStreamEventOptions {
historyLimit?: number;
}
@@ -0,0 +1,255 @@
/** @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
const apiMocks = vi.hoisted(() => ({
getGameClientBridgeCommand: vi.fn(),
listLogStreams: vi.fn(),
openServerLogEvents: vi.fn(),
queryLogStream: vi.fn(),
queueGameClientBridgeCommand: vi.fn()
}));
vi.mock("../api/client", () => ({ platformApiClient: apiMocks }));
class FakeEventStream {
onerror: ((event: Event) => void) | null = null;
closed = false;
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
addEventListener(type: string, listener: (event: MessageEvent) => void) {
const listeners = this.listeners.get(type) ?? new Set<(event: MessageEvent) => void>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: (event: MessageEvent) => void) {
this.listeners.get(type)?.delete(listener);
}
close() {
this.closed = true;
this.listeners.clear();
}
emit(type: string, payload: unknown) {
const event = new MessageEvent(type, { data: JSON.stringify(payload) });
this.listeners.get(type)?.forEach((listener) => listener(event));
}
}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let eventStream: FakeEventStream;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
beforeEach(() => {
eventStream = new FakeEventStream();
apiMocks.openServerLogEvents.mockReturnValue(eventStream);
apiMocks.listLogStreams.mockResolvedValue({ items: [], count: 0 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: "", entries: [], nextSeq: 0, latestSeq: 0 });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { callback(0); return 1; });
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe("ServerManagementTerminalDrawer", () => {
it("shows current-session replay and keeps it on a repeated boundary for the same session", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay"));
await emitReady();
expect(container?.textContent).toContain("generation A current replay");
expect(container?.textContent).toContain("当前受管进程会话 · SSE 实时推送");
await emitSession("session-a");
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay"));
expect(container?.textContent).toContain("generation A current replay");
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1);
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
});
it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
await renderDrawer();
await emitSession();
await emitLog("legacy-job", undefined, "job", logEntry(1, "legacy output must stay historical"));
await emitReady();
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
expect(container?.textContent).not.toContain("legacy output must stay historical");
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
});
it("clears generation A on a new session and rejects late generation A events", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A output"));
await emitSession("session-b");
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(2, "late generation A output"));
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "generation B output"));
expect(container?.textContent).not.toContain("generation A output");
expect(container?.textContent).not.toContain("late generation A output");
expect(container?.textContent).toContain("generation B output");
expect(container?.textContent).toContain("Run 已切换到新的受管进程输出会话");
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
});
it("clears a stopped session and restores the running session on the same SSE connection", async () => {
await renderDrawer();
await emitSession("session-a");
await emitStream(logStream("stdout-a", "session-a", "process.stdout"));
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "running output before stop"));
await emitSession();
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(2, "late output after stop"));
expect(container?.textContent).not.toContain("running output before stop");
expect(container?.textContent).not.toContain("late output after stop");
expect(container?.textContent).toContain("当前没有可跟随的受管进程输出");
await emitSession("session-b");
await emitStream(logStream("stdout-b", "session-b", "process.stdout"));
await emitLog("stdout-b", "session-b", "process.stdout", logEntry(1, "running output after recovery"));
expect(container?.textContent).not.toContain("running output before stop");
expect(container?.textContent).toContain("running output after recovery");
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
});
it("keeps selected historical output separate while live output continues in the background", async () => {
const oldStream = logStream("stdout-old", "session-old", "process.stdout", "2026-08-01T00:00:00Z");
oldStream.latestSeq = 900;
apiMocks.listLogStreams.mockResolvedValue({ items: [oldStream], count: 1 });
apiMocks.queryLogStream.mockResolvedValue({ logStreamId: oldStream.id, entries: [logEntry(1, "selected historical output", "2026-08-01T00:00:01Z")], nextSeq: 1, latestSeq: 1 });
await renderDrawer();
await emitSession("session-current");
await emitStream(logStream("stdout-current", "session-current", "process.stdout"));
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "current live output"));
await clickButton("查看历史");
await flushPromises();
const select = container?.querySelector<HTMLSelectElement>('select[aria-label="选择历史日志流"]');
if (!select) throw new Error("history stream selector not found");
await act(async () => setSelectValue(select, oldStream.id));
await flushPromises();
expect(container?.textContent).toContain("selected historical output");
expect(container?.textContent).not.toContain("current live output");
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(2, "new live output while viewing history"));
expect(container?.textContent).not.toContain("new live output while viewing history");
await clickButton("实时输出");
expect(container?.textContent).toContain("current live output");
expect(container?.textContent).toContain("new live output while viewing history");
expect(container?.textContent).not.toContain("selected historical output");
expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1");
expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 });
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
});
it("uses RCON only after an operator submits a command", async () => {
const pending = bridgeCommand("pending");
const succeeded = bridgeCommand("succeeded");
apiMocks.queueGameClientBridgeCommand.mockResolvedValue(pending);
apiMocks.getGameClientBridgeCommand.mockResolvedValue(succeeded);
await renderDrawer();
await emitSession("session-current");
await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output"));
expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled();
const input = container?.querySelector<HTMLInputElement>('.terminal-command-form input');
const form = container?.querySelector<HTMLFormElement>('.terminal-command-form');
if (!input || !form) throw new Error("command form not found");
await act(async () => setInputValue(input, "#ListPlayers"));
await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })));
await flushPromises();
expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledTimes(1);
expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", pending.id);
expect(container?.textContent).toContain("ordinary live output");
});
});
async function renderDrawer() {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />);
});
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 500 });
}
async function emitSession(logSessionId?: string) {
await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime: "2026-08-14T00:00:00Z" }));
}
async function emitStream(stream: LogStreamResponse) {
await act(async () => eventStream.emit("stream", stream));
}
async function emitLog(streamId: string, logSessionId: string | undefined, streamKey: string, entry: LogEntryBody) {
await act(async () => eventStream.emit("log", { serverInstanceId: "server-1", streamId, source: "process", streamKey, logSessionId, latestSeq: entry.seq, entry }));
}
async function emitReady() {
await act(async () => eventStream.emit("ready", { serverInstanceId: "server-1", streamCount: 1, serverTime: "2026-08-14T00:00:00Z" }));
}
async function clickButton(label: string) {
const button = Array.from(container?.querySelectorAll<HTMLButtonElement>("button") ?? []).find((item) => item.textContent?.includes(label));
if (!button) throw new Error(`button not found: ${label}`);
await act(async () => button.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })));
}
async function flushPromises() {
await act(async () => { await Promise.resolve(); });
}
function setInputValue(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function setSelectValue(select: HTMLSelectElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
}
function logStream(id: string, logSessionId: string, streamKey: string, updatedAt = "2026-08-14T00:00:00Z"): LogStreamResponse {
return { id, serverInstanceId: "server-1", source: "process", streamKey, logSessionId, sessionStartedAt: updatedAt, latestSeq: 1, storageBackend: "database", retentionPolicy: "default", createdAt: updatedAt, updatedAt };
}
function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${seq}Z`): LogEntryBody {
return { seq, timestamp, line, redacted: true };
}
function bridgeCommand(state: GameClientBridgeCommandResponse["state"]): GameClientBridgeCommandResponse {
return {
id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-rcon", commandType: "management.command", priority: 50, state, approvalState: "not_required",
result: state === "succeeded" ? { status: "succeeded", summary: "command completed", completedAt: "2026-08-14T00:00:03Z" } : undefined,
expiresAt: "2026-08-14T00:01:00Z", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:03Z", completedAt: state === "succeeded" ? "2026-08-14T00:00:03Z" : undefined
};
}
@@ -1,14 +1,15 @@
import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
import { cx } from "../utils/classes";
import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { EmptyState, ResultBadge } from "./StateViews";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
type TerminalQuickCommand = { label: string; command: string; hint: string };
@@ -89,9 +90,16 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [followLatest, setFollowLatest] = useState(true);
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyStreams, setHistoryStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [historyLines, setHistoryLines] = useState<HistoryLineState>({ status: "idle" });
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
const outputRef = useRef<HTMLDivElement>(null);
const followLatestRef = useRef(true);
const initialHistoryPendingRef = useRef(false);
const liveSessionRef = useRef<string | null | undefined>(undefined);
const historyRequestRef = useRef(0);
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
const supportsCommands = quickCommands.length > 0;
@@ -121,10 +129,17 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setPending(false);
setResult(null);
setHistoryIndex(null);
setLiveSessionId(null);
setHistoryOpen(false);
setHistoryStreams({ status: "loading" });
setHistoryLines({ status: "idle" });
setSelectedHistoryStreamId("");
liveSessionRef.current = undefined;
historyRequestRef.current += 1;
initialHistoryPendingRef.current = true;
followLatestRef.current = true;
setFollowLatest(true);
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]);
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]);
}, [open, supportsCommands]);
useEffect(() => {
@@ -140,9 +155,24 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
if (!open) return undefined;
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow });
events.addEventListener("session", (event) => {
const session = parseLogSessionEvent(event);
if (!session) return;
ready = true;
const nextSessionId = normalizeLogSessionId(session.logSessionId);
const previousSessionId = liveSessionRef.current;
liveSessionRef.current = nextSessionId;
setLiveSessionId(nextSessionId);
if (previousSessionId === nextSessionId) return;
setStreams({ status: "ready", data: [] });
setLines(nextSessionId
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`)]
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty")]);
lockTerminalFollow();
});
events.addEventListener("stream", (event) => {
const stream = parseLogStreamEvent(event);
if (!stream) return;
if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
});
@@ -153,7 +183,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
});
events.addEventListener("log", (event) => {
const payload = parseServerLogEvent(event);
if (!payload) return;
if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return;
ready = true;
const stream = streamFromServerLogEvent(payload);
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
@@ -165,6 +195,35 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
return () => events.close();
}, [appendLines, lockTerminalFollow, open, serverId]);
useEffect(() => {
if (!open || !historyOpen) return;
let cancelled = false;
setHistoryStreams({ status: "loading" });
void platformApiClient.listLogStreams(serverId).then((response) => {
if (!cancelled) setHistoryStreams({ status: "ready", data: [...response.items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
}).catch((error) => {
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
});
return () => { cancelled = true; };
}, [historyOpen, open, serverId]);
async function selectHistoryStream(streamId: string) {
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
if (!stream) return;
const requestId = historyRequestRef.current + 1;
historyRequestRef.current = requestId;
setSelectedHistoryStreamId(streamId);
setHistoryLines({ status: "loading" });
try {
const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalInitialHistoryWindow), limit: terminalInitialHistoryWindow });
if (historyRequestRef.current !== requestId) return;
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
} catch (error) {
if (historyRequestRef.current !== requestId) return;
setHistoryLines({ status: "error", reason: error instanceof Error ? error.message : "历史日志加载失败" });
}
}
function selectQuickCommand(item: TerminalQuickCommand) {
setCommand(item.command);
setHistoryIndex(null);
@@ -193,9 +252,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
function clearTerminalBuffer() {
if (historyOpen) {
setHistoryLines({ status: "ready", data: [] });
return;
}
setLines([]);
}
function toggleHistory() {
historyRequestRef.current += 1;
setHistoryOpen((current) => !current);
setSelectedHistoryStreamId("");
setHistoryLines({ status: "idle" });
}
function handleTerminalScroll() {
const output = outputRef.current;
if (!output || initialHistoryPendingRef.current) return;
@@ -253,17 +323,19 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
<div className="terminal-output-topbar">
<div>
<strong>{serverName}</strong>
<span> Run · + SSE · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
<span>{historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
</div>
<div>
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span></span></button>
<button type="button" className="terminal-output-action" onClick={toggleHistory}><History size={14} /><span>{historyOpen ? "实时输出" : "查看历史"}</span></button>
<button type="button" className="terminal-output-action" aria-label="关闭打开终端" onClick={onClose}><X size={14} /><span></span></button>
</div>
</div>
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
{streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{streams.reason}</span></div>}
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">Run </span></div>}
{lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
{!historyOpen && streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{streams.reason}</span></div>}
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} />}
{!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>}
{!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
</div>
</section>
<section className="terminal-command-dock" aria-label="terminal command controls">
@@ -279,7 +351,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
<label>
<span>SCUM </span>
<small>Enter / Run </small>
<small>Enter / </small>
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令" : "当前账号没有运行操作权限"} onKeyDown={handleCommandKeyDown} onChange={(event) => { setCommand(event.target.value); setHistoryIndex(null); }} />
</label>
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
@@ -290,6 +362,41 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
);
}
interface HistoryLogViewProps {
streams: LoadState<LogStreamResponse[]>;
lines: HistoryLineState;
selectedStreamId: string;
onSelect: (streamId: string) => Promise<void>;
}
function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryLogViewProps) {
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" />;
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} />;
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" />;
return (
<>
<div className="terminal-line terminal-line-info terminal-source-system">
<time></time>
<span className="terminal-text">
<select aria-label="选择历史日志流" value={selectedStreamId} onChange={(event) => void onSelect(event.target.value)}>
<option value="" disabled></option>
{streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey} · {stream.updatedAt}</option>)}
</select>
</span>
</div>
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" />}
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" />}
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} />}
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" />}
{lines.status === "ready" && lines.data.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
</>
);
}
function TerminalStatusLine({ tone, label }: { tone: "info" | "warn" | "error"; label: string }) {
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{label}</span></div>;
}
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
return terminalQuickCommandCatalog[pluginId] ?? [];
}
@@ -331,6 +438,16 @@ function terminalSourceClass(value?: string): string {
return "log";
}
function normalizeLogSessionId(value?: string): string | null {
const normalized = value?.trim();
return normalized || null;
}
function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessionId: string | null | undefined): boolean {
const normalizedEventSessionId = normalizeLogSessionId(eventSessionId);
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
}
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
}
+11 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import type { GamePluginResponse, ServerInstanceResponse } from "../api/types";
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
import { minimalServerCreateRequestFromForm, serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
import { minimalServerCreateRequestFromForm, serverCreateRequestFromForm, serverInstanceIdFromName, serverLifecycleCommandRequest } from "./serverManagement";
const plugin: GamePluginResponse = {
id: "game.runtime",
@@ -109,6 +109,15 @@ describe("runtime profile server creation contracts", () => {
expect(serverInstanceIdFromName("测试服", 18)).toBe("server-18");
});
it("matches the platform lifecycle command DTO exactly", () => {
const instance = { id: "server-1", configVersion: 7, configChecksum: "sha256:unused" } as ServerInstanceResponse;
expect(serverLifecycleCommandRequest(instance, "start", 20)).toEqual({
expectedConfigVersion: 7,
idempotencyKey: "web:start:server-1:20"
});
});
it("keeps complete paths and commands in a write-only deployment payload", () => {
const form = defaultServerCreateForm([plugin], []);
const request = serverCreateRequestFromForm({ ...form, name: "Venv Server", deploymentMode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py", shell: "" }, 19);
-1
View File
@@ -59,7 +59,6 @@ export function serverInstanceIdFromName(name: string, sequence = Date.now()): s
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "deploy" | "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
return {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
};
}
+9 -1
View File
@@ -1,4 +1,4 @@
import type { LogEntryBody, LogStreamEventResponse, LogStreamResponse } from "../api/types";
import type { LogEntryBody, LogStreamEventResponse, LogStreamEventsSessionResponse, LogStreamResponse } from "../api/types";
export type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
@@ -14,6 +14,12 @@ export function parseServerLogEvent(event: MessageEvent): LogStreamEventResponse
return value as unknown as LogStreamEventResponse;
}
export function parseLogSessionEvent(event: MessageEvent): LogStreamEventsSessionResponse | null {
const value = parseEventData(event);
if (!isRecord(value) || typeof value.serverInstanceId !== "string") return null;
return value as unknown as LogStreamEventsSessionResponse;
}
export function entryFromServerLogEvent(event: LogStreamEventResponse): LiveLogEntry {
return { ...event.entry, source: event.source || event.streamKey, streamId: event.streamId, streamKey: event.streamKey };
}
@@ -24,6 +30,8 @@ export function streamFromServerLogEvent(event: LogStreamEventResponse): LogStre
serverInstanceId: event.serverInstanceId,
source: event.source,
streamKey: event.streamKey,
logSessionId: event.logSessionId,
sessionStartedAt: event.sessionStartedAt,
latestSeq: event.latestSeq,
storageBackend: "",
retentionPolicy: "",
+1
View File
@@ -4,6 +4,7 @@ Use these root-level commands for day-to-day work:
- `scripts/dev-start.sh`: start the local platform API, run worker, and platform_web console.
- `scripts/dev-smoke.sh`: seed and verify the API-backed local plugin/server fixtures.
- `LOCAL_DEBUG_SELF_START=true LOCAL_DEBUG_LOG_SESSION_SMOKE_ONLY=true scripts/dev-smoke.sh`: run the focused current supervised-log session restart/SSE proof without distribution builds.
- `scripts/dev-stop.sh`: stop the managed local debug stack.
- `scripts/dev-reset.sh`: stop the stack and delete only the safe local debug data root.
- `scripts/browser-acceptance.sh`: run the full self-starting browser acceptance suite.
+471 -30
View File
@@ -10,6 +10,7 @@ API_URL="$PLATFORM_URL/api/v1"
WORK_DIR="$LOCAL_DEBUG_ROOT/smoke"
SMOKE_INVOCATION_ID="${SMOKE_INVOCATION_ID:-$(date -u +%Y%m%d%H%M%S)-$$}"
SERVER_LOCAL_ID="server-local-debug-$SMOKE_INVOCATION_ID"
LOG_SESSION_SERVER_ID="server-log-session-$SMOKE_INVOCATION_ID"
SCUM_ALPHA_ID="scum-alpha-$SMOKE_INVOCATION_ID"
SCUM_BETA_ID="scum-beta-$SMOKE_INVOCATION_ID"
SCUM_DYNAMIC_ID="scum-dynamic-$SMOKE_INVOCATION_ID"
@@ -17,11 +18,21 @@ GENERATED_RUN_ENDPOINT_ID="server-run-$SERVER_LOCAL_ID"
GENERATED_RUN_BIN="$WORK_DIR/generated-$SERVER_LOCAL_ID-run"
GENERATED_RUN_LOG="$LOCAL_DEBUG_LOG_DIR/generated-$SERVER_LOCAL_ID-run.log"
GENERATED_RUN_PID_FILE="$LOCAL_DEBUG_PID_DIR/generated-$SERVER_LOCAL_ID-run.pid"
LOG_SESSION_SCOPE="$RUN_WORKSPACE_ROOT/instances/$LOG_SESSION_SERVER_ID/run-local"
LOG_SESSION_MARKER_PREFIX="SMOKE-CURRENT-SESSION-$SMOKE_INVOCATION_ID"
LOG_SESSION_SSE_FILE="$WORK_DIR/current-log-session.events.sse"
LOG_SESSION_SSE_ERROR_FILE="$WORK_DIR/current-log-session.events.stderr.log"
LOG_SESSION_SSE_PID=""
LOG_SESSION_LEGACY_STREAM_ID="legacy.$LOG_SESSION_SERVER_ID.stdout"
LOG_SESSION_JOB_STREAM_ID="job.smoke-$SMOKE_INVOCATION_ID.stdout"
LOG_SESSION_FILE_STREAM_ID="file.$LOG_SESSION_SERVER_ID.backfill"
LOG_SESSION_REAL_JOB_STREAM_IDS=""
mkdir -p "$WORK_DIR"
cat >"$WORK_DIR/run-build-config.env" <<EOF
SMOKE_INVOCATION_ID=$SMOKE_INVOCATION_ID
SERVER_LOCAL_ID=$SERVER_LOCAL_ID
LOG_SESSION_SERVER_ID=$LOG_SESSION_SERVER_ID
SCUM_ALPHA_ID=$SCUM_ALPHA_ID
SCUM_BETA_ID=$SCUM_BETA_ID
SCUM_DYNAMIC_ID=$SCUM_DYNAMIC_ID
@@ -45,6 +56,7 @@ EOF
prepare_run_workspace() {
local_debug_prepare_run_lifecycle_templates
local_debug_seed_server_lifecycle_workspace "$SERVER_LOCAL_ID" game.example run-local
local_debug_seed_server_lifecycle_workspace "$LOG_SESSION_SERVER_ID" game.example run-local
local_debug_seed_server_lifecycle_workspace "$SCUM_ALPHA_ID" game.scum run-local
local_debug_seed_server_lifecycle_workspace "$SCUM_BETA_ID" game.scum run-local
local_debug_seed_server_lifecycle_workspace "$SCUM_DYNAMIC_ID" game.scum run-local
@@ -52,6 +64,43 @@ prepare_run_workspace() {
prepare_run_workspace
cat >"$WORK_DIR/current-log-session-fixture.sh" <<EOF
#!/usr/bin/env sh
set -eu
generation="\$(cat smoke-generation 2>/dev/null || printf A)"
printf '%s-%s-START-STDOUT\n' '$LOG_SESSION_MARKER_PREFIX' "\$generation"
printf '%s-%s-START-STDERR\n' '$LOG_SESSION_MARKER_PREFIX' "\$generation" >&2
printf '%s\n' "\$\$" >smoke-process.pid
last_command=""
while :; do
command="\$(cat smoke-command 2>/dev/null || true)"
if [ -n "\$command" ] && [ "\$command" != "\$last_command" ]; then
case "\$command" in
resume)
printf '%s-%s-RUN-RESUME-STDOUT\n' '$LOG_SESSION_MARKER_PREFIX' "\$generation"
printf '%s-%s-RUN-RESUME-STDERR\n' '$LOG_SESSION_MARKER_PREFIX' "\$generation" >&2
;;
esac
last_command="\$command"
printf '%s\n' "\$command" >smoke-last-command
fi
sleep 0.2
done
EOF
cat >"$WORK_DIR/current-log-session-install-fixture.sh" <<EOF
#!/usr/bin/env sh
set -eu
printf '%s-HISTORICAL-JOB-STDOUT\n' '$LOG_SESSION_MARKER_PREFIX'
printf '%s-HISTORICAL-JOB-STDERR\n' '$LOG_SESSION_MARKER_PREFIX' >&2
EOF
cp "$WORK_DIR/current-log-session-fixture.sh" "$LOG_SESSION_SCOPE/bin/game-server"
cp "$WORK_DIR/current-log-session-install-fixture.sh" "$LOG_SESSION_SCOPE/bin/install-server"
chmod 700 "$LOG_SESSION_SCOPE/bin/game-server"
chmod 700 "$LOG_SESSION_SCOPE/bin/install-server"
printf 'A\n' >"$LOG_SESSION_SCOPE/smoke-generation"
rm -f "$LOG_SESSION_SCOPE/smoke-command" "$LOG_SESSION_SCOPE/smoke-last-command" "$LOG_SESSION_SCOPE/smoke-process.pid"
SELF_STARTED_PIDS=()
cleanup_self_started() {
@@ -63,6 +112,83 @@ cleanup_self_started() {
done
}
cleanup_log_session_fixture() {
if [[ -n "${LOG_SESSION_SSE_PID:-}" ]] && kill -0 "$LOG_SESSION_SSE_PID" 2>/dev/null; then
kill "$LOG_SESSION_SSE_PID" 2>/dev/null || true
wait "$LOG_SESSION_SSE_PID" 2>/dev/null || true
fi
local fixture_pid_file="$LOG_SESSION_SCOPE/smoke-process.pid"
if [[ -f "$fixture_pid_file" ]]; then
local fixture_pid
fixture_pid="$(<"$fixture_pid_file")"
if [[ "$fixture_pid" =~ ^[0-9]+$ ]] && kill -0 "$fixture_pid" 2>/dev/null; then
local fixture_command
fixture_command="$(ps -p "$fixture_pid" -o command= 2>/dev/null || true)"
if [[ "$fixture_command" == *"$LOG_SESSION_SCOPE/bin/game-server"* ]]; then
kill "$fixture_pid" 2>/dev/null || true
fi
fi
fi
}
cleanup_smoke() {
cleanup_log_session_fixture
cleanup_self_started
}
trap cleanup_smoke EXIT
launch_bootstrap_run() {
local log_mode="${1:-overwrite}"
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR"
if [[ "$log_mode" == "append" ]]; then
(
cd "$(dirname "$RUN_BOOTSTRAP_BIN")"
exec env \
GOCACHE="$GOCACHE" \
RUN_MODE="$RUN_MODE" \
RUN_PLATFORM_URL="$RUN_PLATFORM_URL" \
RUN_ENDPOINT_ID="$RUN_ENDPOINT_ID" \
RUN_DISPLAY_NAME="$RUN_DISPLAY_NAME" \
RUN_VERSION="$RUN_VERSION" \
RUN_REGISTRATION_TOKEN="$RUN_REGISTRATION_TOKEN" \
RUN_WORKSPACE_ROOT="$RUN_WORKSPACE_ROOT" \
RUN_BUILD_SOURCE_ROOT="$RUN_BUILD_SOURCE_ROOT" \
RUN_SPOOL_ROOT="$RUN_SPOOL_ROOT" \
RUN_MAX_JOBS="$RUN_MAX_JOBS" \
RUN_HEARTBEAT_INTERVAL_MS="$RUN_HEARTBEAT_INTERVAL_MS" \
RUN_POLL_INTERVAL_MS="$RUN_POLL_INTERVAL_MS" \
RUN_RETRY_BACKOFF_MS="$RUN_RETRY_BACKOFF_MS" \
"$RUN_BOOTSTRAP_BIN"
) >>"$LOCAL_DEBUG_LOG_DIR/run.log" 2>&1 &
else
(
cd "$(dirname "$RUN_BOOTSTRAP_BIN")"
exec env \
GOCACHE="$GOCACHE" \
RUN_MODE="$RUN_MODE" \
RUN_PLATFORM_URL="$RUN_PLATFORM_URL" \
RUN_ENDPOINT_ID="$RUN_ENDPOINT_ID" \
RUN_DISPLAY_NAME="$RUN_DISPLAY_NAME" \
RUN_VERSION="$RUN_VERSION" \
RUN_REGISTRATION_TOKEN="$RUN_REGISTRATION_TOKEN" \
RUN_WORKSPACE_ROOT="$RUN_WORKSPACE_ROOT" \
RUN_BUILD_SOURCE_ROOT="$RUN_BUILD_SOURCE_ROOT" \
RUN_SPOOL_ROOT="$RUN_SPOOL_ROOT" \
RUN_MAX_JOBS="$RUN_MAX_JOBS" \
RUN_HEARTBEAT_INTERVAL_MS="$RUN_HEARTBEAT_INTERVAL_MS" \
RUN_POLL_INTERVAL_MS="$RUN_POLL_INTERVAL_MS" \
RUN_RETRY_BACKOFF_MS="$RUN_RETRY_BACKOFF_MS" \
"$RUN_BOOTSTRAP_BIN"
) >"$LOCAL_DEBUG_LOG_DIR/run.log" 2>&1 &
fi
local run_pid="$!"
printf '%s' "$run_pid" >"$LOCAL_DEBUG_PID_DIR/run.pid"
if [[ "${LOCAL_DEBUG_SELF_START:-false}" == "true" ]]; then
SELF_STARTED_PIDS+=("$run_pid")
fi
}
wait_for_url() {
local name="$1"
local url="$2"
@@ -127,8 +253,9 @@ assert_no_managed_run_process() {
start_self_hosted_stack() {
mkdir -p "$LOCAL_DEBUG_LOG_DIR" "$LOCAL_DEBUG_PID_DIR" "$PLATFORM_DATA_DIR" "$PLATFORM_LOG_DIR" "$PLATFORM_ARTIFACT_DIR" "$RUN_WORKSPACE_ROOT" "$RUN_SPOOL_ROOT" "$RUN_BUILD_BUCKET_ROOT" "$GOCACHE"
local_debug_prepare_distribution_builder
trap cleanup_self_started EXIT
if [[ "${LOCAL_DEBUG_LOG_SESSION_SMOKE_ONLY:-false}" != "true" ]]; then
local_debug_prepare_distribution_builder
fi
printf 'self-starting platform for local debug smoke\n'
(
@@ -172,27 +299,7 @@ start_self_hosted_stack() {
printf 'self-starting run worker for local debug smoke\n'
local_debug_build_bootstrap_run
(
cd "$(dirname "$RUN_BOOTSTRAP_BIN")"
exec env \
GOCACHE="$GOCACHE" \
RUN_MODE="$RUN_MODE" \
RUN_PLATFORM_URL="$RUN_PLATFORM_URL" \
RUN_ENDPOINT_ID="$RUN_ENDPOINT_ID" \
RUN_DISPLAY_NAME="$RUN_DISPLAY_NAME" \
RUN_VERSION="$RUN_VERSION" \
RUN_REGISTRATION_TOKEN="$RUN_REGISTRATION_TOKEN" \
RUN_WORKSPACE_ROOT="$RUN_WORKSPACE_ROOT" \
RUN_BUILD_SOURCE_ROOT="$RUN_BUILD_SOURCE_ROOT" \
RUN_SPOOL_ROOT="$RUN_SPOOL_ROOT" \
RUN_MAX_JOBS="$RUN_MAX_JOBS" \
RUN_HEARTBEAT_INTERVAL_MS="$RUN_HEARTBEAT_INTERVAL_MS" \
RUN_POLL_INTERVAL_MS="$RUN_POLL_INTERVAL_MS" \
RUN_RETRY_BACKOFF_MS="$RUN_RETRY_BACKOFF_MS" \
"$RUN_BOOTSTRAP_BIN"
) >"$LOCAL_DEBUG_LOG_DIR/run.log" 2>&1 &
SELF_STARTED_PIDS+=("$!")
printf '%s' "$!" >"$LOCAL_DEBUG_PID_DIR/run.pid"
launch_bootstrap_run overwrite
}
if [[ "${LOCAL_DEBUG_SELF_START:-false}" == "true" ]]; then
@@ -329,7 +436,7 @@ if (!logSources.some((source) => source.kind === "process.stderr" && source.stre
if (!plugin.gameClientBridge?.commands?.length) {
missing.push("game client bridge declarations");
}
for (const pageKey of ["files-config", "players", "squads", "live-map", "gifts"]) {
for (const pageKey of ["players", "squads", "live-map", "gifts"]) {
if (!pageKeys.includes(pageKey)) {
missing.push(`SCUM page ${pageKey}`);
}
@@ -636,6 +743,201 @@ NODE
exit 1
}
wait_for_job_success() {
local response_file="$1"
local output_file="$2"
local label="$3"
local job_id
job_id="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const id=data.job?.id || data.id; if (!id) process.exit(2); process.stdout.write(id);' "$response_file")"
rm -f "$output_file"
for _ in $(seq 1 60); do
json_get "$API_URL/jobs/$job_id" "$output_file" "${AUTH_HEADER[@]}" || true
if [[ -s "$output_file" ]]; then
local state
state="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.stdout.write(data.state || "unknown");' "$output_file")"
case "$state" in
succeeded)
return 0
;;
failed | cancelled)
printf '%s job %s reached %s\n' "$label" "$job_id" "$state" >&2
sed -n '1,200p' "$output_file" >&2
return 1
;;
esac
fi
sleep 1
done
printf '%s job %s did not succeed before timeout\n' "$label" "$job_id" >&2
[[ -s "$output_file" ]] && sed -n '1,200p' "$output_file" >&2
return 1
}
dump_log_session_diagnostics() {
local reason="$1"
printf 'current log session smoke diagnostics: %s\n' "$reason" >&2
for file in \
"$WORK_DIR/log-session-streams.response.json" \
"$LOG_SESSION_SSE_FILE" \
"$LOG_SESSION_SSE_ERROR_FILE" \
"$LOCAL_DEBUG_LOG_DIR/run.log"; do
if [[ -s "$file" ]]; then
printf '%s (last 160 lines):\n' "$file" >&2
tail -n 160 "$file" >&2
fi
done
}
wait_for_file_literal() {
local label="$1"
local file="$2"
local literal="$3"
local attempts="${4:-60}"
for _ in $(seq 1 "$attempts"); do
if [[ -f "$file" ]] && grep -Fq -- "$literal" "$file"; then
return 0
fi
if [[ -n "${LOG_SESSION_SSE_PID:-}" ]] && ! kill -0 "$LOG_SESSION_SSE_PID" 2>/dev/null; then
dump_log_session_diagnostics "$label: SSE client exited"
return 1
fi
sleep 1
done
dump_log_session_diagnostics "$label: timed out waiting for $literal"
return 1
}
wait_for_persisted_log_marker() {
local marker="$1"
local evidence_file="$2"
local stream_scope="${3:-session}"
local streams_file="$WORK_DIR/log-session-streams.response.json"
for attempt in $(seq 1 60); do
json_get "$API_URL/log-streams?serverInstanceId=$LOG_SESSION_SERVER_ID" "$streams_file" "${AUTH_HEADER[@]}" || true
if [[ -s "$streams_file" ]]; then
local stream_ids
stream_ids="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); const scope=process.argv[2]; for (const item of data.items || []) if (scope === "history" || item.logSessionId) console.log(item.id);' "$streams_file" "$stream_scope" 2>/dev/null || true)"
local stream_id
for stream_id in $stream_ids; do
local request_file="$WORK_DIR/log-session-query-$attempt.request.json"
printf '{"logStreamId":"%s","afterSeq":0,"limit":200}\n' "$stream_id" >"$request_file"
if json_post "$API_URL/log-streams/query" "$request_file" "$evidence_file" "${AUTH_HEADER[@]}" && node -e 'const fs=require("fs"); const marker=process.argv[2]; const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.exit((data.entries || []).some((entry) => entry.line === marker) ? 0 : 1);' "$evidence_file" "$marker"; then
return 0
fi
done
fi
sleep 1
done
dump_log_session_diagnostics "persisted marker timeout: $marker"
return 1
}
current_log_session_id() {
json_get "$API_URL/log-streams?serverInstanceId=$LOG_SESSION_SERVER_ID" "$WORK_DIR/log-session-streams.response.json" "${AUTH_HEADER[@]}"
node - "$WORK_DIR/log-session-streams.response.json" <<'NODE'
const fs = require("fs");
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const current = (response.items || [])
.filter((item) => item.source === "process" && item.logSessionId && item.sessionStartedAt)
.sort((left, right) => Date.parse(right.sessionStartedAt) - Date.parse(left.sessionStartedAt) || right.logSessionId.localeCompare(left.logSessionId))[0];
if (!current) process.exit(2);
process.stdout.write(current.logSessionId);
NODE
}
wait_for_run_heartbeat_after() {
local previous_heartbeat="$1"
local endpoint_file="$WORK_DIR/log-session-run-endpoint.response.json"
for _ in $(seq 1 45); do
if json_get "$API_URL/run/endpoints/$RUN_ENDPOINT_ID" "$endpoint_file" "${AUTH_HEADER[@]}" 2>/dev/null && node - "$endpoint_file" "$RUN_ENDPOINT_ID" "$previous_heartbeat" <<'NODE'
const fs = require("fs");
const endpoint = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const current = Date.parse(endpoint.lastHeartbeatAt || endpoint.LastHeartbeatAt || "");
const previous = Date.parse(process.argv[4] || "");
process.exit(endpoint.id === process.argv[3] && endpoint.status === "online" && Number.isFinite(current) && current > previous ? 0 : 1);
NODE
then
return 0
fi
sleep 1
done
dump_log_session_diagnostics "Run did not heartbeat after restart"
return 1
}
restart_bootstrap_run_preserving_process() {
local endpoint_file="$WORK_DIR/log-session-run-endpoint-before-restart.response.json"
json_get "$API_URL/run/endpoints/$RUN_ENDPOINT_ID" "$endpoint_file" "${AUTH_HEADER[@]}"
local previous_heartbeat
previous_heartbeat="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.stdout.write(data.lastHeartbeatAt || data.LastHeartbeatAt || "");' "$endpoint_file")"
local run_pid
run_pid="$(<"$LOCAL_DEBUG_PID_DIR/run.pid")"
if [[ ! "$run_pid" =~ ^[0-9]+$ ]] || ! kill -0 "$run_pid" 2>/dev/null; then
dump_log_session_diagnostics "managed Run pid is unavailable before restart"
return 1
fi
printf 'stopping Run pid %s while leaving its supervised process alive\n' "$run_pid"
kill "$run_pid"
for _ in $(seq 1 40); do
if ! kill -0 "$run_pid" 2>/dev/null; then
break
fi
sleep 0.25
done
if kill -0 "$run_pid" 2>/dev/null; then
dump_log_session_diagnostics "Run pid $run_pid did not stop"
return 1
fi
printf 'resume\n' >"$LOG_SESSION_SCOPE/smoke-command.next"
mv "$LOG_SESSION_SCOPE/smoke-command.next" "$LOG_SESSION_SCOPE/smoke-command"
wait_for_file_literal "supervised process output while Run is down" "$LOG_SESSION_SCOPE/smoke-last-command" "resume" 40
launch_bootstrap_run append
wait_for_run_heartbeat_after "$previous_heartbeat"
}
write_server_lifecycle_request() {
local action="$1"
local output_file="$2"
json_get "$API_URL/server-instances/$LOG_SESSION_SERVER_ID" "$WORK_DIR/log-session-server-current.response.json" "${AUTH_HEADER[@]}"
local config_version
config_version="$(node -e 'const fs=require("fs"); const data=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!Number.isInteger(data.configVersion)) process.exit(2); process.stdout.write(String(data.configVersion));' "$WORK_DIR/log-session-server-current.response.json")"
cat >"$output_file" <<JSON
{
"expectedConfigVersion": $config_version,
"idempotencyKey": "local-debug-log-session-$action-$SMOKE_INVOCATION_ID-$(date +%s)-$RANDOM"
}
JSON
}
dispatch_log_session_lifecycle() {
local action="$1"
local response_file="$2"
local job_file="$3"
local request_file="$response_file.request.json"
write_server_lifecycle_request "$action" "$request_file"
json_post "$API_URL/server-instances/$LOG_SESSION_SERVER_ID/$action" "$request_file" "$response_file" "${AUTH_HEADER[@]}"
wait_for_job_success "$response_file" "$job_file" "log session $action"
}
create_historical_log_stream() {
local stream_id="$1"
local source="$2"
local stream_key="$3"
local request_file="$WORK_DIR/$stream_id.request.json"
local response_file="$WORK_DIR/$stream_id.response.json"
cat >"$request_file" <<JSON
{
"id": "$stream_id",
"serverInstanceId": "$LOG_SESSION_SERVER_ID",
"source": "$source",
"streamKey": "$stream_key",
"storageBackend": "local-segments",
"retentionPolicy": "local-debug-smoke"
}
JSON
json_post "$API_URL/log-streams" "$request_file" "$response_file" "${AUTH_HEADER[@]}"
}
printf 'checking platform health at %s\n' "$PLATFORM_URL"
json_get "$PLATFORM_URL/healthz" "$WORK_DIR/health.json"
require_file_contains "$WORK_DIR/health.json" '"status"[[:space:]]*:[[:space:]]*"ok"'
@@ -709,7 +1011,11 @@ const manifest = {
start: "actions/start.json",
stop: "actions/stop.json"
}
}]
}],
logSources: [
{ key: "smoke-stdout", kind: "process.stdout", streamKey: "smoke.console.stdout", cursorKind: "sequence", retentionDays: 1 },
{ key: "smoke-stderr", kind: "process.stderr", streamKey: "smoke.console.stderr", cursorKind: "sequence", retentionDays: 1 }
]
}
};
fs.writeFileSync(outputPath, JSON.stringify({
@@ -833,6 +1139,32 @@ cat >"$WORK_DIR/server-runtime-binding.request.json" <<JSON
}
JSON
cat >"$WORK_DIR/create-log-session-server.request.json" <<JSON
{
"id": "$LOG_SESSION_SERVER_ID",
"pluginId": "game.example",
"name": "Current Log Session Smoke $SMOKE_INVOCATION_ID",
"idempotencyKey": "local-debug-log-session-create-$SMOKE_INVOCATION_ID"
}
JSON
cat >"$WORK_DIR/log-session-runtime-binding.request.json" <<JSON
{
"profileKey": "run-local",
"bindings": {}
}
JSON
cat >"$WORK_DIR/log-session-deployment.request.json" <<JSON
{
"runEndpointId": "$RUN_ENDPOINT_ID",
"mode": "existing-server",
"profileKey": "run-local",
"createInputs": {},
"serverRoot": "/srv/local-debug/$LOG_SESSION_SERVER_ID"
}
JSON
cat >"$WORK_DIR/server-run-generate.request.json" <<JSON
{
"targetOs": "$GENERATED_RUN_TARGET_OS",
@@ -887,6 +1219,10 @@ printf 'creating server lifecycle workflow through platform API\n'
create_server_workflow "dev" "$SERVER_LOCAL_ID" "$WORK_DIR/create-server.request.json" "$WORK_DIR/create-server.response.json"
reject_forbidden_fragments "$WORK_DIR/create-server.response.json"
printf 'creating current supervised log session fixture server\n'
create_server_workflow "log session fixture" "$LOG_SESSION_SERVER_ID" "$WORK_DIR/create-log-session-server.request.json" "$WORK_DIR/create-log-session-server.response.json"
reject_forbidden_fragments "$WORK_DIR/create-log-session-server.response.json"
printf 'creating SCUM server lifecycle workflows through platform API\n'
create_server_workflow "SCUM alpha" "$SCUM_ALPHA_ID" "$WORK_DIR/create-scum-alpha.request.json" "$WORK_DIR/create-scum-alpha.response.json"
create_server_workflow "SCUM beta" "$SCUM_BETA_ID" "$WORK_DIR/create-scum-beta.request.json" "$WORK_DIR/create-scum-beta.response.json"
@@ -899,10 +1235,11 @@ require_file_contains "$WORK_DIR/create-scum-beta.response.json" '"pluginId"[[:s
require_file_contains "$WORK_DIR/create-scum-dynamic.response.json" '"pluginId"[[:space:]]*:[[:space:]]*"game.scum"'
SERVER_ID="$(json_id "$WORK_DIR/create-server.response.json")"
CREATED_LOG_SESSION_SERVER_ID="$(json_id "$WORK_DIR/create-log-session-server.response.json")"
CREATED_SCUM_ALPHA_ID="$(json_id "$WORK_DIR/create-scum-alpha.response.json")"
CREATED_SCUM_BETA_ID="$(json_id "$WORK_DIR/create-scum-beta.response.json")"
CREATED_SCUM_DYNAMIC_ID="$(json_id "$WORK_DIR/create-scum-dynamic.response.json")"
if [[ "$SERVER_ID" != "$SERVER_LOCAL_ID" || "$CREATED_SCUM_ALPHA_ID" != "$SCUM_ALPHA_ID" || "$CREATED_SCUM_BETA_ID" != "$SCUM_BETA_ID" || "$CREATED_SCUM_DYNAMIC_ID" != "$SCUM_DYNAMIC_ID" ]]; then
if [[ "$SERVER_ID" != "$SERVER_LOCAL_ID" || "$CREATED_LOG_SESSION_SERVER_ID" != "$LOG_SESSION_SERVER_ID" || "$CREATED_SCUM_ALPHA_ID" != "$SCUM_ALPHA_ID" || "$CREATED_SCUM_BETA_ID" != "$SCUM_BETA_ID" || "$CREATED_SCUM_DYNAMIC_ID" != "$SCUM_DYNAMIC_ID" ]]; then
printf 'created server IDs do not match invocation-scoped workspace IDs\n' >&2
exit 1
fi
@@ -929,9 +1266,10 @@ curl -fsS -X PUT -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
reject_forbidden_fragments "$WORK_DIR/server-runtime-binding.response.json"
require_file_contains "$WORK_DIR/server-runtime-binding.response.json" '"status"[[:space:]]*:[[:space:]]*"complete"'
printf 'checking example server platform-builder action\n'
json_get "$API_URL/server-instances/$SERVER_ID/runtime/actions" "$WORK_DIR/server-runtime-actions.response.json" "${AUTH_HEADER[@]}"
node - "$WORK_DIR/server-runtime-actions.response.json" <<'NODE'
if [[ "${LOCAL_DEBUG_LOG_SESSION_SMOKE_ONLY:-false}" != "true" ]]; then
printf 'checking example server platform-builder action\n'
json_get "$API_URL/server-instances/$SERVER_ID/runtime/actions" "$WORK_DIR/server-runtime-actions.response.json" "${AUTH_HEADER[@]}"
node - "$WORK_DIR/server-runtime-actions.response.json" <<'NODE'
const fs = require("fs");
const response = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const action = (response.actions || []).find((candidate) => candidate.key === "generate-run");
@@ -941,7 +1279,110 @@ if (!action || action.available !== true || (action.reason || "").includes("run
process.exit(1);
}
NODE
reject_forbidden_fragments "$WORK_DIR/server-runtime-actions.response.json"
reject_forbidden_fragments "$WORK_DIR/server-runtime-actions.response.json"
fi
printf 'configuring current log session fixture on the local Run endpoint\n'
curl -fsS -X PUT -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
--data-binary "@$WORK_DIR/log-session-runtime-binding.request.json" \
"$API_URL/server-instances/$LOG_SESSION_SERVER_ID/runtime-binding" >"$WORK_DIR/log-session-runtime-binding.response.json"
reject_forbidden_fragments "$WORK_DIR/log-session-runtime-binding.response.json"
require_file_contains "$WORK_DIR/log-session-runtime-binding.response.json" '"status"[[:space:]]*:[[:space:]]*"complete"'
curl -fsS -X PUT -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" \
--data-binary "@$WORK_DIR/log-session-deployment.request.json" \
"$API_URL/server-instances/$LOG_SESSION_SERVER_ID/deployment" >"$WORK_DIR/log-session-deployment.response.json"
reject_forbidden_fragments "$WORK_DIR/log-session-deployment.response.json"
write_server_lifecycle_request deploy "$WORK_DIR/log-session-deploy.request.json"
json_post "$API_URL/server-instances/$LOG_SESSION_SERVER_ID/deploy" "$WORK_DIR/log-session-deploy.request.json" "$WORK_DIR/log-session-deploy.response.json" "${AUTH_HEADER[@]}"
wait_for_job_success "$WORK_DIR/log-session-deploy.response.json" "$WORK_DIR/log-session-deploy-job.response.json" "log session deploy"
LOG_SESSION_DEPLOY_JOB_ID="$(json_id "$WORK_DIR/log-session-deploy-job.response.json")"
LOG_SESSION_REAL_JOB_STREAM_IDS="job.$LOG_SESSION_DEPLOY_JOB_ID.stdout,job.$LOG_SESSION_DEPLOY_JOB_ID.stderr"
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-HISTORICAL-JOB-STDOUT" "$WORK_DIR/log-session-job-stdout-history.response.json" history
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-HISTORICAL-JOB-STDERR" "$WORK_DIR/log-session-job-stderr-history.response.json" history
printf 'seeding explicit legacy, job, and file-tail history stream metadata\n'
create_historical_log_stream "$LOG_SESSION_LEGACY_STREAM_ID" process legacy.stdout
create_historical_log_stream "$LOG_SESSION_JOB_STREAM_ID" process job.stdout
create_historical_log_stream "$LOG_SESSION_FILE_STREAM_ID" file file.tail
printf 'starting generation A without an open terminal or SSE client\n'
dispatch_log_session_lifecycle start "$WORK_DIR/log-session-start-a.response.json" "$WORK_DIR/log-session-start-a-job.response.json"
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-A-START-STDOUT" "$WORK_DIR/log-session-a-stdout-history.response.json"
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-A-START-STDERR" "$WORK_DIR/log-session-a-stderr-history.response.json"
LOG_SESSION_A="$(current_log_session_id)"
if [[ -z "$LOG_SESSION_A" ]]; then
dump_log_session_diagnostics "generation A did not expose a current session"
exit 1
fi
printf '%s\n' "$LOG_SESSION_A" >"$WORK_DIR/current-log-session-a.id"
printf 'opening one persistent Platform SSE connection after generation A was already uploaded\n'
: >"$LOG_SESSION_SSE_FILE"
: >"$LOG_SESSION_SSE_ERROR_FILE"
curl -fsS --no-buffer --max-time 240 -H "Authorization: Bearer $SESSION_ID" -H 'Accept: text/event-stream' \
"$API_URL/server-instances/$LOG_SESSION_SERVER_ID/logs/events?historyLimit=200" \
>"$LOG_SESSION_SSE_FILE" 2>"$LOG_SESSION_SSE_ERROR_FILE" &
LOG_SESSION_SSE_PID="$!"
wait_for_file_literal "initial SSE ready event" "$LOG_SESSION_SSE_FILE" 'event: ready' 60
wait_for_file_literal "generation A stdout replay" "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-A-START-STDOUT" 60
wait_for_file_literal "generation A stderr replay" "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-A-START-STDERR" 60
LOG_SESSION_A_FROM_SSE="$(node "$ROOT_DIR/scripts/local-debug/verify-current-log-session-sse.mjs" session-for-marker "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-A-START-STDOUT")"
if [[ "$LOG_SESSION_A_FROM_SSE" != "$LOG_SESSION_A" ]]; then
dump_log_session_diagnostics "generation A history and SSE selected different sessions"
exit 1
fi
printf 'restarting Run while generation A remains alive\n'
restart_bootstrap_run_preserving_process
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-A-RUN-RESUME-STDOUT" "$WORK_DIR/log-session-a-resume-stdout-history.response.json"
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-A-RUN-RESUME-STDERR" "$WORK_DIR/log-session-a-resume-stderr-history.response.json"
wait_for_file_literal "generation A stdout after Run restart" "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-A-RUN-RESUME-STDOUT" 60
wait_for_file_literal "generation A stderr after Run restart" "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-A-RUN-RESUME-STDERR" 60
LOG_SESSION_A_AFTER_RUN_RESTART="$(node "$ROOT_DIR/scripts/local-debug/verify-current-log-session-sse.mjs" session-for-marker "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-A-RUN-RESUME-STDOUT")"
if [[ "$LOG_SESSION_A_AFTER_RUN_RESTART" != "$LOG_SESSION_A" || "$(current_log_session_id)" != "$LOG_SESSION_A" ]]; then
dump_log_session_diagnostics "Run restart changed the supervised process session"
exit 1
fi
printf 'stopping generation A and starting generation B on the same SSE connection\n'
dispatch_log_session_lifecycle stop "$WORK_DIR/log-session-stop-a.response.json" "$WORK_DIR/log-session-stop-a-job.response.json"
printf 'B\n' >"$LOG_SESSION_SCOPE/smoke-generation.next"
mv "$LOG_SESSION_SCOPE/smoke-generation.next" "$LOG_SESSION_SCOPE/smoke-generation"
rm -f "$LOG_SESSION_SCOPE/smoke-command" "$LOG_SESSION_SCOPE/smoke-last-command" "$LOG_SESSION_SCOPE/smoke-process.pid"
dispatch_log_session_lifecycle start "$WORK_DIR/log-session-start-b.response.json" "$WORK_DIR/log-session-start-b-job.response.json"
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-B-START-STDOUT" "$WORK_DIR/log-session-b-stdout-history.response.json"
wait_for_persisted_log_marker "$LOG_SESSION_MARKER_PREFIX-B-START-STDERR" "$WORK_DIR/log-session-b-stderr-history.response.json"
wait_for_file_literal "generation B stdout" "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-B-START-STDOUT" 60
wait_for_file_literal "generation B stderr" "$LOG_SESSION_SSE_FILE" "$LOG_SESSION_MARKER_PREFIX-B-START-STDERR" 60
LOG_SESSION_B="$(current_log_session_id)"
if [[ -z "$LOG_SESSION_B" || "$LOG_SESSION_B" == "$LOG_SESSION_A" ]]; then
dump_log_session_diagnostics "generation B did not create a new current session"
exit 1
fi
printf '%s\n' "$LOG_SESSION_B" >"$WORK_DIR/current-log-session-b.id"
node "$ROOT_DIR/scripts/local-debug/verify-current-log-session-sse.mjs" verify \
"$LOG_SESSION_SSE_FILE" \
"$LOG_SESSION_SERVER_ID" \
"$LOG_SESSION_MARKER_PREFIX" \
"$LOG_SESSION_A" \
"$LOG_SESSION_LEGACY_STREAM_ID,$LOG_SESSION_JOB_STREAM_ID,$LOG_SESSION_FILE_STREAM_ID,$LOG_SESSION_REAL_JOB_STREAM_IDS" \
>"$WORK_DIR/current-log-session-verification.json"
reject_forbidden_fragments "$LOG_SESSION_SSE_FILE"
reject_forbidden_fragments "$WORK_DIR/current-log-session-verification.json"
dispatch_log_session_lifecycle stop "$WORK_DIR/log-session-stop-b.response.json" "$WORK_DIR/log-session-stop-b-job.response.json"
if kill -0 "$LOG_SESSION_SSE_PID" 2>/dev/null; then
kill "$LOG_SESSION_SSE_PID" 2>/dev/null || true
wait "$LOG_SESSION_SSE_PID" 2>/dev/null || true
fi
LOG_SESSION_SSE_PID=""
printf 'current supervised log session smoke passed; evidence: %s\n' "$WORK_DIR/current-log-session-verification.json"
if [[ "${LOCAL_DEBUG_LOG_SESSION_SMOKE_ONLY:-false}" == "true" ]]; then
printf 'focused current supervised log session smoke passed\n'
printf 'evidence directory: %s\n' "$WORK_DIR"
exit 0
fi
printf 'generating host-native example Run through platform Docker builder\n'
curl -fsS -H 'Content-Type: application/json' "${AUTH_HEADER[@]}" --data-binary "@$WORK_DIR/server-run-generate.request.json" "$API_URL/server-instances/$SERVER_ID/run/generate" >"$WORK_DIR/server-run-generate.response.json"
@@ -0,0 +1,152 @@
#!/usr/bin/env node
import fs from "node:fs";
function fail(message, details = undefined) {
console.error(message);
if (details !== undefined) console.error(JSON.stringify(details, null, 2));
process.exit(1);
}
function parseEvents(file) {
const body = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
const events = [];
for (const block of body.split("\n\n")) {
const lines = block.split("\n");
let event = "message";
let id = "";
const data = [];
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
if (line.startsWith("id:")) id = line.slice(3).trim();
if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
}
if (data.length === 0) continue;
try {
events.push({ event, id, data: JSON.parse(data.join("\n")) });
} catch {
// The final block may be incomplete while curl is still appending.
}
}
return events;
}
function markerEvent(events, marker) {
return events.find((item) => item.event === "log" && item.data?.entry?.line === marker);
}
function requireMarker(events, marker) {
const item = markerEvent(events, marker);
if (!item) fail(`missing SSE log marker: ${marker}`);
return item;
}
const [command, file, ...args] = process.argv.slice(2);
if (!command || !file) {
fail("usage: verify-current-log-session-sse.mjs <session-for-marker|verify> <sse-file> [...args]");
}
const events = parseEvents(file);
if (command === "session-for-marker") {
const [marker] = args;
const item = requireMarker(events, marker);
if (!item.data.logSessionId) fail(`marker has no logSessionId: ${marker}`, item);
process.stdout.write(item.data.logSessionId);
process.exit(0);
}
if (command !== "verify") fail(`unknown command: ${command}`);
const [serverID, prefix, expectedSessionA, forbiddenIDsText = ""] = args;
if (!serverID || !prefix || !expectedSessionA) fail("verify requires server id, marker prefix, and generation A session id");
const markers = {
aStdout: `${prefix}-A-START-STDOUT`,
aStderr: `${prefix}-A-START-STDERR`,
aResumeStdout: `${prefix}-A-RUN-RESUME-STDOUT`,
aResumeStderr: `${prefix}-A-RUN-RESUME-STDERR`,
bStdout: `${prefix}-B-START-STDOUT`,
bStderr: `${prefix}-B-START-STDERR`
};
const markerItems = Object.fromEntries(Object.entries(markers).map(([key, marker]) => [key, requireMarker(events, marker)]));
const eventIndex = (needle) => events.indexOf(needle);
const sessionEvents = events.filter((item) => item.event === "session" && item.data?.logSessionId);
const sessionIDs = sessionEvents.map((item) => item.data.logSessionId);
if (sessionIDs.length !== 2 || sessionIDs[0] !== expectedSessionA) {
fail("expected exactly one generation A boundary followed by one generation B boundary", { sessionIDs, expectedSessionA });
}
const sessionB = sessionIDs[1];
if (!sessionB || sessionB === expectedSessionA) fail("generation B did not receive a new session id", { sessionIDs });
for (const key of ["aStdout", "aStderr", "aResumeStdout", "aResumeStderr"]) {
if (markerItems[key].data.logSessionId !== expectedSessionA) {
fail(`generation A marker changed session at ${key}`, markerItems[key]);
}
}
for (const key of ["bStdout", "bStderr"]) {
if (markerItems[key].data.logSessionId !== sessionB) fail(`generation B marker has the wrong session at ${key}`, markerItems[key]);
}
const boundaryBIndex = eventIndex(sessionEvents[1]);
const firstBLogIndex = Math.min(eventIndex(markerItems.bStdout), eventIndex(markerItems.bStderr));
if (boundaryBIndex < 0 || boundaryBIndex >= firstBLogIndex) {
fail("generation B session boundary was not delivered before generation B output", { boundaryBIndex, firstBLogIndex });
}
if (eventIndex(markerItems.aResumeStdout) >= boundaryBIndex || eventIndex(markerItems.aResumeStderr) >= boundaryBIndex) {
fail("Run-resume output arrived after the process-generation boundary");
}
const allowedStreamKeys = new Set(["smoke.console.stdout", "smoke.console.stderr"]);
const forbiddenIDs = new Set(forbiddenIDsText.split(",").filter(Boolean));
const streamIDsBySession = new Map();
for (let index = 0; index < events.length; index += 1) {
const item = events[index];
if (item.data?.serverInstanceId && item.data.serverInstanceId !== serverID) {
fail("SSE feed included another server instance", item);
}
const streamID = item.event === "stream" ? item.data?.id : item.data?.streamId;
if (streamID && forbiddenIDs.has(streamID)) fail("SSE feed included an explicitly historical stream", item);
if (item.event !== "stream" && item.event !== "log") continue;
const sessionID = item.data?.logSessionId;
const source = item.data?.source;
const streamKey = item.data?.streamKey;
if (item.data?.entry?.line === `${prefix}-HISTORICAL-JOB-STDOUT` || item.data?.entry?.line === `${prefix}-HISTORICAL-JOB-STDERR`) {
fail("live SSE included historical job output", item);
}
if (source !== "process" || !allowedStreamKeys.has(streamKey)) {
fail("live SSE included a legacy, job, file-tail, or undeclared stream", item);
}
if (index < boundaryBIndex && sessionID !== expectedSessionA) {
fail("generation A feed mixed output from another or sessionless stream", item);
}
if (index > boundaryBIndex && sessionID !== sessionB) {
fail("post-switch SSE mixed output from another session", item);
}
if (index > boundaryBIndex && item.data?.entry?.line?.startsWith(`${prefix}-A-`)) {
fail("post-switch SSE retained generation A output", item);
}
if (streamID && sessionID) {
if (!streamIDsBySession.has(sessionID)) streamIDsBySession.set(sessionID, new Set());
streamIDsBySession.get(sessionID).add(streamID);
}
}
const streamsA = streamIDsBySession.get(expectedSessionA) ?? new Set();
const streamsB = streamIDsBySession.get(sessionB) ?? new Set();
if (streamsA.size < 2 || streamsB.size < 2) fail("stdout/stderr stream metadata was incomplete", { streamsA: [...streamsA], streamsB: [...streamsB] });
for (const streamID of streamsA) {
if (streamsB.has(streamID)) fail("process generations reused a stream id", { streamID });
}
process.stdout.write(`${JSON.stringify({
serverInstanceId: serverID,
sessionA: expectedSessionA,
sessionB,
sessionBoundaries: sessionIDs,
markerOrder: Object.fromEntries(Object.entries(markerItems).map(([key, item]) => [key, eventIndex(item)])),
generationAStreamIds: [...streamsA].sort(),
generationBStreamIds: [...streamsB].sort(),
excludedHistoricalStreamIds: [...forbiddenIDs].sort(),
eventCount: events.length
}, null, 2)}\n`);