Remove terminal log replay

This commit is contained in:
npc0-hue
2026-08-25 22:25:09 +08:00
parent 725b71b6b6
commit 7ebe3bb3dd
9 changed files with 40 additions and 136 deletions
+8 -76
View File
@@ -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 {
+16 -34
View File
@@ -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"
+1 -1
View File
@@ -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.