first commit
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
type ArtifactMetadata struct {
|
||||
ID string `json:"id"`
|
||||
OwnerKind string `json:"ownerKind"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
State string `json:"state"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ArtifactTransferOpenRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Direction string `json:"direction"`
|
||||
OwnerKind string `json:"ownerKind"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
ChunkSizeBytes int `json:"chunkSizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type ArtifactTransferOpenResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
Direction string `json:"direction"`
|
||||
Artifact ArtifactMetadata `json:"artifact"`
|
||||
TotalChunks int `json:"totalChunks"`
|
||||
ChunkSizeBytes int `json:"chunkSizeBytes"`
|
||||
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
|
||||
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
|
||||
Completed bool `json:"completed"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type ArtifactChunkUploadRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
ChunkIndex int `json:"chunkIndex"`
|
||||
Offset int64 `json:"offset"`
|
||||
SizeBytes int `json:"sizeBytes"`
|
||||
Checksum string `json:"checksum"`
|
||||
Payload []byte `json:"payload"`
|
||||
}
|
||||
|
||||
type ArtifactChunkUploadResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
ChunkIndex int `json:"chunkIndex"`
|
||||
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
|
||||
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type ArtifactTransferStatusRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
}
|
||||
|
||||
type ArtifactTransferStatusResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Direction string `json:"direction"`
|
||||
TotalChunks int `json:"totalChunks"`
|
||||
ChunkSizeBytes int `json:"chunkSizeBytes"`
|
||||
ReceivedChunkIndexes []int `json:"receivedChunkIndexes"`
|
||||
NextMissingChunkIndex int `json:"nextMissingChunkIndex"`
|
||||
Completed bool `json:"completed"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type ArtifactTransferCompleteRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
TransferID string `json:"transferId"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Checksum string `json:"checksum"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
type ArtifactTransferCompleteResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
TransferID string `json:"transferId"`
|
||||
Artifact ArtifactMetadata `json:"artifact"`
|
||||
Completed bool `json:"completed"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Run Artifact Contract
|
||||
|
||||
Artifacts move files and large payloads between platform and run without blocking logs or control.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/artifacts/open`: opens a run-to-platform upload transfer and returns resume state.
|
||||
- `POST /api/v1/run/artifacts/chunks`: uploads one bounded chunk with byte range and checksum metadata.
|
||||
- `POST /api/v1/run/artifacts/status`: queries received chunks and the next missing chunk index.
|
||||
- `POST /api/v1/run/artifacts/complete`: verifies all chunks and final checksum before marking the artifact available.
|
||||
|
||||
Browser-facing artifact downloads are implemented through platform-owned routes after a run upload completes:
|
||||
|
||||
- `POST /api/v1/artifacts/{id}/download`: returns safe download metadata and a platform content route.
|
||||
- `GET /api/v1/artifacts/{id}/content`: returns bounded byte ranges for authorized browser or plugin-page reads.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `ArtifactTransferOpenRequest`: run ID, session token, artifact ID, upload direction, owner scope, size, chunk size, checksum, and idempotency key.
|
||||
- `ArtifactTransferOpenResponse`: transfer ID, artifact metadata, total chunks, received chunk indexes, next missing chunk index, duplicate flag, and server time.
|
||||
- `ArtifactChunkUploadRequest`: transfer ID, artifact ID, chunk index, byte offset, size, checksum, and JSON byte payload.
|
||||
- `ArtifactChunkUploadResponse`: accepted chunk index, received chunk indexes, next missing chunk index, duplicate flag, and server time.
|
||||
- `ArtifactTransferStatusRequest`: run ID, session token, transfer ID, and artifact ID.
|
||||
- `ArtifactTransferStatusResponse`: transfer direction, total chunks, received chunk indexes, next missing chunk index, completion flag, and server time.
|
||||
- `ArtifactTransferCompleteRequest`: transfer ID, artifact ID, final checksum, and final size.
|
||||
- `ArtifactTransferCompleteResponse`: completed artifact metadata and server time.
|
||||
|
||||
## Local Queue
|
||||
|
||||
Run stores unacknowledged `ArtifactChunkUploadRequest` payloads in the local artifact queue. A queued chunk may be removed only after the platform acknowledges the same transfer ID, artifact ID, and chunk index. The queue must not store or expose raw host paths.
|
||||
|
||||
## Rules
|
||||
|
||||
- Transfers must be resumable.
|
||||
- Transfers must be checksummed.
|
||||
- Artifact concurrency must be limited.
|
||||
- Artifact transfer must not block control heartbeat, job ack/result, or log upload.
|
||||
- Artifact transfer is lower priority than control, job lifecycle metadata, and durable log ingest.
|
||||
- Slow or retrying artifact chunks must not prevent log spool acknowledgement cleanup or terminal job result submission.
|
||||
- Control, job, and log routes must reject artifact chunk payloads or transport details rather than accepting them through lightweight channel payloads.
|
||||
|
||||
## Deferred Channels
|
||||
|
||||
Platform-to-run download, browser artifact upload, external object storage, presigned URLs, and production throttling policies remain separate future work.
|
||||
@@ -0,0 +1,52 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
type RunCapacityReport struct {
|
||||
MaxJobs int `json:"maxJobs"`
|
||||
RunningJobs int `json:"runningJobs"`
|
||||
QueuedJobs int `json:"queuedJobs"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
type RunCapabilityReport struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
type RunHelloRequest struct {
|
||||
RegistrationToken string `json:"registrationToken"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
CapabilityReport RunCapabilityReport `json:"capabilityReport"`
|
||||
Capacity RunCapacityReport `json:"capacity"`
|
||||
}
|
||||
|
||||
type RunHelloResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds"`
|
||||
FeatureFlags []string `json:"featureFlags,omitempty"`
|
||||
}
|
||||
|
||||
type RunHeartbeatRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
CapabilityFingerprint string `json:"capabilityFingerprint"`
|
||||
Capacity RunCapacityReport `json:"capacity"`
|
||||
}
|
||||
|
||||
type RunHeartbeatResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
NextHeartbeatSeconds int `json:"nextHeartbeatSeconds"`
|
||||
RefreshCapabilities bool `json:"refreshCapabilities"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Run Control Contract
|
||||
|
||||
Control is the lightweight high-priority channel between run and platform.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/control/hello`: registers run metadata and receives a platform-issued session token.
|
||||
- `POST /api/v1/run/control/heartbeat`: reports status, capacity, and capability fingerprint using the active session token.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `RunHelloRequest`: registration token, run ID, display name, version, status, platform, capability summary, and capacity summary.
|
||||
- `RunHelloResponse`: session token, server time, polling hints, and feature flags.
|
||||
- `RunHeartbeatRequest`: session token, version, status, capacity, and current capability fingerprint.
|
||||
- `RunHeartbeatResponse`: accepted status, next heartbeat interval, and optional capability refresh request.
|
||||
- `RunCapabilityReport`: capability names and compact fingerprint metadata.
|
||||
- `RunCapacityReport`: max jobs, active jobs, queued jobs, and local resource summary.
|
||||
|
||||
## Rules
|
||||
|
||||
- Control payloads must be small.
|
||||
- Control must not carry logs, artifact chunks, or long job result bodies.
|
||||
- Control must have priority over job execution, log upload, and artifact transfer.
|
||||
- Heartbeat capacity summaries must remain metadata-only and must not mention or carry heavy channel payloads.
|
||||
|
||||
## Deferred Channels
|
||||
|
||||
Durable log ingest, artifact chunk transfer, and the optional game client bridge remain separate channels. The job channel is separate from control and uses `/api/v1/run/jobs/*` routes.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Game Client Bridge Contract
|
||||
|
||||
The game client bridge is optional and exists only for games that need in-game command execution or snapshots.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `ClientHello`: game client credential, server instance ID, version, and display name.
|
||||
- `ClientHeartbeat`: session token, version, status, and game connection status.
|
||||
- `ClientCommandPoll`: session token and batch limit.
|
||||
- `ClientCommandResult`: command ID, status, bounded output, and timestamp.
|
||||
- `ClientSnapshot`: snapshot mode, raw bounded text or structured data reference, and timestamp.
|
||||
|
||||
## Rules
|
||||
|
||||
- The bridge must not carry run lifecycle jobs.
|
||||
- The bridge must not carry run log ingest batches.
|
||||
- Games without in-game bridge needs should not enable this channel.
|
||||
@@ -0,0 +1,132 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RunCapabilityProcessInstall = "process.install"
|
||||
RunCapabilityProcessStart = "process.start"
|
||||
RunCapabilityProcessStop = "process.stop"
|
||||
RunCapabilityLogsRead = "logs.read"
|
||||
RunCapabilityConfigWrite = "config.write"
|
||||
RunCapabilityFilesRead = "files.read"
|
||||
RunCapabilityFilesWrite = "files.write"
|
||||
)
|
||||
|
||||
type RunJobProgressReport struct {
|
||||
Percent int `json:"percent"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobAssignment struct {
|
||||
JobID string `json:"jobId"`
|
||||
ServerInstanceID string `json:"serverInstanceId,omitempty"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
Capability string `json:"capability"`
|
||||
TargetKey string `json:"targetKey,omitempty"`
|
||||
InputRef string `json:"inputRef,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
State string `json:"state"`
|
||||
Progress RunJobProgressReport `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RunJobClaimRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Capacity RunCapacityReport `json:"capacity"`
|
||||
}
|
||||
|
||||
type RunJobClaimResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
HasJob bool `json:"hasJob"`
|
||||
Job *RunJobAssignment `json:"job,omitempty"`
|
||||
NextPollSeconds int `json:"nextPollSeconds"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobAckRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobAckResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Job RunJobAssignment `json:"job"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobProgressRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
Progress RunJobProgressReport `json:"progress"`
|
||||
Sequence uint64 `json:"sequence,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobProgressResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Job RunJobAssignment `json:"job"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobResultRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId"`
|
||||
LeaseToken string `json:"leaseToken"`
|
||||
Attempt int `json:"attempt"`
|
||||
State string `json:"state"`
|
||||
Progress RunJobProgressReport `json:"progress"`
|
||||
ResultRef string `json:"resultRef,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobResultResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
Job RunJobAssignment `json:"job"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobCancelPollRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
LeaseToken string `json:"leaseToken,omitempty"`
|
||||
}
|
||||
|
||||
type RunJobCancelPollResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
HasCancel bool `json:"hasCancel"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RequestedAt time.Time `json:"requestedAt,omitempty"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type RunJobReconcileRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ActiveJobIDs []string `json:"activeJobIds"`
|
||||
}
|
||||
|
||||
type RunJobReconcileResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
ActiveJobs []RunJobAssignment `json:"activeJobs"`
|
||||
UnknownJobIDs []string `json:"unknownJobIds"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Run Job Contract
|
||||
|
||||
Jobs execute bounded server management work.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/jobs/claim`: claims one queued job for the registered run endpoint.
|
||||
- `POST /api/v1/run/jobs/ack`: acknowledges an active leased job before execution.
|
||||
- `POST /api/v1/run/jobs/progress`: reports bounded progress for an active leased job.
|
||||
- `POST /api/v1/run/jobs/result`: submits a bounded terminal result for an active leased job.
|
||||
- `POST /api/v1/run/jobs/cancel`: polls platform cancellation requests for active leased jobs.
|
||||
- `POST /api/v1/run/jobs/reconcile`: reconciles platform-known active jobs after run restart or reconnect.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `RunJobClaimRequest`: session token, run ID, capacity, and supported capabilities.
|
||||
- `RunJobClaimResponse`: optional job assignment with identity, capability, server instance, logical target key, scoped input ref, idempotency key, lease token, attempt, and polling hint.
|
||||
- `RunJobAckRequest`: job ID, run ID, session token, lease token, attempt, and bounded message.
|
||||
- `RunJobProgressRequest`: job ID, run ID, session token, lease token, attempt, percent, sequence, and bounded message.
|
||||
- `RunJobResultRequest`: job ID, run ID, session token, lease token, attempt, terminal state, progress, bounded message, error code, and result reference.
|
||||
- `RunJobCancelPollRequest`: run ID, session token, and optional job lease identity.
|
||||
- `RunJobReconcileRequest`: run ID, session token, and active local job IDs.
|
||||
|
||||
## Local Journal
|
||||
|
||||
Run must keep a local short-term journal for accepted jobs so duplicate delivery, reconnect, and restart can be reconciled.
|
||||
|
||||
## Lifecycle Executor
|
||||
|
||||
The runtime worker executes these bounded lifecycle job capabilities:
|
||||
|
||||
- `process.install`
|
||||
- `process.start`
|
||||
- `process.stop`
|
||||
|
||||
Platform-dispatched config/file jobs are now represented in the run job payload and validated before execution by later worker implementations:
|
||||
|
||||
- `config.write`: writes approved config content addressed by a logical config key plus scoped `input://...` ref.
|
||||
- `files.read`: reads a declared logical file key and returns results through bounded metadata or artifact refs.
|
||||
- `files.write`: writes content addressed by a logical file key plus scoped `input://...` or `artifact://...` ref.
|
||||
|
||||
The executor resolves lifecycle action templates under the scoped server workspace and runs direct command/argument vectors through the process supervisor. It does not run unrestricted shell strings, execute arbitrary plugin code, expose host paths, return raw credentials, open direct sockets, or embed logs/artifacts in job result payloads.
|
||||
|
||||
## Rules
|
||||
|
||||
- Job ack must be sent before execution.
|
||||
- Terminal result must be replayable while the journal retains the job.
|
||||
- Large files must be passed as artifact references, not embedded in job payloads.
|
||||
- Config/file job payloads must use logical target keys and scoped input/artifact refs.
|
||||
- Job payloads must not include logs, artifact chunks, raw host paths, raw credentials, direct sockets, or large inline result bodies.
|
||||
- Process stdout/stderr must be redacted and written to the log spool rather than embedded in progress/result bodies.
|
||||
- Job ack/progress/result/cancel/reconcile calls are lightweight lifecycle metadata and must be able to complete while artifact/file transfer work is active or retrying.
|
||||
- Terminal results must remain idempotent under log and artifact retry pressure and must reference artifacts by safe `artifact://...` refs rather than embedding transfer payloads.
|
||||
|
||||
## Deferred Channels
|
||||
|
||||
Durable log ingest, artifact chunk transfer, and optional game client bridge traffic remain separate channels and must not be multiplexed through job result payloads. Artifact transfer carries chunk payloads only through `/api/v1/run/artifacts/*` routes.
|
||||
@@ -0,0 +1,58 @@
|
||||
package protocol
|
||||
|
||||
import "strings"
|
||||
|
||||
const maxRunLogicalFileKeyLength = 160
|
||||
|
||||
func ValidateRunJobAssignment(assignment RunJobAssignment) error {
|
||||
if assignment.JobID == "" || assignment.RunEndpointID == "" || assignment.Capability == "" {
|
||||
return ValidationError("jobId, runEndpointId, and capability are required")
|
||||
}
|
||||
switch assignment.Capability {
|
||||
case RunCapabilityConfigWrite, RunCapabilityFilesRead, RunCapabilityFilesWrite:
|
||||
if assignment.ServerInstanceID == "" {
|
||||
return ValidationError("serverInstanceId is required for scoped file jobs")
|
||||
}
|
||||
if !ValidLogicalFileKey(assignment.TargetKey) {
|
||||
return ValidationError("targetKey is not allowed")
|
||||
}
|
||||
}
|
||||
switch assignment.Capability {
|
||||
case RunCapabilityConfigWrite, RunCapabilityFilesWrite:
|
||||
if !ValidScopedInputRef(assignment.InputRef) {
|
||||
return ValidationError("inputRef is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ValidationError string
|
||||
|
||||
func (err ValidationError) Error() string { return string(err) }
|
||||
|
||||
func ValidLogicalFileKey(key string) bool {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" || trimmed != key || len([]rune(key)) > maxRunLogicalFileKeyLength {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(key)
|
||||
if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || strings.Contains(lower, "/users/") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "sk-") || strings.Contains(lower, "bearer ") {
|
||||
return false
|
||||
}
|
||||
for _, char := range key {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ValidScopedInputRef(ref string) bool {
|
||||
trimmed := strings.TrimSpace(ref)
|
||||
lower := strings.ToLower(ref)
|
||||
if trimmed == "" || trimmed != ref || strings.Contains(lower, "/users/") || strings.Contains(lower, "password=") || strings.Contains(lower, "secret=") || strings.Contains(lower, "sk-") || strings.Contains(lower, "bearer ") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(ref, "input://") || strings.HasPrefix(ref, "artifact://")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRunJobAssignmentScopedFilePayloads(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-config-write",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityConfigWrite,
|
||||
TargetKey: "server.properties",
|
||||
InputRef: "input://server-config/server-1/server.properties/v1",
|
||||
IdempotencyKey: "idem-config",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid config write assignment: %v", err)
|
||||
}
|
||||
|
||||
assignment.TargetKey = "/Users/tasia/server.properties"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "targetKey") {
|
||||
t.Fatalf("expected raw host path rejection, got %v", err)
|
||||
}
|
||||
|
||||
assignment.TargetKey = "server.properties"
|
||||
assignment.InputRef = "sk-raw-secret"
|
||||
if err := ValidateRunJobAssignment(assignment); err == nil || !strings.Contains(err.Error(), "inputRef") {
|
||||
t.Fatalf("expected raw credential ref rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRunJobAssignmentScopedReadDoesNotRequireInputRef(t *testing.T) {
|
||||
assignment := RunJobAssignment{
|
||||
JobID: "job-files-read",
|
||||
ServerInstanceID: "server-1",
|
||||
RunEndpointID: "run-local",
|
||||
Capability: RunCapabilityFilesRead,
|
||||
TargetKey: "logs/latest.log",
|
||||
IdempotencyKey: "idem-read",
|
||||
}
|
||||
if err := ValidateRunJobAssignment(assignment); err != nil {
|
||||
t.Fatalf("expected valid file read assignment: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Run Log Ingest Contract
|
||||
|
||||
Logs are durable historical data. They are not transported as best-effort UI messages.
|
||||
|
||||
## Implemented Routes
|
||||
|
||||
- `POST /api/v1/run/logs/batches`: uploads one bounded log batch and receives an acknowledgement range.
|
||||
- `POST /api/v1/log-streams/query`: queries stored log entries after a stream sequence cursor.
|
||||
|
||||
## Payloads
|
||||
|
||||
- `LogBatchIngestRequest`: run ID, session token, server instance ID, stream ID, source, sequence range, compression metadata, checksum, and bounded entries.
|
||||
- `LogEntry`: sequence, timestamp, level, line, parser metadata, and redaction state.
|
||||
- `LogBatchIngestResponse`: accepted sequence range, latest acknowledged sequence, duplicate flag, retry hint, and server time.
|
||||
- `LogStreamCursorRequest`: stream ID, sequence cursor, and limit.
|
||||
- `LogStreamCursorResponse`: ordered entries, next cursor, and latest acknowledged sequence.
|
||||
|
||||
## Local Spool
|
||||
|
||||
Run must write unacknowledged logs to a local spool/WAL before upload. Segments may be removed only after platform acknowledgement.
|
||||
|
||||
## Priority
|
||||
|
||||
Log flush has higher priority than artifact transfer. Artifact work must slow down when log spool pressure rises.
|
||||
|
||||
Log spool retry state is independent from artifact/file retry state. Acknowledged log batches may be removed even when artifact chunks are still pending, and artifact chunk acknowledgement must not alter log sequence state. Log ingest payloads carry bounded entries only and must not include artifact chunks, file bodies, host paths, raw credentials, or direct socket details.
|
||||
|
||||
## Deferred Channels
|
||||
|
||||
Browser live tail, external log storage backends, and optional game client bridge traffic remain separate future channels. Artifact transfer uses its own lower-priority channel and must not be multiplexed through log ingest.
|
||||
@@ -0,0 +1,50 @@
|
||||
package protocol
|
||||
|
||||
import "time"
|
||||
|
||||
type LogEntry struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Line string `json:"line"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Redacted bool `json:"redacted"`
|
||||
}
|
||||
|
||||
type LogBatchIngestRequest struct {
|
||||
RunEndpointID string `json:"runEndpointId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
Source string `json:"source"`
|
||||
FirstSeq uint64 `json:"firstSeq"`
|
||||
LastSeq uint64 `json:"lastSeq"`
|
||||
Compression string `json:"compression"`
|
||||
Checksum string `json:"checksum"`
|
||||
Entries []LogEntry `json:"entries"`
|
||||
}
|
||||
|
||||
type LogBatchIngestResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
AcceptedFrom uint64 `json:"acceptedFrom"`
|
||||
AcceptedTo uint64 `json:"acceptedTo"`
|
||||
LatestSeq uint64 `json:"latestSeq"`
|
||||
Duplicate bool `json:"duplicate"`
|
||||
RetryAfterSec int `json:"retryAfterSec,omitempty"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type LogStreamCursorRequest struct {
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
AfterSeq uint64 `json:"afterSeq"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type LogStreamCursorResponse struct {
|
||||
LogStreamID string `json:"logStreamId"`
|
||||
Entries []LogEntry `json:"entries"`
|
||||
NextSeq uint64 `json:"nextSeq"`
|
||||
LatestSeq uint64 `json:"latestSeq"`
|
||||
}
|
||||
Reference in New Issue
Block a user