Remove terminal log replay
This commit is contained in:
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,14 +13,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLogEventHistoryLimit = 0
|
||||
maxLogEventHistoryLimit = 10000
|
||||
logEventHeartbeatInterval = 15 * time.Second
|
||||
managedLogSessionIDPrefix = "log-session:"
|
||||
logEventHeartbeatInterval = 15 * time.Second
|
||||
managedLogSessionIDPrefix = "log-session:"
|
||||
)
|
||||
|
||||
// serverLogEvents streams platform-accepted live append events for the terminal drawer.
|
||||
// Callers can opt into a bounded current-session replay with historyLimit.
|
||||
func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
@@ -47,12 +42,11 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
header.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
historyLimit := parseLogEventHistoryLimit(r.URL.Query().Get("historyLimit"))
|
||||
active := supervisedLogSession{}
|
||||
if liveEligible {
|
||||
active = activeSupervisedLogSession(streams)
|
||||
}
|
||||
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
|
||||
emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active)
|
||||
if err != nil {
|
||||
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
|
||||
flusher.Flush()
|
||||
@@ -83,7 +77,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
active = supervisedLogSession{}
|
||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
|
||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -102,7 +96,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
active = next
|
||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
|
||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -125,7 +119,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if !sameSupervisedLogSession(active, next) {
|
||||
active = next
|
||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit)
|
||||
emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -216,34 +210,17 @@ func (session supervisedLogSession) hasStream(streamID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInstanceID string, active supervisedLogSession, historyLimit int) (map[string]uint64, error) {
|
||||
func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInstanceID string, active supervisedLogSession) (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 historyLimit == 0 {
|
||||
emittedThrough[stream.ID] = stream.LatestSeq
|
||||
}
|
||||
emittedThrough[stream.ID] = stream.LatestSeq
|
||||
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
|
||||
}
|
||||
|
||||
@@ -314,51 +291,6 @@ func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string) ([]domain.Lo
|
||||
return current, true, nil
|
||||
}
|
||||
|
||||
func (h *coreHandlers) loadLogEventHistory(streams []domain.LogStream, limit int) ([]domain.LogStreamEvent, error) {
|
||||
history := make([]domain.LogStreamEvent, 0, limit)
|
||||
for _, stream := range streams {
|
||||
afterSeq := uint64(0)
|
||||
if stream.LatestSeq > uint64(limit) {
|
||||
afterSeq = stream.LatestSeq - uint64(limit)
|
||||
}
|
||||
cursor, err := h.core.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: stream.ID, AfterSeq: afterSeq, Limit: limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range cursor.Entries {
|
||||
history = append(history, domain.LogStreamEvent{ServerInstanceID: stream.ServerInstanceID, Stream: stream, Entry: entry, LatestSeq: cursor.LatestSeq})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(history, func(i, j int) bool {
|
||||
left, right := history[i], history[j]
|
||||
if !left.Entry.Timestamp.Equal(right.Entry.Timestamp) {
|
||||
return left.Entry.Timestamp.Before(right.Entry.Timestamp)
|
||||
}
|
||||
if left.Entry.Seq != right.Entry.Seq {
|
||||
return left.Entry.Seq < right.Entry.Seq
|
||||
}
|
||||
return left.Stream.ID < right.Stream.ID
|
||||
})
|
||||
if len(history) > limit {
|
||||
history = history[len(history)-limit:]
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
func parseLogEventHistoryLimit(value string) int {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return defaultLogEventHistoryLimit
|
||||
}
|
||||
limit, err := strconv.Atoi(value)
|
||||
if err != nil || limit < 0 {
|
||||
return defaultLogEventHistoryLimit
|
||||
}
|
||||
if limit > maxLogEventHistoryLimit {
|
||||
return maxLogEventHistoryLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func writeSSEJSON(w http.ResponseWriter, eventName string, id string, value any) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
|
||||
@@ -81,17 +81,17 @@ func TestLogIngestAPIWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEventsSSEReplaysHistory(t *testing.T) {
|
||||
func TestLogEventsSSEDoesNotReplayHistory(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
|
||||
|
||||
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
|
||||
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")
|
||||
assertStatus(t, recorder, http.StatusOK)
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") || !strings.Contains(body, "event: stream") || !strings.Contains(body, "event: log") || !strings.Contains(body, "event: ready") || !strings.Contains(body, `"seq":1`) || !strings.Contains(body, `"seq":2`) {
|
||||
t.Fatalf("expected stream, history log, and ready SSE events, headers=%v body=%s", recorder.Header(), body)
|
||||
if !strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") || !strings.Contains(body, "event: stream") || !strings.Contains(body, "event: ready") || strings.Contains(body, "event: log") || strings.Contains(body, `"seq":1`) || strings.Contains(body, `"seq":2`) {
|
||||
t.Fatalf("expected live-only SSE events without history replay, headers=%v body=%s", recorder.Header(), body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
|
||||
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=0", nil).WithContext(ctx)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -152,25 +152,6 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
|
||||
assertSSEEvent(t, reader, "log", `"seq":2`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
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)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-2", "stderr", 1, 2, 10)), http.StatusOK)
|
||||
|
||||
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
|
||||
assertStatus(t, recorder, http.StatusOK)
|
||||
body := recorder.Body.String()
|
||||
if strings.Count(body, "event: log") != 2 || !strings.Contains(body, `"streamId":"log-2"`) || strings.Contains(body, `"streamId":"log-1"`) {
|
||||
t.Fatalf("expected server-wide newest history across streams, body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
@@ -182,7 +163,7 @@ func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
|
||||
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")
|
||||
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")
|
||||
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)
|
||||
@@ -196,7 +177,7 @@ func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.
|
||||
|
||||
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"))
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events"))
|
||||
|
||||
running := stopped
|
||||
running.ManagedProcessID = "log-session:session-missing"
|
||||
@@ -204,9 +185,9 @@ func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.
|
||||
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"))
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events"))
|
||||
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"))
|
||||
assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events"))
|
||||
}
|
||||
|
||||
func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconnect(t *testing.T) {
|
||||
@@ -215,7 +196,7 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne
|
||||
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)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -234,7 +215,6 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne
|
||||
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"}}
|
||||
@@ -257,10 +237,14 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne
|
||||
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"`)
|
||||
nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21)
|
||||
nextLive.LogSessionID = "session-next"
|
||||
nextLive.SessionStartedAt = nextStartedAt
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", nextLive), http.StatusOK)
|
||||
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicates(t *testing.T) {
|
||||
func TestLogEventsSSEOrdersSessionSwitchAndAdditionalStreamWithoutDuplicates(t *testing.T) {
|
||||
core := service.NewCoreService(repo.NewMemoryStore())
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
t.Fatalf("seed platform admin: %v", err)
|
||||
@@ -274,7 +258,7 @@ func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicat
|
||||
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)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx)
|
||||
streamWriter, streamReader := newSSEPipeResponseWriter()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -293,7 +277,6 @@ func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicat
|
||||
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)
|
||||
@@ -311,7 +294,6 @@ func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicat
|
||||
}
|
||||
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"
|
||||
|
||||
@@ -154,7 +154,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
||||
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events by default. A caller can opt into bounded current-session replay with `historyLimit`; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
|
||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
|
||||
|
||||
@@ -805,8 +805,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
it("builds encoded server log event stream URLs for the terminal drawer", () => {
|
||||
const client = new PlatformApiClient("/api/v1");
|
||||
|
||||
expect(client.serverLogEventsUrl("server/scum 1", { historyLimit: 500 })).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events?historyLimit=500");
|
||||
expect(client.serverLogEventsUrl("server-1", { historyLimit: 0 })).toBe("/api/v1/server-instances/server-1/logs/events?historyLimit=0");
|
||||
expect(client.serverLogEventsUrl("server/scum 1")).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events");
|
||||
expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events");
|
||||
});
|
||||
|
||||
@@ -830,7 +829,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = new PlatformApiClient("/api/v1", () => "terminal-session");
|
||||
const events: string[] = [];
|
||||
const stream = client.openServerLogEvents("server-1", { historyLimit: 2 });
|
||||
const stream = client.openServerLogEvents("server-1");
|
||||
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}`));
|
||||
@@ -842,7 +841,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
'log:{"streamId":"log-1","entry":{"seq":3,"line":"live line"}}',
|
||||
'ready:{"serverInstanceId":"server-1"}'
|
||||
]));
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server-1/logs/events?historyLimit=2");
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server-1/logs/events");
|
||||
stream.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ import type {
|
||||
LlmConfigSuggestionResponse,
|
||||
LogStreamCursorRequest,
|
||||
LogStreamCursorResponse,
|
||||
LogStreamEventOptions,
|
||||
LogStreamListResponse,
|
||||
LoginRequest,
|
||||
MarketplacePluginFilterRequest,
|
||||
@@ -634,8 +633,8 @@ export class PlatformApiClient {
|
||||
return this.request<LogStreamListResponse>(`/log-streams${query}`);
|
||||
}
|
||||
|
||||
openServerLogEvents(id: string, options: LogStreamEventOptions = {}): PlatformEventStream {
|
||||
const url = this.serverLogEventsUrl(id, options);
|
||||
openServerLogEvents(id: string): PlatformEventStream {
|
||||
const url = this.serverLogEventsUrl(id);
|
||||
const sessionToken = this.sessionTokenProvider();
|
||||
if (!sessionToken) {
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
@@ -643,11 +642,8 @@ export class PlatformApiClient {
|
||||
return new FetchServerSentEventStream(url, sessionToken);
|
||||
}
|
||||
|
||||
serverLogEventsUrl(id: string, options: LogStreamEventOptions = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
if (options.historyLimit !== undefined) params.set("historyLimit", String(options.historyLimit));
|
||||
const query = params.toString();
|
||||
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query ? `?${query}` : ""}`;
|
||||
serverLogEventsUrl(id: string): string {
|
||||
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events`;
|
||||
}
|
||||
|
||||
async queryLogStream(request: LogStreamCursorRequest): Promise<LogStreamCursorResponse> {
|
||||
|
||||
@@ -60,7 +60,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
|
||||
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||
|
||||
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
||||
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output by default, with bounded current-session replay only when the caller explicitly sends `historyLimit`. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
|
||||
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output only. It does not replay retained log entries; raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients, and internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
|
||||
# Client Manager API projection
|
||||
|
||||
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
|
||||
|
||||
@@ -1631,10 +1631,6 @@ export interface LogStreamEventsSessionResponse {
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
export interface LogStreamEventOptions {
|
||||
historyLimit?: number;
|
||||
}
|
||||
|
||||
export interface JobCreateRequest {
|
||||
id: string;
|
||||
serverInstanceId?: string;
|
||||
|
||||
@@ -74,16 +74,16 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
|
||||
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 emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A live output"));
|
||||
await emitReady();
|
||||
|
||||
expect(container?.textContent).toContain("generation A current replay");
|
||||
expect(container?.textContent).toContain("generation A live output");
|
||||
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);
|
||||
await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A live output"));
|
||||
expect(container?.textContent).toContain("generation A live output");
|
||||
expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A live output")).toHaveLength(1);
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.listLogStreams).not.toHaveBeenCalled();
|
||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
||||
@@ -211,7 +211,7 @@ async function renderDrawer() {
|
||||
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: 0 });
|
||||
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1");
|
||||
}
|
||||
|
||||
async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") {
|
||||
|
||||
@@ -16,7 +16,6 @@ type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||
|
||||
const terminalJobResultPollMs = 1000;
|
||||
const terminalJobResultPollAttempts = 30;
|
||||
const terminalLiveReplayWindow = 0;
|
||||
const terminalHistoryWindow = 500;
|
||||
const maxTerminalLines = 10000;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
@@ -158,7 +157,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
let ready = false;
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalLiveReplayWindow });
|
||||
const events = platformApiClient.openServerLogEvents(serverId);
|
||||
events.addEventListener("session", (event) => {
|
||||
const session = parseLogSessionEvent(event);
|
||||
if (!session) return;
|
||||
|
||||
Reference in New Issue
Block a user