Add run lifecycle report projection

This commit is contained in:
npc0-hue
2026-08-06 20:35:13 +08:00
parent 4e78957a60
commit 68921b3f68
13 changed files with 270 additions and 8 deletions
@@ -9,11 +9,13 @@ The corrected ownership model is: Platform builds and authenticates a server-sco
**Goals:** **Goals:**
- Stop generated Run registration from enqueueing `process.start` or `process.status` jobs as a bootstrap side effect. - Stop generated Run registration from enqueueing `process.start` or `process.status` jobs as a bootstrap side effect.
- Add a generated-package autonomous lifecycle plan containing plugin lifecycle action refs, dependency declarations, safe deployment inputs, log sources, and selected runtime profile metadata. - Add a generated-package autonomous lifecycle plan containing plugin lifecycle action refs, dependency declarations, safe deployment inputs, log sources, and selected runtime profile metadata.
- Implement the independent Run repository's generic plan loader/executor so generated packages consume `.platform/autonomous-lifecycle-plan.json` locally and report terminal lifecycle facts back to Platform.
- Add a Platform lifecycle report endpoint that authenticates the active Run session, validates the server binding, records audit evidence, and projects visible server state from Run-reported facts without creating jobs.
- Keep Platform responsible for server records, registration binding, auth, distribution builds, audit, and visible projections from Run reports. - Keep Platform responsible for server records, registration binding, auth, distribution builds, audit, and visible projections from Run reports.
- Update governance and protocol docs so future work treats Run as the lifecycle authority. - Update governance and protocol docs so future work treats Run as the lifecycle authority.
**Non-Goals:** **Non-Goals:**
- Implement the independent `run` repository's plan executor in this repo. - Re-add the independent `run` repository as a source tree owned by this repo.
- Add game-specific SCUM install/start behavior to Platform. - Add game-specific SCUM install/start behavior to Platform.
- Move distribution builds to machine-side Run endpoints or expose distribution-build authority to generated Runs. - Move distribution builds to machine-side Run endpoints or expose distribution-build authority to generated Runs.
- Remove explicit operator lifecycle command APIs in this change. - Remove explicit operator lifecycle command APIs in this change.
@@ -24,11 +26,12 @@ The corrected ownership model is: Platform builds and authenticates a server-sco
- **Use plugin declarations, not Platform logic, for game behavior.** The plan references action files, dependency probes/install plans, process log sources, DLL extension declarations, and sanitized deployment inputs already declared by the plugin. It does not include SCUM executable names, Steam app IDs, ports, or platform-side command synthesis. - **Use plugin declarations, not Platform logic, for game behavior.** The plan references action files, dependency probes/install plans, process log sources, DLL extension declarations, and sanitized deployment inputs already declared by the plugin. It does not include SCUM executable names, Steam app IDs, ports, or platform-side command synthesis.
- **Make registration binding-only for generated Run bootstrap.** `RegisterRunHello` continues to authenticate the component, upsert endpoint metadata, and issue a session token. It does not dispatch lifecycle or reconciliation work merely because a generated Run appeared. - **Make registration binding-only for generated Run bootstrap.** `RegisterRunHello` continues to authenticate the component, upsert endpoint metadata, and issue a session token. It does not dispatch lifecycle or reconciliation work merely because a generated Run appeared.
- **Keep Platform projections report-driven.** Existing terminal job/result projection can remain for explicit lifecycle commands, but generated Run startup state must converge through Run reports rather than Platform's stale stored state or registration-time probes. - **Keep Platform projections report-driven.** Existing terminal job/result projection can remain for explicit lifecycle commands, but generated Run startup state must converge through Run reports rather than Platform's stale stored state or registration-time probes.
- **Report autonomous execution as observed facts, not job completions.** Run submits terminal autonomous lifecycle results through a signed lifecycle report route. Platform verifies the report belongs to the bound server/run session, then reuses lifecycle projection logic without manufacturing a platform job lease.
- **Preserve builder security boundaries.** The platform-owned builder receives the plaintext component auth key internally and the autonomous plan as build input. Machine-side run endpoints still cannot claim `distribution.build` jobs or fetch plaintext build input. - **Preserve builder security boundaries.** The platform-owned builder receives the plaintext component auth key internally and the autonomous plan as build input. Machine-side run endpoints still cannot claim `distribution.build` jobs or fetch plaintext build input.
## Risks / Trade-offs ## Risks / Trade-offs
- **Run repository lag** -> The generated package can carry the plan before the independent Run executable consumes it. Mitigation: protocol and build input are explicit, and this repo does not re-add Run source. - **Cross-repository release skew** -> Platform can build packages with the plan before every deployed Run binary has the autonomous executor. Mitigation: generated Run logs explicit plan load/skip/failure states, tests cover the independent `run` repository, and this repo still does not own the `run/` source tree.
- **Stored Platform state may look stale until Run reports** -> Registration no longer paper-over probes with Platform jobs. Mitigation: UI/API must treat persisted lifecycle state as projection, not observed process truth. - **Stored Platform state may look stale until Run reports** -> Registration no longer paper-over probes with Platform jobs. Mitigation: UI/API must treat persisted lifecycle state as projection, not observed process truth.
- **Plan drift between build and execution** -> A package carries the plugin declarations and deployment revision available at build time. Mitigation: include plugin version, profile key, deployment revision, and target release so Run and Platform can report stale-plan evidence. - **Plan drift between build and execution** -> A package carries the plugin declarations and deployment revision available at build time. Mitigation: include plugin version, profile key, deployment revision, and target release so Run and Platform can report stale-plan evidence.
- **Operator command APIs still dispatch jobs** -> This change fixes generated Run autonomous startup first. Explicit commands remain auditable Platform requests until a later change redesigns command transport around Run-owned intent handling. - **Operator command APIs still dispatch jobs** -> This change fixes generated Run autonomous startup first. Explicit commands remain auditable Platform requests until a later change redesigns command transport around Run-owned intent handling.
@@ -38,6 +41,8 @@ The corrected ownership model is: Platform builds and authenticates a server-sco
- Stop queuing registration-time lifecycle/status jobs for generated Runs. - Stop queuing registration-time lifecycle/status jobs for generated Runs.
- Extend run distribution build input and DTOs with `autonomousLifecycle` for Run packages only. - Extend run distribution build input and DTOs with `autonomousLifecycle` for Run packages only.
- Update platform builder input materialization so the generated package has a serialized plan alongside the existing workspace seed. - Update platform builder input materialization so the generated package has a serialized plan alongside the existing workspace seed.
- Add Platform's signed lifecycle report endpoint for Run-owned bootstrap results.
- Update the independent Run repository to load, validate, execute, and report the autonomous plan using generic lifecycle capabilities.
- Update service tests to assert registration does not enqueue bootstrap/reconciliation jobs and build input includes the plan. - Update service tests to assert registration does not enqueue bootstrap/reconciliation jobs and build input includes the plan.
## Open Questions ## Open Questions
@@ -11,6 +11,10 @@ Generated Run packages SHALL carry a bounded autonomous lifecycle plan that lets
- **WHEN** the generated Run executable starts on its host - **WHEN** the generated Run executable starts on its host
- **THEN** Run can read the embedded autonomous lifecycle plan and execute plugin-declared init, dependency verification/install, install-if-needed, readiness/status, and start behavior locally - **THEN** Run can read the embedded autonomous lifecycle plan and execute plugin-declared init, dependency verification/install, install-if-needed, readiness/status, and start behavior locally
#### Scenario: Generated Run reports autonomous bootstrap result
- **WHEN** Run completes an autonomous lifecycle bootstrap action from the embedded plan
- **THEN** Run reports the terminal lifecycle result to Platform without requiring a Platform job claim, acknowledgement, lease token, or job result
### Requirement: Platform follows Run-reported lifecycle facts ### Requirement: Platform follows Run-reported lifecycle facts
Platform SHALL treat persisted server lifecycle state as a projection of Run-reported lifecycle facts, heartbeats, logs, and terminal process reports rather than as authoritative observed process truth. Platform SHALL treat persisted server lifecycle state as a projection of Run-reported lifecycle facts, heartbeats, logs, and terminal process reports rather than as authoritative observed process truth.
@@ -22,6 +26,10 @@ Platform SHALL treat persisted server lifecycle state as a projection of Run-rep
- **WHEN** Run reports that the managed process is running - **WHEN** Run reports that the managed process is running
- **THEN** Platform projects the server as running based on the Run report - **THEN** Platform projects the server as running based on the Run report
#### Scenario: Run reports through the signed lifecycle report channel
- **WHEN** a registered Run submits a terminal lifecycle report for its bound server instance
- **THEN** Platform validates the active Run session and server binding, records audit evidence, and updates the visible server projection from the reported process facts
### Requirement: Plugins declare game-specific lifecycle behavior ### Requirement: Plugins declare game-specific lifecycle behavior
Plugins SHALL declare lifecycle action refs, dependency probes, install plans, runtime profiles, log sources, and deployment templates needed by Run, and Platform SHALL NOT hardcode game-specific install, update, status, or startup behavior. Plugins SHALL declare lifecycle action refs, dependency probes, install plans, runtime profiles, log sources, and deployment templates needed by Run, and Platform SHALL NOT hardcode game-specific install, update, status, or startup behavior.
@@ -14,9 +14,16 @@
- [x] 2.1 Add autonomous lifecycle plan domain/build-input structures without exposing the plan through machine job-channel DTOs. - [x] 2.1 Add autonomous lifecycle plan domain/build-input structures without exposing the plan through machine job-channel DTOs.
- [x] 2.2 Populate the plan from plugin lifecycle declarations, runtime profile data, dependency declarations, log sources, DLL extensions, and deployment definition. - [x] 2.2 Populate the plan from plugin lifecycle declarations, runtime profile data, dependency declarations, log sources, DLL extensions, and deployment definition.
- [x] 2.3 Stop generated Run registration from enqueueing bootstrap start or status reconciliation jobs. - [x] 2.3 Stop generated Run registration from enqueueing bootstrap start or status reconciliation jobs.
- [x] 2.4 Update protocol and governance docs to make Run the lifecycle authority. - [x] 2.4 Add a signed Platform lifecycle report endpoint that projects server state from Run-owned terminal lifecycle facts without creating jobs.
- [x] 2.5 Update protocol and governance docs to make Run the lifecycle authority.
## 3. Verification ## 3. Independent Run Implementation
- [x] 3.1 Update service tests for autonomous build input and no registration-time lifecycle dispatch. - [x] 3.1 Add generic autonomous lifecycle plan protocol types and validation in the independent `run` repository.
- [x] 3.2 Run targeted Go tests plus repository structure checks. - [x] 3.2 Load `.platform/autonomous-lifecycle-plan.json` from the generated package workspace, validate package identity/target, run dependency probes/install plans, execute the bootstrap action, and report the terminal lifecycle result to Platform.
- [x] 3.3 Keep Run generic: no SCUM executable names, Steam app IDs, install paths, ports, or game-specific branches.
## 4. Verification
- [x] 4.1 Update service/API/runtime tests for autonomous build input, no registration-time lifecycle dispatch, Run-owned bootstrap execution, lifecycle reporting, and signed report routing.
- [x] 4.2 Run full Go tests for `platform/` and the independent `run` repository plus repository structure checks.
+1
View File
@@ -142,6 +142,7 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) {
assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized) assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized)
privateUpdateBodies := map[string]any{ privateUpdateBodies := map[string]any{
"/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}},
"/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, "/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
"/api/v1/run/jobs/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 1}, "/api/v1/run/jobs/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 1},
"/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, "/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1},
+22
View File
@@ -42,6 +42,28 @@ func TestRunControlAPIHelloHeartbeatWorkflow(t *testing.T) {
} }
} }
func TestRunLifecycleReportAPIProjectsServerState(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "logs.read")
helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle-report"
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-lifecycle-report-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Lifecycle Report API", State: domain.ServerInstanceStateReady}, adminSession)
recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: server.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}})
assertStatus(t, recorder, http.StatusOK)
response := decodeBody[dto.RunLifecycleReportResponse](t, recorder)
if !response.Accepted || response.ProjectedState != domain.ServerInstanceStateRunning {
t.Fatalf("expected lifecycle report projection, got %+v", response)
}
updated := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/"+server.ID, adminSession)
if updated.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected server state projected running, got %+v", updated)
}
}
func TestRunControlAPIReRegistrationRotatesToken(t *testing.T) { func TestRunControlAPIReRegistrationRotatesToken(t *testing.T) {
router := newTestRouter() router := newTestRouter()
first := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest())) first := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
+30
View File
@@ -120,6 +120,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}", h.serverInstanceDetail) mux.HandleFunc("/api/v1/server-instances/{id}", h.serverInstanceDetail)
mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello) mux.HandleFunc("/api/v1/run/control/hello", h.runControlHello)
mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat)) mux.HandleFunc("/api/v1/run/control/heartbeat", h.requireRunSignature(h.runControlHeartbeat))
mux.HandleFunc("/api/v1/run/lifecycle/report", h.requireRunSignature(h.runLifecycleReport))
mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim)) mux.HandleFunc("/api/v1/run/jobs/claim", h.requireRunSignature(h.runJobClaim))
mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck)) mux.HandleFunc("/api/v1/run/jobs/ack", h.requireRunSignature(h.runJobAck))
mux.HandleFunc("/api/v1/run/jobs/progress", h.requireRunSignature(h.runJobProgress)) mux.HandleFunc("/api/v1/run/jobs/progress", h.requireRunSignature(h.runJobProgress))
@@ -1697,6 +1698,35 @@ func (h *coreHandlers) runControlHeartbeat(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result)) writeJSON(w, http.StatusOK, dto.RunControlHeartbeatFromDomain(result))
} }
// runLifecycleReport godoc
// @Summary Report autonomous run lifecycle result
// @Description Lets a registered run endpoint report an observed lifecycle terminal result without a platform-assigned job lease.
// @Tags run
// @Accept json
// @Produce json
// @Param body body dto.RunLifecycleReportRequest true "Run lifecycle report request"
// @Success 200 {object} dto.RunLifecycleReportResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/run/lifecycle/report [post]
func (h *coreHandlers) runLifecycleReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunLifecycleReportRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.ReportRunLifecycle(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunLifecycleReportFromDomain(result))
}
// runJobClaim godoc // runJobClaim godoc
// @Summary Claim one run job // @Summary Claim one run job
// @Description Lets a registered run endpoint claim one queued job assigned to it using the active session token. // @Description Lets a registered run endpoint claim one queued job assigned to it using the active session token.
+30
View File
@@ -53,6 +53,26 @@ type RunControlHeartbeatResult struct {
ServerTime time.Time ServerTime time.Time
} }
type RunLifecycleReport struct {
RunEndpointID string
SessionToken string
ServerInstanceID string
Capability string
State JobState
Progress RunJobProgressReport
Message string
ErrorCode string
ExecutionResult JobExecutionResult
}
type RunLifecycleReportResult struct {
Accepted bool
RunEndpointID string
ServerInstanceID string
ProjectedState ServerInstanceState
ServerTime time.Time
}
type RunControlSession struct { type RunControlSession struct {
RunEndpointID string RunEndpointID string
SessionToken string `json:"-"` SessionToken string `json:"-"`
@@ -103,6 +123,16 @@ func CopyRunControlHeartbeatResult(result RunControlHeartbeatResult) RunControlH
return result return result
} }
func CopyRunLifecycleReport(report RunLifecycleReport) RunLifecycleReport {
report.ExecutionResult.ServerDeploymentEvidence = CopyServerDeploymentEvidence(report.ExecutionResult.ServerDeploymentEvidence)
report.ExecutionResult.DeploymentReceipt = CopyServerDeploymentExecutionReceipt(report.ExecutionResult.DeploymentReceipt)
return report
}
func CopyRunLifecycleReportResult(result RunLifecycleReportResult) RunLifecycleReportResult {
return result
}
func CopyRunControlSession(session RunControlSession) RunControlSession { func CopyRunControlSession(session RunControlSession) RunControlSession {
session.UsedNonces = CopyStringSlice(session.UsedNonces) session.UsedNonces = CopyStringSlice(session.UsedNonces)
return session return session
+39
View File
@@ -57,6 +57,26 @@ type RunControlHeartbeatResponse struct {
ServerTime time.Time `json:"serverTime"` ServerTime time.Time `json:"serverTime"`
} }
type RunLifecycleReportRequest struct {
RunEndpointID string `json:"runEndpointId"`
SessionToken string `json:"sessionToken"`
ServerInstanceID string `json:"serverInstanceId"`
Capability string `json:"capability"`
State domain.JobState `json:"state"`
Progress JobProgressBody `json:"progress"`
Message string `json:"message,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ExecutionResult RunJobExecutionResultBody `json:"executionResult,omitempty"`
}
type RunLifecycleReportResponse struct {
Accepted bool `json:"accepted"`
RunEndpointID string `json:"runEndpointId"`
ServerInstanceID string `json:"serverInstanceId"`
ProjectedState domain.ServerInstanceState `json:"projectedState,omitempty"`
ServerTime time.Time `json:"serverTime"`
}
func (request RunControlHelloRequest) ToDomain() domain.RunControlHello { func (request RunControlHelloRequest) ToDomain() domain.RunControlHello {
return domain.RunControlHello{ return domain.RunControlHello{
RegistrationToken: request.RegistrationToken, RegistrationToken: request.RegistrationToken,
@@ -92,6 +112,20 @@ func (request RunControlHeartbeatRequest) ToDomain() domain.RunControlHeartbeat
} }
} }
func (request RunLifecycleReportRequest) ToDomain() domain.RunLifecycleReport {
return domain.RunLifecycleReport{
RunEndpointID: request.RunEndpointID,
SessionToken: request.SessionToken,
ServerInstanceID: request.ServerInstanceID,
Capability: request.Capability,
State: request.State,
Progress: progressReportToDomain(request.Progress),
Message: request.Message,
ErrorCode: request.ErrorCode,
ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)},
}
}
func RunControlHelloFromDomain(result domain.RunControlHelloResult) RunControlHelloResponse { func RunControlHelloFromDomain(result domain.RunControlHelloResult) RunControlHelloResponse {
result = domain.CopyRunControlHelloResult(result) result = domain.CopyRunControlHelloResult(result)
return RunControlHelloResponse{ return RunControlHelloResponse{
@@ -114,3 +148,8 @@ func RunControlHeartbeatFromDomain(result domain.RunControlHeartbeatResult) RunC
ServerTime: result.ServerTime, ServerTime: result.ServerTime,
} }
} }
func RunLifecycleReportFromDomain(result domain.RunLifecycleReportResult) RunLifecycleReportResponse {
result = domain.CopyRunLifecycleReportResult(result)
return RunLifecycleReportResponse{Accepted: result.Accepted, RunEndpointID: result.RunEndpointID, ServerInstanceID: result.ServerInstanceID, ProjectedState: result.ProjectedState, ServerTime: result.ServerTime}
}
+2
View File
@@ -63,6 +63,8 @@ Platform-owned Run distribution builds embed an autonomous lifecycle plan for th
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. 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.
Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the active Run session and signed envelope when required. The route accepts only bounded terminal lifecycle facts for `process.install`, `process.start`, `process.stop`, or `process.status`; it validates the server/run binding, records audit evidence, and projects server state from Run-reported process facts without creating or completing a Platform job.
## Log Ingest ## Log Ingest
Implemented HTTP JSON routes: Implemented HTTP JSON routes:
+32
View File
@@ -432,6 +432,38 @@ func TestCoreServiceGeneratedSCUMRunRegistrationDoesNotQueueGuidedStart(t *testi
} }
} }
func TestCoreServiceRunLifecycleReportProjectsGeneratedRunFacts(t *testing.T) {
svc := newTestCoreService()
plugin := createGeneratedRunStatusPlugin(t, svc)
instance := domain.ServerInstance{ID: "managed-autonomous-start", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: dedicatedRunEndpointID("managed-autonomous-start"), Name: "Managed Autonomous Start", State: domain.ServerInstanceStateDraft, ConfigVersion: 1, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ProfileKey: "run-local", ServerRoot: `D:\scum-autonomous`, Revision: 1}}
if err := svc.store.ServerInstances().Create(instance); err != nil {
t.Fatalf("create autonomous server: %v", err)
}
registered := registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID)
reported, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}})
if err != nil || !reported.Accepted || reported.ProjectedState != domain.ServerInstanceStateRunning {
t.Fatalf("expected accepted lifecycle report projected running, result=%+v err=%v", reported, err)
}
stored, err := svc.GetServerInstance(instance.ID)
if err != nil || stored.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected Run report to project server running, server=%+v err=%v", stored, err)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil || len(jobs) != 0 {
t.Fatalf("autonomous lifecycle report must not create platform jobs, jobs=%+v err=%v", jobs, err)
}
other := domain.ServerInstance{ID: "managed-autonomous-other", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: dedicatedRunEndpointID("managed-autonomous-other"), Name: "Managed Autonomous Other", State: domain.ServerInstanceStateDraft, ConfigVersion: 1}
if err := svc.store.ServerInstances().Create(other); err != nil {
t.Fatalf("create other server: %v", err)
}
_, err = svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: other.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}})
if err == nil || !strings.Contains(err.Error(), "runEndpointId must match server instance") {
t.Fatalf("expected report for another server binding to be rejected, err=%v", err)
}
}
func TestCoreServiceGeneratedRunRegistrationDoesNotDispatchStatusReconciliation(t *testing.T) { func TestCoreServiceGeneratedRunRegistrationDoesNotDispatchStatusReconciliation(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin := createGeneratedRunStatusPlugin(t, svc) plugin := createGeneratedRunStatusPlugin(t, svc)
+1
View File
@@ -139,6 +139,7 @@ type Core interface {
AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error) AckRunJob(domain.RunJobAck) (domain.RunJobAckResult, error)
UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error) UpdateRunJobProgress(domain.RunJobProgress) (domain.RunJobProgressResult, error)
CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error) CompleteRunJob(domain.RunJobResult) (domain.RunJobResultResult, error)
ReportRunLifecycle(domain.RunLifecycleReport) (domain.RunLifecycleReportResult, error)
GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error) GetDistributionBuildInput(domain.DistributionBuildInputRequest) (domain.DistributionBuildInput, error)
GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error)
DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error) DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error)
@@ -2,13 +2,65 @@ package service
import ( import (
"strings" "strings"
"time" "time"
"browser.local/platform/domain" "browser.local/platform/domain"
"browser.local/platform/validator" "browser.local/platform/validator"
) )
func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (domain.RunLifecycleReportResult, error) {
report = domain.CopyRunLifecycleReport(report)
if err := validator.ValidateRunLifecycleReport(report); err != nil {
return domain.RunLifecycleReportResult{}, err
}
if _, err := svc.validatedRunSession(report.RunEndpointID, report.SessionToken); err != nil {
return domain.RunLifecycleReportResult{}, err
}
instance, err := svc.store.ServerInstances().Get(report.ServerInstanceID)
if err != nil {
return domain.RunLifecycleReportResult{}, err
}
if instance.RunEndpointID != report.RunEndpointID {
return domain.RunLifecycleReportResult{}, validationError("runEndpointId must match server instance")
}
if instance.State == domain.ServerInstanceStateDeleted {
return domain.RunLifecycleReportResult{}, validationError("server instance must not be deleted")
}
stamp := svc.now()
nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult)
if projected {
instance.State = nextState
instance.UpdatedAt = stamp
if err := validator.ValidateServerInstance(instance); err != nil {
return domain.RunLifecycleReportResult{}, err
}
if err := svc.store.ServerInstances().Update(instance); err != nil {
return domain.RunLifecycleReportResult{}, err
}
}
auditResult := domain.AuditResultSuccess
if report.State == domain.JobStateFailed || report.State == domain.JobStateCancelled {
auditResult = domain.AuditResultFailed
}
if err := svc.recordAuditEvent("run:"+report.RunEndpointID, "lifecycle.report", "server-instance", instance.ID, auditResult, lifecycleReportSummary(report, nextState, projected)); err != nil {
return domain.RunLifecycleReportResult{}, err
}
return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil
}
func lifecycleReportSummary(report domain.RunLifecycleReport, projectedState domain.ServerInstanceState, projected bool) string {
for _, candidate := range []string{report.ExecutionResult.AuditSummary, report.Progress.Message, report.Message, report.ErrorCode} {
if strings.TrimSpace(candidate) != "" {
return candidate
}
}
if projected {
return "run reported " + report.Capability + " " + string(report.State) + "; projected server state " + string(projectedState)
}
return "run reported " + report.Capability + " " + string(report.State)
}
func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error { func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error {
if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) { if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) {
return nil return nil
+34 -1
View File
@@ -54,6 +54,30 @@ func ValidateRunJobResult(result domain.RunJobResult) error {
return finish(violations) return finish(violations)
} }
func ValidateRunLifecycleReport(report domain.RunLifecycleReport) error {
var violations []string
violations = appendRequired(violations, "runEndpointId", report.RunEndpointID)
violations = appendRequired(violations, "sessionToken", report.SessionToken)
violations = appendRequired(violations, "serverInstanceId", report.ServerInstanceID)
violations = appendRequired(violations, "capability", report.Capability)
if !validLifecycleReportCapability(report.Capability) {
violations = append(violations, "capability must be process.install, process.start, process.stop, or process.status")
}
if !validTerminalJobState(report.State) {
violations = append(violations, "state must be succeeded, failed, or cancelled")
}
violations = appendProgressViolations(violations, report.Progress)
violations = appendMessageLength(violations, "message", report.Message)
violations = appendMessageLength(violations, "errorCode", report.ErrorCode)
if len([]byte(report.ExecutionResult.Content)) > maxJobChannelMessageLength*256 {
violations = append(violations, "executionResult.content is too large")
}
if report.ExecutionResult.Checksum != "" && !validSHA256Checksum(report.ExecutionResult.Checksum) {
violations = append(violations, "executionResult.checksum must be sha256:<hex>")
}
return finish(violations)
}
func ValidateDistributionBuildInputRequest(request domain.DistributionBuildInputRequest) error { func ValidateDistributionBuildInputRequest(request domain.DistributionBuildInputRequest) error {
var violations []string var violations []string
violations = appendLeaseFields(violations, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) violations = appendLeaseFields(violations, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)
@@ -159,7 +183,7 @@ func appendProgressViolations(violations []string, progress domain.RunJobProgres
func validDeploymentProgressPhase(phase string) bool { func validDeploymentProgressPhase(phase string) bool {
switch phase { switch phase {
case "queued", "claimed", "preflight", "install", "configure", "start", "health": case "queued", "claimed", "preflight", "install", "configure", "start", "stop", "status", "health":
return true return true
default: default:
return false return false
@@ -181,3 +205,12 @@ func validTerminalJobState(state domain.JobState) bool {
return false return false
} }
} }
func validLifecycleReportCapability(capability string) bool {
switch capability {
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus:
return true
default:
return false
}
}