From 12fed856f7b4eb3136c42ef8905d1ac27dd0cf04 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 11 Aug 2026 00:36:03 +0800 Subject: [PATCH] Stream plugin file-tail logs through Run plan --- platform/protocol/run-contracts.md | 2 +- .../service/distribution_build_execution.go | 6 +++- .../distribution_build_execution_test.go | 16 +++++++-- platform/service/log_ingest.go | 2 +- platform/service/log_ingest_test.go | 36 +++++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index a1fbdb8..dc1a0aa 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -59,7 +59,7 @@ Job ack, progress, cancellation polling, reconciliation, and terminal result cal ## Generated Run autonomous lifecycle plan -Platform-owned Run distribution builds embed an autonomous lifecycle plan for the server-scoped generated Run. The plan carries the server/plugin identity, selected runtime profile, target OS/architecture/release, plugin lifecycle action refs, dependency probes/install plans, process log sources, optional DLL extension plans, runtime bindings, and redacted deployment inputs. The builder includes the same JSON as internal build input and as `.platform/autonomous-lifecycle-plan.json` in the generated workspace seed. Run reads this package-local plan on startup and performs plugin-declared init, dependency verification/install, install-if-needed, readiness/status, and start behavior locally before reporting observed state back to Platform. +Platform-owned Run distribution builds embed an autonomous lifecycle plan for the server-scoped generated Run. The plan carries the server/plugin identity, selected runtime profile, target OS/architecture/release, plugin lifecycle action refs, dependency probes/install plans, plugin-declared runtime log sources such as supervised process channels and file tails, optional DLL extension plans, runtime bindings, and redacted deployment inputs. The builder includes the same JSON as internal build input and as `.platform/autonomous-lifecycle-plan.json` in the generated workspace seed. Run reads this package-local plan on startup and performs plugin-declared init, dependency verification/install, install-if-needed, readiness/status, and start behavior locally before reporting observed state back to Platform. The plan is build input for the generated package, not a machine-side job-channel payload. Generated Run registration must not be treated as a trigger to enqueue `process.start`, `process.install`, or `process.status` work; Platform state converges from Run heartbeats, logs, lifecycle reports, supervised process facts, and terminal job/report messages. Platform and Run must not add game-specific hardcoding to interpret the plan. diff --git a/platform/service/distribution_build_execution.go b/platform/service/distribution_build_execution.go index 476e29a..0b2f8aa 100644 --- a/platform/service/distribution_build_execution.go +++ b/platform/service/distribution_build_execution.go @@ -264,7 +264,7 @@ func runAutonomousLifecyclePlan(distribution domain.RunDistribution, instance do } } for _, source := range plugin.RuntimeProfiles.LogSources { - if source.Kind == "process.stdout" || source.Kind == "process.stderr" { + if autonomousRunLogSourceKind(source.Kind) { plan.LogSources = append(plan.LogSources, autonomousLogSource(source)) } } @@ -318,6 +318,10 @@ func autonomousLogSource(source domain.RuntimeLogSource) domain.RunAutonomousLog return domain.RunAutonomousLogSource{Key: source.Key, Kind: source.Kind, TargetKey: source.TargetKey, StreamKey: source.StreamKey, CursorKind: source.CursorKind, RetentionDays: source.RetentionDays} } +func autonomousRunLogSourceKind(kind string) bool { + return kind == "process.stdout" || kind == "process.stderr" || kind == "file.tail" +} + func autonomousDLLExtension(extension domain.RuntimeDLLExtensionPlan) domain.RunAutonomousDLLExtension { return domain.RunAutonomousDLLExtension{Key: extension.Key, Version: extension.Version, ReleaseURL: extension.ReleaseURL, Checksum: extension.Checksum, SizeBytes: extension.SizeBytes, TargetKey: extension.TargetKey, ModKey: extension.ModKey, DLLRef: extension.DLLRef, SCUMExecutableChecksum: extension.SCUMExecutableChecksum, UE4SSABI: extension.UE4SSABI, RCONPort: extension.RCONPort} } diff --git a/platform/service/distribution_build_execution_test.go b/platform/service/distribution_build_execution_test.go index 0054032..997f172 100644 --- a/platform/service/distribution_build_execution_test.go +++ b/platform/service/distribution_build_execution_test.go @@ -41,7 +41,10 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { {Path: "actions/install.json", Content: `{"version":1,"action":"install","mode":"oneshot"}`, Mode: 0o600}, {Path: "bin/install-server", Content: "#!/usr/bin/env sh\n", Mode: 0o700}, } - plugin.RuntimeProfiles.LogSources = append(plugin.RuntimeProfiles.LogSources, domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14}) + plugin.RuntimeProfiles.LogSources = append(plugin.RuntimeProfiles.LogSources, + domain.RuntimeLogSource{Key: "console", Kind: "process.stdout", TargetKey: "server/process", StreamKey: "console", CursorKind: "sequence", RetentionDays: 14}, + domain.RuntimeLogSource{Key: "server-events", Kind: "file.tail", TargetKey: "logs/server", StreamKey: "scum.server", CursorKind: "fingerprint", RetentionDays: 90}, + ) if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("seed plugin lifecycle assets: %v", err) } @@ -87,7 +90,7 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { if plan == nil || plan.SchemaVersion != "1" || plan.ServerInstanceID != instance.ID || plan.PluginID != plugin.ID || plan.ProfileKey != "local" || plan.Bootstrap == nil || plan.Bootstrap.Action != domain.ServerLifecycleActionStart || plan.Bootstrap.TargetKey != "actions/start.json" { t.Fatalf("platform builder received incomplete autonomous lifecycle plan: %+v", plan) } - if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 1 || plan.LogSources[0].Kind != "process.stdout" || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" { + if len(plan.DependencyProbes) != 1 || plan.DependencyProbes[0].Key != "java-runtime" || len(plan.InstallPlans) != 1 || plan.InstallPlans[0].Key != "java-install" || len(plan.LogSources) != 3 || !hasAutonomousLogSource(plan.LogSources, "process.stdout", "console") || !hasAutonomousLogSource(plan.LogSources, "file.tail", "latest-log") || !hasAutonomousLogSource(plan.LogSources, "file.tail", "scum.server") || plan.RuntimeBindings["logs/latest"] != "runtime.logs.latest" { t.Fatalf("autonomous lifecycle plan lost plugin runtime declarations: %+v", plan) } var seededPlan domain.RunAutonomousLifecyclePlan @@ -143,6 +146,15 @@ func TestCoreServiceKeepsPlatformBuildKeyOffMachineJobChannel(t *testing.T) { completeDistributionBuild(t, svc, distribution, nil) } +func hasAutonomousLogSource(sources []domain.RunAutonomousLogSource, kind string, streamKey string) bool { + for _, source := range sources { + if source.Kind == kind && source.StreamKey == streamKey { + return true + } + } + return false +} + func TestCoreServiceBuildsWithoutRegisteredDistributionWorker(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) bootstrapEndpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) diff --git a/platform/service/log_ingest.go b/platform/service/log_ingest.go index 78fd3a9..0e49df8 100644 --- a/platform/service/log_ingest.go +++ b/platform/service/log_ingest.go @@ -130,7 +130,7 @@ func (svc *CoreService) ensureLogStreamForBatch(batch domain.LogBatchIngest, sta } func (svc *CoreService) ensureRunLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error { - if batch.Source != domain.LogStreamSourceProcess && batch.Source != domain.LogStreamSourceManagementProgram { + 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) { diff --git a/platform/service/log_ingest_test.go b/platform/service/log_ingest_test.go index 3033969..39273a5 100644 --- a/platform/service/log_ingest_test.go +++ b/platform/service/log_ingest_test.go @@ -285,6 +285,42 @@ func TestCoreServiceAcceptsAutonomousRunLogStreamWithoutPlatformJob(t *testing.T } } +func TestCoreServiceAcceptsAutonomousRunFileTailLogStreamWithoutPlatformJob(t *testing.T) { + svc, sessionToken := newRegisteredLogIngestService(t) + entry := domain.LogEntry{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "LogSCUM: Display: player joined"} + streamID := runLogStreamID("run-local", "server-1", "scum.server") + ack, err := svc.IngestLogBatch(domain.LogBatchIngest{ + RunEndpointID: "run-local", + SessionToken: sessionToken, + LogStreamID: streamID, + ServerInstanceID: "server-1", + StreamKey: "scum.server", + Source: domain.LogStreamSourceFile, + FirstSeq: entry.Seq, + LastSeq: entry.Seq, + Compression: "none", + Checksum: validator.LogLineChecksum(entry.Line), + Entries: []domain.LogEntry{entry}, + }) + if err != nil { + t.Fatalf("ingest autonomous run file-tail log batch: %v", err) + } + if !ack.Accepted || ack.LogStreamID != streamID || ack.LatestSeq != entry.Seq { + t.Fatalf("unexpected autonomous file-tail stream ack: %+v", ack) + } + stream, err := svc.GetLogStream(streamID) + if err != nil { + t.Fatalf("get autonomous file-tail stream: %v", err) + } + if stream.ServerInstanceID != "server-1" || stream.StreamKey != "scum.server" || stream.Source != domain.LogStreamSourceFile { + t.Fatalf("unexpected autonomous file-tail stream metadata: %+v", stream) + } + query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: streamID, AfterSeq: 0, Limit: 10}) + if err != nil || len(query.Entries) != 1 || query.Entries[0].Line != entry.Line { + t.Fatalf("unexpected autonomous file-tail query: %+v err=%v", query, err) + } +} + func TestCoreServiceAcceptsLegacyAutonomousJobLogStreamWithoutPlatformJob(t *testing.T) { svc, sessionToken := newRegisteredLogIngestService(t) entry := domain.LogEntry{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "legacy autonomous bootstrap output"}