Make terminal log stream live-only by default

This commit is contained in:
npc0-hue
2026-08-25 09:42:24 +08:00
parent 199fa8b41d
commit 725b71b6b6
7 changed files with 72 additions and 9 deletions
+6 -2
View File
@@ -15,13 +15,14 @@ import (
) )
const ( const (
defaultLogEventHistoryLimit = 100 defaultLogEventHistoryLimit = 0
maxLogEventHistoryLimit = 10000 maxLogEventHistoryLimit = 10000
logEventHeartbeatInterval = 15 * time.Second logEventHeartbeatInterval = 15 * time.Second
managedLogSessionIDPrefix = "log-session:" managedLogSessionIDPrefix = "log-session:"
) )
// serverLogEvents streams platform-accepted server log history and live append events for the terminal drawer. // 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) { func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
@@ -221,6 +222,9 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
return nil, err return nil, err
} }
for _, stream := range active.streams { for _, stream := range active.streams {
if historyLimit == 0 {
emittedThrough[stream.ID] = stream.LatestSeq
}
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil { if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
return nil, err return nil, err
} }
+57
View File
@@ -95,6 +95,63 @@ func TestLogEventsSSEReplaysHistory(t *testing.T) {
} }
} }
func TestLogEventsSSEDefaultsToLiveOnly(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, 2)), http.StatusOK)
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")
assertStatus(t, recorder, http.StatusOK)
body := recorder.Body.String()
if !strings.Contains(body, "event: stream") || !strings.Contains(body, "event: ready") || strings.Contains(body, "event: log") {
t.Fatalf("expected live-only SSE snapshot without history logs, body=%s", body)
}
}
func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(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=0", 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, "ready", `"streamCount":1`)
if err := <-hookResult; err != nil {
t.Fatalf("ingest during stream snapshot: %v", err)
}
next := validLogBatchRequest(t, hello.SessionToken, 2, 2)
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
t.Fatalf("ingest next live batch: %v", err)
}
assertSSEEvent(t, reader, "log", `"seq":2`)
}
func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) { func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
router := newTestRouter() router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router) hello := createLogIngestAPIFixtures(t, router)
+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. - `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/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. - `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 log SSE history/live events only. The raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs; 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 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.
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. 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.
+1
View File
@@ -806,6 +806,7 @@ describe("PlatformApiClient AI providers", () => {
const client = new PlatformApiClient("/api/v1"); 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/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-1")).toBe("/api/v1/server-instances/server-1/logs/events"); expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events");
}); });
+1 -1
View File
@@ -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. - 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. 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 SSE history/live output. 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 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.
# Client Manager API projection # 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. `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.
@@ -69,7 +69,7 @@ afterEach(async () => {
}); });
describe("ServerManagementTerminalDrawer", () => { describe("ServerManagementTerminalDrawer", () => {
it("shows current-session replay and keeps it on a repeated boundary for the same session", async () => { it("shows live current-session output and keeps it on a repeated boundary for the same session", async () => {
await renderDrawer(); await renderDrawer();
await emitSession("session-a"); await emitSession("session-a");
@@ -211,7 +211,7 @@ async function renderDrawer() {
await act(async () => { await act(async () => {
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />); root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />);
}); });
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 500 }); expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 0 });
} }
async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") { async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") {
@@ -16,7 +16,8 @@ type TerminalQuickCommand = { label: string; command: string; hint: string };
const terminalJobResultPollMs = 1000; const terminalJobResultPollMs = 1000;
const terminalJobResultPollAttempts = 30; const terminalJobResultPollAttempts = 30;
const terminalInitialHistoryWindow = 500; const terminalLiveReplayWindow = 0;
const terminalHistoryWindow = 500;
const maxTerminalLines = 10000; const maxTerminalLines = 10000;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = { const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
"game.scum": [ "game.scum": [
@@ -157,7 +158,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
useEffect(() => { useEffect(() => {
if (!open) return undefined; if (!open) return undefined;
let ready = false; let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow }); const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalLiveReplayWindow });
events.addEventListener("session", (event) => { events.addEventListener("session", (event) => {
const session = parseLogSessionEvent(event); const session = parseLogSessionEvent(event);
if (!session) return; if (!session) return;
@@ -219,7 +220,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setSelectedHistoryStreamId(streamId); setSelectedHistoryStreamId(streamId);
setHistoryLines({ status: "loading" }); setHistoryLines({ status: "loading" });
try { try {
const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalInitialHistoryWindow), limit: terminalInitialHistoryWindow }); const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalHistoryWindow), limit: terminalHistoryWindow });
if (historyRequestRef.current !== requestId) return; if (historyRequestRef.current !== requestId) return;
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) }); setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
} catch (error) { } catch (error) {