package runtime import ( "context" "encoding/json" "errors" "os" "path/filepath" "runtime" "sync" "testing" "time" "browser.local/run/protocol" ) func TestWorkerRunsAutonomousBootstrapFromSeededPlan(t *testing.T) { client := newFakeWorkerClient() cfg := workerTestConfig(t) cfg.ServerInstanceID = "server-worker" cfg.PluginID = "server.scum" cfg.ComponentKind = "run" cfg.ComponentKey = "run-local" writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{ SchemaVersion: "1", ServerInstanceID: cfg.ServerInstanceID, PluginID: cfg.PluginID, PluginVersion: "1.0.0", RunEndpointID: cfg.RunEndpointID, ProfileKey: cfg.ComponentKey, TargetOS: runtime.GOOS, TargetArch: runtime.GOARCH, TargetRelease: "run-dist-test", Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"}, LogSources: []protocol.RuntimeLogSourcePlan{{Key: "console-stdout", Kind: "process.stdout", StreamKey: "game.console.stdout", CursorKind: "sequence"}}, Deployment: &protocol.RunAutonomousDeployment{SchemaVersion: "1", Mode: "guided", ProfileKey: cfg.ComponentKey, ServerRoot: "D:/game-server", CreateInputs: map[string]string{"gamePort": "7779", "maxPlayers": "128"}, Revision: 2}, }) managed := &recordingManagedSupervisor{} worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed)) if err != nil { t.Fatalf("new worker: %v", err) } if err := worker.Register(context.Background()); err != nil { t.Fatalf("register: %v", err) } if err := worker.RunAutonomousLifecycleOnce(context.Background()); err != nil { t.Fatalf("run autonomous lifecycle: %v", err) } if len(client.claimRequests) != 0 || len(client.ackRequests) != 0 || len(client.resultRequests) != 0 || len(client.lifecycleReports) != 1 { t.Fatalf("autonomous bootstrap must report without platform job assignment: claims=%d acks=%d results=%d reports=%d", len(client.claimRequests), len(client.ackRequests), len(client.resultRequests), len(client.lifecycleReports)) } if client.lifecycleReports[0].Capability != protocol.RunCapabilityProcessStart || client.lifecycleReports[0].ExecutionResult.ProcessState != "running" { t.Fatalf("expected autonomous process lifecycle report, got %+v", client.lifecycleReports[0]) } if !managed.started || managed.identity.ServerInstanceID != cfg.ServerInstanceID || managed.identity.RunEndpointID != cfg.RunEndpointID { t.Fatalf("expected managed process start from autonomous plan, managed=%+v", managed) } if managed.identity.StdoutStreamKey != "game.console.stdout" { t.Fatalf("expected declared process stdout stream, identity=%+v", managed.identity) } if managed.command.Env["SERVER_ROOT"] != "D:/game-server" || managed.command.Env["SERVER_CREATE_GAMEPORT"] != "7779" || managed.command.Env["SERVER_CREATE_MAXPLAYERS"] != "128" { t.Fatalf("expected deployment env from autonomous plan, env=%+v", managed.command.Env) } } func TestWorkerContinuesAutonomousBootstrapWhenLifecycleReportFails(t *testing.T) { client := newFakeWorkerClient() client.lifecycleReportErr = errors.New("platform request failed: status=401 path=/api/v1/run/lifecycle/report code=unauthorized") cfg := workerTestConfig(t) cfg.ServerInstanceID = "server-worker" cfg.PluginID = "game.example" cfg.ComponentKind = "run" cfg.ComponentKey = "run-local" writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{ SchemaVersion: "1", ServerInstanceID: cfg.ServerInstanceID, PluginID: cfg.PluginID, PluginVersion: "1.0.0", RunEndpointID: cfg.RunEndpointID, ProfileKey: cfg.ComponentKey, TargetOS: runtime.GOOS, TargetArch: runtime.GOARCH, TargetRelease: "run-dist-test", Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"}, }) managed := &recordingManagedSupervisor{} worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed)) if err != nil { t.Fatalf("new worker: %v", err) } if err := worker.Register(context.Background()); err != nil { t.Fatalf("register: %v", err) } if err := worker.RunAutonomousLifecycleOnce(context.Background()); err != nil { t.Fatalf("run autonomous lifecycle with report failure: %v", err) } if !managed.started || len(client.lifecycleReports) != 1 { t.Fatalf("expected bootstrap to continue after lifecycle report failure, managed=%+v reports=%d", managed, len(client.lifecycleReports)) } } func TestWorkerDefersUnmatchedRequiredDependencyToBootstrap(t *testing.T) { client := newFakeWorkerClient() cfg := workerTestConfig(t) cfg.ServerInstanceID = "server-worker" cfg.PluginID = "game.example" cfg.ComponentKind = "run" cfg.ComponentKey = "run-local" writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{ SchemaVersion: "1", ServerInstanceID: cfg.ServerInstanceID, PluginID: cfg.PluginID, PluginVersion: "1.0.0", RunEndpointID: cfg.RunEndpointID, ProfileKey: cfg.ComponentKey, TargetOS: runtime.GOOS, TargetArch: runtime.GOARCH, TargetRelease: "run-dist-test", Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"}, DependencyProbes: []protocol.DependencyProbe{{Key: "steamcmd", Kind: "command.version", TargetKey: "steamcmd", Required: true, Platforms: []string{runtime.GOOS}}}, InstallPlans: []protocol.DependencyInstallPlan{{Key: "install-game-server", Title: "Install game server", Platforms: []string{runtime.GOOS}, Steps: []protocol.DependencyInstallStep{{Type: "steamcmd-app", TargetKey: "server/install-root", PackageManager: "steamcmd", PackageName: "3792580"}}}}, }) managed := &recordingManagedSupervisor{} worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed), WithDependencyCommandRunner(missingCommandRunner{})) if err != nil { t.Fatalf("new worker: %v", err) } if err := worker.Register(context.Background()); err != nil { t.Fatalf("register: %v", err) } if err := worker.RunAutonomousLifecycleOnce(context.Background()); err != nil { t.Fatalf("run autonomous lifecycle: %v", err) } if !managed.started || len(client.lifecycleReports) != 1 { t.Fatalf("expected bootstrap to run despite unmatched dependency install plan, managed=%+v reports=%d", managed, len(client.lifecycleReports)) } } func TestLoadAutonomousLifecyclePlanRejectsMismatchedIdentity(t *testing.T) { cfg := workerTestConfig(t) cfg.ServerInstanceID = "server-worker" cfg.PluginID = "game.example" cfg.ComponentKind = "run" cfg.ComponentKey = "run-local" writeAutonomousPlanFixture(t, cfg.WorkspaceRoot, cfg.ServerInstanceID, cfg.ComponentKey, protocol.RunAutonomousLifecyclePlan{ SchemaVersion: "1", ServerInstanceID: cfg.ServerInstanceID, PluginID: "game.other", PluginVersion: "1.0.0", RunEndpointID: cfg.RunEndpointID, ProfileKey: cfg.ComponentKey, TargetOS: runtime.GOOS, TargetArch: runtime.GOARCH, TargetRelease: "run-dist-test", Bootstrap: &protocol.RunAutonomousLifecycleAction{Action: "start", Operation: "start", Capability: protocol.RunCapabilityProcessStart, TargetKey: "actions/start.json"}, }) worker, err := NewWorker(cfg, newFakeWorkerClient()) if err != nil { t.Fatalf("new worker: %v", err) } worker.state.SessionToken = "session-token" if err := worker.RunAutonomousLifecycleOnce(context.Background()); err == nil { t.Fatal("expected mismatched autonomous plan identity to fail") } } func TestGenericWorkerReportsRunningAndExitObservationsForAllEndpointServers(t *testing.T) { client := newFakeWorkerClient() cfg := workerTestConfig(t) cfg.ServerInstanceID = "" cfg.ComponentKind = "" startedAt := time.Now().UTC().Add(-time.Minute) managed := &observationManagedSupervisor{items: []ProcessIdentity{ {Scope: "scope-a", ServerInstanceID: "server-a", RunEndpointID: cfg.RunEndpointID, PID: 101, StartedAt: startedAt, CommandFingerprint: "sha256:a", State: "running", ObservationSeq: 1, UpdatedAt: startedAt}, {Scope: "scope-b", ServerInstanceID: "server-b", RunEndpointID: cfg.RunEndpointID, PID: 202, StartedAt: startedAt, CommandFingerprint: "sha256:b", State: "running", ObservationSeq: 4, UpdatedAt: startedAt}, {Scope: "scope-other", ServerInstanceID: "server-other", RunEndpointID: "run-other", PID: 303, StartedAt: startedAt, CommandFingerprint: "sha256:other", State: "running", ObservationSeq: 1, UpdatedAt: startedAt}, }} worker, err := NewWorker(cfg, client, WithManagedProcessSupervisor(managed)) if err != nil { t.Fatalf("new generic worker: %v", err) } if err := worker.Register(context.Background()); err != nil { t.Fatalf("register generic worker: %v", err) } if err := worker.reportAutonomousProcessObservations(context.Background()); err != nil { t.Fatalf("report running observations: %v", err) } if len(client.lifecycleReports) != 2 || client.lifecycleReports[0].ExecutionResult.ProcessState != "running" || client.lifecycleReports[1].ExecutionResult.ProcessState != "running" { t.Fatalf("generic worker did not report both matching running observations: %+v", client.lifecycleReports) } if err := worker.reportAutonomousProcessObservations(context.Background()); err != nil || len(client.lifecycleReports) != 2 { t.Fatalf("unchanged observations were not deduplicated: reports=%+v err=%v", client.lifecycleReports, err) } managed.setItems([]ProcessIdentity{ {Scope: "scope-a", ServerInstanceID: "server-a", RunEndpointID: cfg.RunEndpointID, PID: 101, StartedAt: startedAt, CommandFingerprint: "sha256:a", State: "exited", ExitCode: 1, ExitClassification: "unexpected-exit", ObservationSeq: 2, UpdatedAt: time.Now().UTC()}, {Scope: "scope-b", ServerInstanceID: "server-b", RunEndpointID: cfg.RunEndpointID, PID: 202, StartedAt: startedAt, CommandFingerprint: "sha256:b", State: "exited", ExitCode: 0, ExitClassification: "clean-exit", ObservationSeq: 5, UpdatedAt: time.Now().UTC()}, }) if err := worker.reportAutonomousProcessObservations(context.Background()); err != nil { t.Fatalf("report exit observations: %v", err) } if len(client.lifecycleReports) != 4 || client.lifecycleReports[2].ExecutionResult.ProcessState != "exited" || client.lifecycleReports[3].ExecutionResult.ProcessState != "exited" { t.Fatalf("generic worker did not report matching exit observations: %+v", client.lifecycleReports) } } func writeAutonomousPlanFixture(t *testing.T, root string, serverInstanceID string, profileKey string, plan protocol.RunAutonomousLifecyclePlan) { t.Helper() scope, err := NewWorkspaceResolver(root).Scope(serverInstanceID, profileKey) if err != nil { t.Fatalf("resolve scope: %v", err) } if err := os.MkdirAll(filepath.Join(scope, "actions"), 0o700); err != nil { t.Fatalf("create actions: %v", err) } if err := os.MkdirAll(filepath.Join(scope, "bin"), 0o700); err != nil { t.Fatalf("create bin: %v", err) } writeJSONFixture(t, filepath.Join(scope, "actions", "start.json"), map[string]any{"version": 1, "action": "start", "mode": "supervised", "executableKey": "bin/game-server"}) if err := os.WriteFile(filepath.Join(scope, "bin", "game-server"), []byte("plugin-owned executable"), 0o700); err != nil { t.Fatalf("write executable: %v", err) } if err := os.MkdirAll(filepath.Join(scope, ".platform"), 0o700); err != nil { t.Fatalf("create platform dir: %v", err) } body, err := json.Marshal(plan) if err != nil { t.Fatalf("marshal plan: %v", err) } if err := os.WriteFile(filepath.Join(scope, autonomousLifecyclePlanKey), body, 0o600); err != nil { t.Fatalf("write plan: %v", err) } } type recordingManagedSupervisor struct { started bool command ProcessCommand identity ProcessIdentity } type observationManagedSupervisor struct { mu sync.Mutex items []ProcessIdentity } func (supervisor *observationManagedSupervisor) Start(_ context.Context, _ ProcessCommand, identity ProcessIdentity, _ ManagedProcessOutput) (ProcessIdentity, error) { return identity, nil } func (supervisor *observationManagedSupervisor) Stop(_ context.Context, identity ProcessIdentity) (ProcessIdentity, error) { return identity, nil } func (supervisor *observationManagedSupervisor) Status(identity ProcessIdentity) ProcessIdentity { return identity } func (supervisor *observationManagedSupervisor) ResumeOutput(ManagedProcessOutput) {} func (supervisor *observationManagedSupervisor) ManagedProcessObservations() []ProcessIdentity { supervisor.mu.Lock() defer supervisor.mu.Unlock() return append([]ProcessIdentity(nil), supervisor.items...) } func (supervisor *observationManagedSupervisor) setItems(items []ProcessIdentity) { supervisor.mu.Lock() defer supervisor.mu.Unlock() supervisor.items = append([]ProcessIdentity(nil), items...) } func (supervisor *recordingManagedSupervisor) Start(_ context.Context, command ProcessCommand, identity ProcessIdentity, _ ManagedProcessOutput) (ProcessIdentity, error) { supervisor.started = true supervisor.command = command identity.PID = 42 identity.State = "running" supervisor.identity = identity return identity, nil } func (supervisor *recordingManagedSupervisor) Stop(_ context.Context, identity ProcessIdentity) (ProcessIdentity, error) { identity.State = "stopped" return identity, nil } func (supervisor *recordingManagedSupervisor) Status(identity ProcessIdentity) ProcessIdentity { return identity } func (supervisor *recordingManagedSupervisor) ResumeOutput(ManagedProcessOutput) {} type missingCommandRunner struct{} func (missingCommandRunner) Run(context.Context, ProcessCommand) (ProcessResult, error) { return ProcessResult{ExitCode: 1}, os.ErrNotExist }