461 lines
19 KiB
Go
461 lines
19 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
func TestNewPlatformClientNormalizesBaseURL(t *testing.T) {
|
|
client, err := NewPlatformClient("http://platform.test/")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if client.BaseURL() != "http://platform.test" {
|
|
t.Fatalf("expected normalized base URL, got %q", client.BaseURL())
|
|
}
|
|
}
|
|
|
|
func TestNewPlatformClientRequiresAbsoluteURL(t *testing.T) {
|
|
if _, err := NewPlatformClient("platform.local"); err == nil {
|
|
t.Fatal("expected error for URL without scheme and host")
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientHelloPostsJSONAndDecodesResponse(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/hello" {
|
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
|
|
t.Fatalf("expected JSON content type, got %q", contentType)
|
|
}
|
|
if r.Header.Get("X-Run-Signature") != "" {
|
|
t.Fatal("hello must not be signed with a session that does not exist yet")
|
|
}
|
|
var request protocol.RunHelloRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Fatalf("decode hello request: %v", err)
|
|
}
|
|
if request.RunEndpointID != "run-local" || request.CapabilityReport.Fingerprint != "cap-v1" {
|
|
t.Fatalf("unexpected hello payload: %+v", request)
|
|
}
|
|
writeTestJSON(t, w, protocol.RunHelloResponse{
|
|
Accepted: true,
|
|
RunEndpointID: "run-local",
|
|
SessionToken: "session-token",
|
|
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
|
HeartbeatIntervalSeconds: 15,
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
response, err := client.Hello(context.Background(), validRunHelloRequest())
|
|
if err != nil {
|
|
t.Fatalf("hello: %v", err)
|
|
}
|
|
if !response.Accepted || response.SessionToken != "session-token" || response.HeartbeatIntervalSeconds != 15 {
|
|
t.Fatalf("unexpected hello response: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestPlatformRequestErrorRecognizesLegacyInvalidSession(t *testing.T) {
|
|
legacy := PlatformRequestError{Status: http.StatusBadRequest, Path: "/api/v1/run/control/heartbeat", Code: "validation_failed", Details: []string{"sessionToken is invalid"}}
|
|
if !legacy.SessionInvalid() {
|
|
t.Fatal("expected legacy invalid session response to be recognized")
|
|
}
|
|
if message := legacy.Error(); !strings.Contains(message, "status=400") || !strings.Contains(message, "path=/api/v1/run/control/heartbeat") || !strings.Contains(message, "code=validation_failed") || !strings.Contains(message, "sessionToken is invalid") {
|
|
t.Fatalf("expected request error to expose status, path, code, and details, got %q", message)
|
|
}
|
|
if (PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"another validation failure"}}).SessionInvalid() {
|
|
t.Fatal("unexpected validation response must not trigger re-registration")
|
|
}
|
|
}
|
|
|
|
func TestPlatformRequestErrorRecognizesLegacyLogSessionMetadata(t *testing.T) {
|
|
err := PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"logSessionId and sessionStartedAt must be provided together"}}
|
|
if !err.LogBatchLegacySessionMetadata() {
|
|
t.Fatal("expected legacy log session metadata response to be recognized")
|
|
}
|
|
if (PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"other validation failure"}}).LogBatchLegacySessionMetadata() {
|
|
t.Fatal("unexpected validation response must not be recognized")
|
|
}
|
|
}
|
|
|
|
func TestPlatformRequestErrorRecognizesLogSessionMetadataMismatch(t *testing.T) {
|
|
err := PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"log session metadata must match stream"}}
|
|
if !err.LogBatchSessionMetadataMismatch() {
|
|
t.Fatal("expected log session metadata mismatch response to be recognized")
|
|
}
|
|
if (PlatformRequestError{Status: http.StatusBadRequest, Code: "validation_failed", Details: []string{"other validation failure"}}).LogBatchSessionMetadataMismatch() {
|
|
t.Fatal("unexpected validation response must not be recognized")
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientSignsRunChannelRequestsWithUniqueNonce(t *testing.T) {
|
|
nonces := map[string]struct{}{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read signed body: %v", err)
|
|
}
|
|
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
|
|
nonce := r.Header.Get("X-Run-Nonce")
|
|
if _, exists := nonces[nonce]; exists {
|
|
t.Fatalf("reused Run request nonce %q", nonce)
|
|
}
|
|
nonces[nonce] = struct{}{}
|
|
writeTestJSON(t, w, protocol.RunHeartbeatResponse{Accepted: true, RunEndpointID: "run-local", NextHeartbeatSeconds: 15, ServerTime: time.Now().UTC()})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
for range 2 {
|
|
if _, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest("session-token")); err != nil {
|
|
t.Fatalf("signed heartbeat: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientSignsWithObservedServerClock(t *testing.T) {
|
|
activeSessionToken := "session-token"
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/run/control/hello":
|
|
writeTestJSON(t, w, protocol.RunHelloResponse{Accepted: true, RunEndpointID: "run-local", SessionToken: activeSessionToken, ServerTime: time.Now().UTC().Add(12 * time.Hour), HeartbeatIntervalSeconds: 15})
|
|
case "/api/v1/run/control/heartbeat":
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read heartbeat body: %v", err)
|
|
}
|
|
verifyRunRequestSignature(t, r, body, "run-local", activeSessionToken)
|
|
timestamp, err := strconv.ParseInt(r.Header.Get("X-Run-Timestamp"), 10, 64)
|
|
if err != nil {
|
|
t.Fatalf("parse signed timestamp: %v", err)
|
|
}
|
|
signedAt := time.Unix(timestamp, 0).UTC()
|
|
serverNow := time.Now().UTC().Add(12 * time.Hour)
|
|
if delta := signedAt.Sub(serverNow); delta < -5*time.Second || delta > 5*time.Second {
|
|
t.Fatalf("expected signed timestamp to follow observed server clock, signedAt=%s serverNow=%s delta=%s", signedAt.Format(time.RFC3339), serverNow.Format(time.RFC3339), delta)
|
|
}
|
|
writeTestJSON(t, w, protocol.RunHeartbeatResponse{Accepted: true, RunEndpointID: "run-local", NextHeartbeatSeconds: 15, ServerTime: serverNow})
|
|
default:
|
|
t.Fatalf("unexpected request path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
hello, err := client.Hello(context.Background(), validRunHelloRequest())
|
|
if err != nil {
|
|
t.Fatalf("hello: %v", err)
|
|
}
|
|
if _, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest(hello.SessionToken)); err != nil {
|
|
t.Fatalf("heartbeat: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientStreamsSignedControlEvents(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/events" {
|
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read stream body: %v", err)
|
|
}
|
|
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
|
|
var request protocol.RunControlStreamRequest
|
|
if err := json.Unmarshal(body, &request); err != nil {
|
|
t.Fatalf("decode stream request: %v", err)
|
|
}
|
|
if request.LastEventSeq != 7 {
|
|
t.Fatalf("unexpected stream cursor: %+v", request)
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = w.Write([]byte("event: job.changed\nid: 8\ndata: {\"runEndpointId\":\"run-local\",\"sequence\":8,\"type\":\"job.changed\",\"serverTime\":\"2026-07-03T12:00:00Z\"}\n\n"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
events := []protocol.RunControlEvent{}
|
|
err = client.StreamControlEvents(context.Background(), protocol.RunControlStreamRequest{RunEndpointID: "run-local", SessionToken: "session-token", LastEventSeq: 7}, func(event protocol.RunControlEvent) error {
|
|
events = append(events, event)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stream events: %v", err)
|
|
}
|
|
if len(events) != 1 || events[0].Type != protocol.RunControlEventTypeJobChanged || events[0].Sequence != 8 {
|
|
t.Fatalf("unexpected control events: %+v", events)
|
|
}
|
|
}
|
|
|
|
func verifyRunRequestSignature(t *testing.T, request *http.Request, body []byte, endpoint string, token string) {
|
|
t.Helper()
|
|
if request.Header.Get("X-Run-Endpoint") != endpoint {
|
|
t.Fatalf("unexpected signed endpoint %q", request.Header.Get("X-Run-Endpoint"))
|
|
}
|
|
timestamp := request.Header.Get("X-Run-Timestamp")
|
|
if _, err := strconv.ParseInt(timestamp, 10, 64); err != nil {
|
|
t.Fatalf("invalid signed timestamp %q", timestamp)
|
|
}
|
|
nonce := request.Header.Get("X-Run-Nonce")
|
|
if decoded, err := hex.DecodeString(nonce); err != nil || len(decoded) != 16 {
|
|
t.Fatalf("invalid signed nonce %q", nonce)
|
|
}
|
|
bodyHash := sha256.Sum256(body)
|
|
canonical := strings.Join([]string{request.Method, request.URL.Path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
|
|
mac := hmac.New(sha256.New, []byte(token))
|
|
_, _ = mac.Write([]byte(canonical))
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
if !hmac.Equal([]byte(expected), []byte(request.Header.Get("X-Run-Signature"))) {
|
|
t.Fatalf("invalid Run request signature")
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientHeartbeatPostsJSONAndDecodesResponse(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/heartbeat" {
|
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
var request protocol.RunHeartbeatRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Fatalf("decode heartbeat request: %v", err)
|
|
}
|
|
if request.SessionToken != "session-token" || request.Capacity.RunningJobs != 1 {
|
|
t.Fatalf("unexpected heartbeat payload: %+v", request)
|
|
}
|
|
writeTestJSON(t, w, protocol.RunHeartbeatResponse{
|
|
Accepted: true,
|
|
RunEndpointID: "run-local",
|
|
NextHeartbeatSeconds: 15,
|
|
RefreshCapabilities: true,
|
|
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
response, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest("session-token"))
|
|
if err != nil {
|
|
t.Fatalf("heartbeat: %v", err)
|
|
}
|
|
if !response.Accepted || !response.RefreshCapabilities || response.NextHeartbeatSeconds != 15 {
|
|
t.Fatalf("unexpected heartbeat response: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientGetsOneTimeSourceRCONInputOverSignedRoute(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/jobs/source-rcon-input" {
|
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read Source RCON input request: %v", err)
|
|
}
|
|
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
|
|
if strings.Contains(string(body), "command") || strings.Contains(string(body), "password") {
|
|
t.Fatalf("Source RCON input request exposed command material: %s", body)
|
|
}
|
|
var request protocol.SourceRCONExecutionInputRequest
|
|
if err := json.Unmarshal(body, &request); err != nil {
|
|
t.Fatalf("decode Source RCON input request: %v", err)
|
|
}
|
|
if request.JobID != "job-source-rcon" || request.LeaseToken != "lease-source-rcon" || request.Attempt != 1 {
|
|
t.Fatalf("unexpected Source RCON input request: %+v", request)
|
|
}
|
|
writeTestJSON(t, w, protocol.SourceRCONExecutionInputResponse{JobID: request.JobID, ServerInstanceID: "server-1", RunEndpointID: request.RunEndpointID, Command: "rcon.status"})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
response, err := client.GetSourceRCONExecutionInput(context.Background(), protocol.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-source-rcon", LeaseToken: "lease-source-rcon", Attempt: 1})
|
|
if err != nil || response.Command != "rcon.status" || response.RunEndpointID != "run-local" {
|
|
t.Fatalf("unexpected Source RCON input response=%+v err=%v", response, err)
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientGetsFencedProtectedRequestOverSignedRoute(t *testing.T) {
|
|
expiresAt := time.Now().UTC().Add(time.Minute)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/jobs/protected-request-input" {
|
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
}
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("read protected input request: %v", err)
|
|
}
|
|
verifyRunRequestSignature(t, r, body, "run-local", "session-token")
|
|
if strings.Contains(string(body), "SELECT") || strings.Contains(string(body), "dsn") || strings.Contains(string(body), "password") {
|
|
t.Fatalf("protected input request exposed execution material: %s", body)
|
|
}
|
|
var request protocol.ProtectedRequestExecutionInputRequest
|
|
if err := json.Unmarshal(body, &request); err != nil {
|
|
t.Fatalf("decode protected input request: %v", err)
|
|
}
|
|
if request.JobID != "job-protected" || request.LeaseToken != "lease-protected" || request.FencingToken != 9 || request.Attempt != 1 {
|
|
t.Fatalf("unexpected protected input request: %+v", request)
|
|
}
|
|
writeTestJSON(t, w, protocol.ProtectedRequestExecutionInputResponse{JobID: request.JobID, ServerInstanceID: "server-1", RunEndpointID: request.RunEndpointID, FencingToken: request.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: expiresAt, Kind: "sql", TransportKey: "scum-database", TargetKey: "scum-database", RequestText: "SELECT player_id FROM players"})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
response, err := client.GetProtectedRequestExecutionInput(context.Background(), protocol.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-protected", LeaseToken: "lease-protected", Attempt: 1, FencingToken: 9})
|
|
if err != nil || response.RequestText == "" || response.FencingToken != 9 || !response.Authorized || response.ApprovalState != "approved" || response.QueueState != "claimed" {
|
|
t.Fatalf("unexpected protected input response=%+v err=%v", response, err)
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientReturnsErrorForPlatformFailure(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"code":"validation_failed"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
if _, err := client.Hello(context.Background(), validRunHelloRequest()); err == nil {
|
|
t.Fatal("expected platform error")
|
|
} else {
|
|
var requestError PlatformRequestError
|
|
if !errors.As(err, &requestError) || requestError.Status != http.StatusBadRequest || !strings.Contains(err.Error(), "code=validation_failed") {
|
|
t.Fatalf("expected typed platform error with diagnostic code, got %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlatformClientHelloThenHeartbeatFlow(t *testing.T) {
|
|
var activeSessionToken string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/run/control/hello":
|
|
var request protocol.RunHelloRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Fatalf("decode hello request: %v", err)
|
|
}
|
|
if request.RegistrationToken == "" || request.RunEndpointID != "run-local" {
|
|
t.Fatalf("unexpected hello request: %+v", request)
|
|
}
|
|
activeSessionToken = "session-token"
|
|
writeTestJSON(t, w, protocol.RunHelloResponse{
|
|
Accepted: true,
|
|
RunEndpointID: request.RunEndpointID,
|
|
SessionToken: activeSessionToken,
|
|
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
|
HeartbeatIntervalSeconds: 15,
|
|
})
|
|
case "/api/v1/run/control/heartbeat":
|
|
var request protocol.RunHeartbeatRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
|
t.Fatalf("decode heartbeat request: %v", err)
|
|
}
|
|
if request.SessionToken != activeSessionToken {
|
|
t.Fatalf("heartbeat did not use active session token: %+v", request)
|
|
}
|
|
writeTestJSON(t, w, protocol.RunHeartbeatResponse{
|
|
Accepted: true,
|
|
RunEndpointID: request.RunEndpointID,
|
|
NextHeartbeatSeconds: 15,
|
|
ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC),
|
|
})
|
|
default:
|
|
t.Fatalf("unexpected request path %s", r.URL.Path)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := NewPlatformClient(server.URL)
|
|
if err != nil {
|
|
t.Fatalf("new client: %v", err)
|
|
}
|
|
hello, err := client.Hello(context.Background(), validRunHelloRequest())
|
|
if err != nil {
|
|
t.Fatalf("hello: %v", err)
|
|
}
|
|
heartbeat, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest(hello.SessionToken))
|
|
if err != nil {
|
|
t.Fatalf("heartbeat: %v", err)
|
|
}
|
|
if !hello.Accepted || !heartbeat.Accepted {
|
|
t.Fatalf("expected accepted hello and heartbeat, got %+v %+v", hello, heartbeat)
|
|
}
|
|
}
|
|
|
|
func writeTestJSON(t *testing.T, w http.ResponseWriter, value any) {
|
|
t.Helper()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(value); err != nil {
|
|
t.Fatalf("encode response: %v", err)
|
|
}
|
|
}
|
|
|
|
func validRunHelloRequest() protocol.RunHelloRequest {
|
|
return protocol.RunHelloRequest{
|
|
RegistrationToken: "registration-token",
|
|
RunEndpointID: "run-local",
|
|
DisplayName: "Local Run",
|
|
Version: "0.1.0",
|
|
Status: "online",
|
|
Platform: "darwin/arm64",
|
|
CapabilityReport: protocol.RunCapabilityReport{
|
|
Capabilities: []string{"control.hello", "control.heartbeat"},
|
|
Fingerprint: "cap-v1",
|
|
},
|
|
Capacity: protocol.RunCapacityReport{MaxJobs: 4},
|
|
}
|
|
}
|
|
|
|
func validRunHeartbeatRequest(sessionToken string) protocol.RunHeartbeatRequest {
|
|
return protocol.RunHeartbeatRequest{
|
|
RunEndpointID: "run-local",
|
|
SessionToken: sessionToken,
|
|
Version: "0.1.0",
|
|
Status: "online",
|
|
CapabilityFingerprint: "cap-v1",
|
|
Capacity: protocol.RunCapacityReport{
|
|
MaxJobs: 4,
|
|
RunningJobs: 1,
|
|
},
|
|
}
|
|
}
|