first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
package service
import (
"errors"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
type AIProviderClient interface {
Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error)
}
type MockAIProviderClient struct{}
func (MockAIProviderClient) Invoke(provider domain.AIProvider, request domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
model := request.Model
if model == "" {
model = provider.DefaultModel
}
if model == "" && len(provider.Models) > 0 {
model = provider.Models[0]
}
recommendation := "Mock AI recommendation for " + request.Purpose + ": review the proposed change before dispatch."
result := domain.AIProviderInvocationResult{
Recommendation: recommendation,
Usage: domain.AIInvocationUsage{
ProviderID: provider.ID,
Model: model,
InputTokens: boundedTokenEstimate(request.Prompt + request.CurrentConfig),
OutputTokens: boundedTokenEstimate(recommendation),
Mocked: true,
},
}
if request.Purpose == "config.suggest" || request.Purpose == "config.generate" {
result.SuggestedConfig = buildSuggestedConfig(request.CurrentConfig, request.Prompt)
}
return result, nil
}
func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIInvocationRequest) (domain.AIInvocationResponse, error) {
request = domain.CopyAIInvocationRequest(request)
if err := validator.ValidateAIInvocationRequest(request); err != nil {
return domain.AIInvocationResponse{}, err
}
if _, err := svc.GetCurrentUser(sessionID); err != nil {
return domain.AIInvocationResponse{}, err
}
if request.ServerInstanceID != "" {
instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID)
if err != nil {
return domain.AIInvocationResponse{}, err
}
if request.PluginID != "" && instance.PluginID != request.PluginID {
return safeAIDenial(request, "plugin scope does not match server instance"), nil
}
}
if request.PluginID != "" {
plugin, err := svc.store.GamePlugins().Get(request.PluginID)
if err != nil {
return domain.AIInvocationResponse{}, err
}
authorization, err := validator.AuthorizePluginBridgeAction(plugin, domain.PluginBridgeAuthorizeRequest{
PluginID: request.PluginID,
RouteKey: request.RouteKey,
ServerInstanceID: request.ServerInstanceID,
Action: domain.PluginBridgeActionAIInvoke,
AIPurpose: request.Purpose,
})
if err != nil {
return domain.AIInvocationResponse{}, err
}
if !authorization.Allowed {
return safeAIDenial(request, authorization.Reason), nil
}
}
provider, err := svc.selectAIProvider(request.ProviderID)
if err != nil {
return domain.AIInvocationResponse{}, err
}
result, err := svc.aiProviderClient.Invoke(provider, request)
if err != nil {
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
RequestID: request.RequestID,
Purpose: request.Purpose,
ProviderID: provider.ID,
Model: safeModel(request.Model, provider),
Status: "error",
Usage: domain.AIInvocationUsage{ProviderID: provider.ID, Model: safeModel(request.Model, provider), Mocked: true},
Error: &domain.AIInvocationSafeError{Code: "provider_failed", Message: safeBridgeReason(err.Error())},
}), nil
}
response := domain.AIInvocationResponse{
RequestID: request.RequestID,
Purpose: request.Purpose,
ProviderID: provider.ID,
Model: result.Usage.Model,
Status: "ok",
Recommendation: result.Recommendation,
Usage: result.Usage,
}
if result.SuggestedConfig != "" {
response.ConfigRecommendation = &domain.AIConfigRecommendation{Key: "server.properties", SuggestedConfig: result.SuggestedConfig, DiffSummary: "review required before config write dispatch"}
}
if err := validator.ValidateAIInvocationResponse(response); err != nil {
return domain.AIInvocationResponse{}, err
}
return domain.CopyAIInvocationResponse(response), nil
}
func (svc *CoreService) selectAIProvider(id string) (domain.AIProvider, error) {
if id != "" {
provider, err := svc.store.AIProviders().Get(id)
if err != nil {
return domain.AIProvider{}, err
}
if provider.Status != domain.AIProviderStatusActive {
return domain.AIProvider{}, ErrForbidden
}
return provider, nil
}
providers, err := svc.store.AIProviders().List(domain.AIProviderFilter{Status: domain.AIProviderStatusActive})
if err != nil {
return domain.AIProvider{}, err
}
if len(providers) == 0 {
return domain.AIProvider{}, repo.ErrNotFound
}
return providers[0], nil
}
func safeAIDenial(request domain.AIInvocationRequest, reason string) domain.AIInvocationResponse {
return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{
RequestID: request.RequestID,
Purpose: request.Purpose,
Status: "denied",
Error: &domain.AIInvocationSafeError{Code: "permission_denied", Message: safeBridgeReason(reason)},
})
}
func safeModel(model string, provider domain.AIProvider) string {
if strings.TrimSpace(model) != "" {
return model
}
if provider.DefaultModel != "" {
return provider.DefaultModel
}
if len(provider.Models) > 0 {
return provider.Models[0]
}
return "mock-model"
}
func buildSuggestedConfig(currentConfig string, prompt string) string {
base := strings.TrimRight(currentConfig, "\n")
if base == "" {
base = "# generated server config"
}
if strings.Contains(strings.ToLower(prompt), "pvp") && !strings.Contains(base, "pvp=") {
base += "\npvp=false"
}
return base + "\n# ai.recommendation=review-required\n"
}
func boundedTokenEstimate(value string) int {
count := len([]rune(value)) / 4
if count < 1 {
return 1
}
if count > 4096 {
return 4096
}
return count
}
type failingAIProviderClient struct{ err error }
func (client failingAIProviderClient) Invoke(domain.AIProvider, domain.AIInvocationRequest) (domain.AIProviderInvocationResult, error) {
if client.err == nil {
return domain.AIProviderInvocationResult{}, errors.New("provider failed")
}
return domain.AIProviderInvocationResult{}, client.err
}
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"fmt"
"net/url"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
const artifactDownloadStorageBehavior = "platform-memory-transfer-session"
func (svc *CoreService) GetArtifactForSession(sessionID string, artifactID string) (domain.Artifact, error) {
artifact, err := svc.store.Artifacts().Get(strings.TrimSpace(artifactID))
if err != nil {
return domain.Artifact{}, err
}
if err := svc.authorizeArtifactAccess(sessionID, artifact); err != nil {
return domain.Artifact{}, err
}
return domain.CopyArtifact(artifact), nil
}
func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request domain.ArtifactDownloadReferenceRequest) (domain.ArtifactDownloadReference, error) {
request = domain.CopyArtifactDownloadReferenceRequest(request)
if err := validator.ValidateArtifactDownloadReferenceRequest(request); err != nil {
return domain.ArtifactDownloadReference{}, err
}
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
if err != nil {
return domain.ArtifactDownloadReference{}, err
}
if artifact.State != domain.ArtifactStateAvailable {
return domain.ArtifactDownloadReference{}, validationError("artifact must be available before download")
}
reference := domain.ArtifactDownloadReference{
ArtifactID: artifact.ID,
OwnerKind: artifact.OwnerKind,
OwnerID: artifact.OwnerID,
Filename: artifactDownloadFilename(artifact.ID),
ContentType: "application/octet-stream",
SizeBytes: artifact.SizeBytes,
Checksum: artifact.Checksum,
State: artifact.State,
DownloadURL: "/api/v1/artifacts/" + url.PathEscape(artifact.ID) + "/content",
ExpiresAt: svc.now().Add(15 * time.Minute),
RangeSupported: true,
ChunkSizeBytes: validator.MaxArtifactDownloadBytes,
StorageBehavior: artifactDownloadStorageBehavior,
}
if err := validator.ValidateArtifactDownloadReference(reference); err != nil {
return domain.ArtifactDownloadReference{}, err
}
return domain.CopyArtifactDownloadReference(reference), nil
}
func (svc *CoreService) ReadArtifactContentForSession(sessionID string, request domain.ArtifactContentRequest) (domain.ArtifactContent, error) {
request = domain.CopyArtifactContentRequest(request)
if err := validator.ValidateArtifactContentRequest(request); err != nil {
return domain.ArtifactContent{}, err
}
artifact, err := svc.GetArtifactForSession(sessionID, request.ArtifactID)
if err != nil {
return domain.ArtifactContent{}, err
}
if artifact.State != domain.ArtifactStateAvailable {
return domain.ArtifactContent{}, validationError("artifact must be available before download")
}
payload, err := svc.artifactPayload(artifact.ID)
if err != nil {
return domain.ArtifactContent{}, err
}
if int64(len(payload)) != artifact.SizeBytes {
return domain.ArtifactContent{}, validationError("artifact content size does not match metadata")
}
if checksum := validator.BytesChecksum(payload); checksum != artifact.Checksum {
return domain.ArtifactContent{}, validationError("artifact content checksum does not match metadata")
}
if request.Offset >= artifact.SizeBytes {
return domain.ArtifactContent{}, validationError("offset must be inside artifact content")
}
limit := request.Limit
if limit == 0 {
limit = validator.MaxArtifactDownloadBytes
}
remaining := artifact.SizeBytes - request.Offset
if int64(limit) > remaining {
limit = int(remaining)
}
end := int(request.Offset) + limit
part := domain.CopyBytes(payload[int(request.Offset):end])
content := domain.ArtifactContent{
ArtifactID: artifact.ID,
Filename: artifactDownloadFilename(artifact.ID),
ContentType: "application/octet-stream",
Offset: request.Offset,
SizeBytes: int64(len(part)),
TotalSizeBytes: artifact.SizeBytes,
Checksum: artifact.Checksum,
ContentChecksum: validator.BytesChecksum(part),
Partial: request.Offset != 0 || int64(len(part)) != artifact.SizeBytes,
RangeSupported: true,
Payload: part,
StorageBehavior: artifactDownloadStorageBehavior,
ServedAt: svc.now(),
}
if err := validator.ValidateArtifactContent(content); err != nil {
return domain.ArtifactContent{}, err
}
return domain.CopyArtifactContent(content), nil
}
func (svc *CoreService) authorizeArtifactAccess(sessionID string, artifact domain.Artifact) error {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return err
}
switch artifact.OwnerKind {
case domain.ArtifactOwnerKindJob:
job, err := svc.store.Jobs().Get(artifact.OwnerID)
if err != nil {
return err
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
if !canAccessServer(user, instance) {
return ErrForbidden
}
case domain.ArtifactOwnerKindServerInstance:
instance, err := svc.store.ServerInstances().Get(artifact.OwnerID)
if err != nil {
return err
}
if !canAccessServer(user, instance) {
return ErrForbidden
}
case domain.ArtifactOwnerKindPlatform, domain.ArtifactOwnerKindPlugin:
if !isPlatformAdmin(user) {
return ErrForbidden
}
default:
return validationError("artifact ownerKind is invalid")
}
return nil
}
func (svc *CoreService) artifactPayload(artifactID string) ([]byte, error) {
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
sessions := make([]domain.ArtifactTransferSession, 0, len(svc.artifactTransfers))
for _, session := range svc.artifactTransfers {
if session.ArtifactID == artifactID && session.Completed {
sessions = append(sessions, domain.CopyArtifactTransferSession(session))
}
}
if len(sessions) == 0 {
return nil, validationError("artifact content is not available from platform storage")
}
sort.Slice(sessions, func(i int, j int) bool { return sessions[i].UpdatedAt.After(sessions[j].UpdatedAt) })
session := sessions[0]
payload := make([]byte, 0, int(session.SizeBytes))
for index := 0; index < session.TotalChunks; index++ {
record, exists := session.ReceivedChunks[index]
if !exists {
return nil, validationError("artifact content has missing chunks")
}
payload = append(payload, record.Payload...)
}
if int64(len(payload)) != session.SizeBytes {
return nil, validationError("artifact content size does not match transfer")
}
return payload, nil
}
func artifactDownloadFilename(artifactID string) string {
name := strings.TrimSpace(artifactID)
if name == "" || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "://") {
return "artifact.bin"
}
return fmt.Sprintf("%s.bin", name)
}
+412
View File
@@ -0,0 +1,412 @@
package service
import (
"bytes"
"errors"
"fmt"
"sort"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) OpenArtifactTransfer(open domain.ArtifactTransferOpen) (domain.ArtifactTransferOpenResult, error) {
open = domain.CopyArtifactTransferOpen(open)
if err := validator.ValidateArtifactTransferOpen(open); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
if err := svc.validateRunSession(open.RunEndpointID, open.SessionToken); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
stamp := svc.now()
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
if session, exists := svc.findArtifactTransferByIdempotency(open.RunEndpointID, open.IdempotencyKey); exists {
if err := validateArtifactTransferOpenMatchesSession(open, session); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
artifact, err := svc.store.Artifacts().Get(session.ArtifactID)
if err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
return artifactTransferOpenResult(session, artifact, true, stamp), nil
}
if err := svc.validateArtifactTransferOwner(open); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
artifact, err := svc.store.Artifacts().Get(open.ArtifactID)
if err != nil {
if !errors.Is(err, repo.ErrNotFound) {
return domain.ArtifactTransferOpenResult{}, err
}
artifact = domain.Artifact{
ID: open.ArtifactID,
OwnerKind: open.OwnerKind,
OwnerID: open.OwnerID,
SizeBytes: open.SizeBytes,
Checksum: open.Checksum,
State: domain.ArtifactStateUploading,
CreatedAt: stamp,
UpdatedAt: stamp,
}
if err := validator.ValidateArtifact(artifact); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
if err := svc.store.Artifacts().Create(artifact); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
} else if err := validateArtifactMatchesTransferOpen(artifact, open); err != nil {
return domain.ArtifactTransferOpenResult{}, err
}
svc.artifactTransferSeq++
session := domain.ArtifactTransferSession{
TransferID: fmt.Sprintf("artifact-transfer:%s:%d:%d", open.ArtifactID, stamp.UnixNano(), svc.artifactTransferSeq),
RunEndpointID: open.RunEndpointID,
ArtifactID: open.ArtifactID,
Direction: open.Direction,
OwnerKind: open.OwnerKind,
OwnerID: open.OwnerID,
SizeBytes: open.SizeBytes,
ChunkSizeBytes: open.ChunkSizeBytes,
Checksum: open.Checksum,
IdempotencyKey: open.IdempotencyKey,
TotalChunks: artifactTotalChunks(open.SizeBytes, open.ChunkSizeBytes),
ReceivedChunks: map[int]domain.ArtifactChunkRecord{},
CreatedAt: stamp,
UpdatedAt: stamp,
}
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return artifactTransferOpenResult(session, artifact, false, stamp), nil
}
func (svc *CoreService) UploadArtifactChunk(chunk domain.ArtifactChunkUpload) (domain.ArtifactChunkUploadResult, error) {
chunk = domain.CopyArtifactChunkUpload(chunk)
if err := validator.ValidateArtifactChunkUpload(chunk); err != nil {
return domain.ArtifactChunkUploadResult{}, err
}
if err := svc.validateRunSession(chunk.RunEndpointID, chunk.SessionToken); err != nil {
return domain.ArtifactChunkUploadResult{}, err
}
stamp := svc.now()
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
session, err := svc.getArtifactTransferSession(chunk.TransferID)
if err != nil {
return domain.ArtifactChunkUploadResult{}, err
}
if err := validateArtifactChunkMatchesSession(chunk, session); err != nil {
return domain.ArtifactChunkUploadResult{}, err
}
if existing, exists := session.ReceivedChunks[chunk.ChunkIndex]; exists {
if existing.Offset == chunk.Offset && existing.SizeBytes == chunk.SizeBytes && existing.Checksum == chunk.Checksum && bytes.Equal(existing.Payload, chunk.Payload) {
return artifactChunkUploadResult(session, chunk.ChunkIndex, true, stamp), nil
}
return domain.ArtifactChunkUploadResult{}, validationError("artifact chunk conflicts with acknowledged chunk")
}
session.ReceivedChunks[chunk.ChunkIndex] = domain.ArtifactChunkRecord{
ChunkIndex: chunk.ChunkIndex,
Offset: chunk.Offset,
SizeBytes: chunk.SizeBytes,
Checksum: chunk.Checksum,
Payload: domain.CopyBytes(chunk.Payload),
ReceivedAt: stamp,
}
session.UpdatedAt = stamp
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return artifactChunkUploadResult(session, chunk.ChunkIndex, false, stamp), nil
}
func (svc *CoreService) QueryArtifactTransferStatus(query domain.ArtifactTransferStatusQuery) (domain.ArtifactTransferStatusResult, error) {
if err := validator.ValidateArtifactTransferStatusQuery(query); err != nil {
return domain.ArtifactTransferStatusResult{}, err
}
if err := svc.validateRunSession(query.RunEndpointID, query.SessionToken); err != nil {
return domain.ArtifactTransferStatusResult{}, err
}
stamp := svc.now()
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
session, err := svc.getArtifactTransferSession(query.TransferID)
if err != nil {
return domain.ArtifactTransferStatusResult{}, err
}
if err := validateArtifactTransferStatusMatchesSession(query, session); err != nil {
return domain.ArtifactTransferStatusResult{}, err
}
return artifactTransferStatusResult(session, stamp), nil
}
func (svc *CoreService) CompleteArtifactTransfer(complete domain.ArtifactTransferComplete) (domain.ArtifactTransferCompleteResult, error) {
if err := validator.ValidateArtifactTransferComplete(complete); err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
if err := svc.validateRunSession(complete.RunEndpointID, complete.SessionToken); err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
stamp := svc.now()
svc.artifactMu.Lock()
defer svc.artifactMu.Unlock()
session, err := svc.getArtifactTransferSession(complete.TransferID)
if err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
if err := validateArtifactCompleteMatchesSession(complete, session); err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
artifact, err := svc.store.Artifacts().Get(session.ArtifactID)
if err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
if session.Completed {
return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil
}
if len(session.ReceivedChunks) != session.TotalChunks {
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer has missing chunks")
}
payload := make([]byte, 0, int(session.SizeBytes))
for index := 0; index < session.TotalChunks; index++ {
record, exists := session.ReceivedChunks[index]
if !exists {
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer has missing chunks")
}
payload = append(payload, record.Payload...)
}
if int64(len(payload)) != session.SizeBytes {
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer size does not match metadata")
}
if checksum := validator.BytesChecksum(payload); checksum != session.Checksum {
return domain.ArtifactTransferCompleteResult{}, validationError("artifact transfer checksum does not match metadata")
}
artifact.SizeBytes = session.SizeBytes
artifact.Checksum = session.Checksum
artifact.State = domain.ArtifactStateAvailable
artifact.UpdatedAt = stamp
if err := validator.ValidateArtifact(artifact); err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
if err := svc.store.Artifacts().Update(artifact); err != nil {
return domain.ArtifactTransferCompleteResult{}, err
}
session.Completed = true
session.UpdatedAt = stamp
svc.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
return domain.ArtifactTransferCompleteResult{Accepted: true, TransferID: session.TransferID, Artifact: artifact, Completed: true, ServerTime: stamp}, nil
}
func (svc *CoreService) getArtifactTransferSession(transferID string) (domain.ArtifactTransferSession, error) {
session, exists := svc.artifactTransfers[transferID]
if !exists {
return domain.ArtifactTransferSession{}, repo.ErrNotFound
}
return domain.CopyArtifactTransferSession(session), nil
}
func (svc *CoreService) findArtifactTransferByIdempotency(runEndpointID string, idempotencyKey string) (domain.ArtifactTransferSession, bool) {
transferIDs := make([]string, 0, len(svc.artifactTransfers))
for transferID := range svc.artifactTransfers {
transferIDs = append(transferIDs, transferID)
}
sort.Strings(transferIDs)
for _, transferID := range transferIDs {
session := svc.artifactTransfers[transferID]
if session.RunEndpointID == runEndpointID && session.IdempotencyKey == idempotencyKey {
return domain.CopyArtifactTransferSession(session), true
}
}
return domain.ArtifactTransferSession{}, false
}
func (svc *CoreService) validateArtifactTransferOwner(open domain.ArtifactTransferOpen) error {
switch open.OwnerKind {
case domain.ArtifactOwnerKindJob:
job, err := svc.store.Jobs().Get(open.OwnerID)
if err != nil {
return err
}
if job.RunEndpointID != open.RunEndpointID {
return validationError("artifact owner job must belong to runEndpointId")
}
case domain.ArtifactOwnerKindServerInstance:
instance, err := svc.store.ServerInstances().Get(open.OwnerID)
if err != nil {
return err
}
if instance.RunEndpointID != open.RunEndpointID {
return validationError("artifact owner server instance must belong to runEndpointId")
}
if instance.State == domain.ServerInstanceStateDeleted {
return validationError("artifact owner server instance must not be deleted")
}
default:
return validationError("ownerKind must be job or server-instance for run uploads")
}
return nil
}
func validateArtifactMatchesTransferOpen(artifact domain.Artifact, open domain.ArtifactTransferOpen) error {
if artifact.OwnerKind != open.OwnerKind {
return validationError("artifact ownerKind must match transfer")
}
if artifact.OwnerID != open.OwnerID {
return validationError("artifact ownerId must match transfer")
}
if artifact.SizeBytes != open.SizeBytes {
return validationError("artifact sizeBytes must match transfer")
}
if artifact.Checksum != open.Checksum {
return validationError("artifact checksum must match transfer")
}
if artifact.State != domain.ArtifactStateUploading {
return validationError("artifact must be uploading")
}
return nil
}
func validateArtifactTransferOpenMatchesSession(open domain.ArtifactTransferOpen, session domain.ArtifactTransferSession) error {
if session.ArtifactID != open.ArtifactID || session.Direction != open.Direction || session.OwnerKind != open.OwnerKind || session.OwnerID != open.OwnerID || session.SizeBytes != open.SizeBytes || session.ChunkSizeBytes != open.ChunkSizeBytes || session.Checksum != open.Checksum {
return validationError("artifact transfer idempotency key conflicts with existing transfer")
}
return nil
}
func validateArtifactChunkMatchesSession(chunk domain.ArtifactChunkUpload, session domain.ArtifactTransferSession) error {
if session.Completed {
return validationError("artifact transfer is already complete")
}
if session.RunEndpointID != chunk.RunEndpointID {
return validationError("runEndpointId must match artifact transfer")
}
if session.ArtifactID != chunk.ArtifactID {
return validationError("artifactId must match artifact transfer")
}
if chunk.ChunkIndex >= session.TotalChunks {
return validationError("chunkIndex exceeds transfer chunk count")
}
expectedOffset := int64(chunk.ChunkIndex) * int64(session.ChunkSizeBytes)
if chunk.Offset != expectedOffset {
return validationError("offset must match chunk index")
}
expectedSize := expectedChunkSize(session, chunk.ChunkIndex)
if chunk.SizeBytes != expectedSize {
return validationError("sizeBytes must match expected chunk size")
}
return nil
}
func validateArtifactTransferStatusMatchesSession(query domain.ArtifactTransferStatusQuery, session domain.ArtifactTransferSession) error {
if session.RunEndpointID != query.RunEndpointID {
return validationError("runEndpointId must match artifact transfer")
}
if session.ArtifactID != query.ArtifactID {
return validationError("artifactId must match artifact transfer")
}
return nil
}
func validateArtifactCompleteMatchesSession(complete domain.ArtifactTransferComplete, session domain.ArtifactTransferSession) error {
if session.RunEndpointID != complete.RunEndpointID {
return validationError("runEndpointId must match artifact transfer")
}
if session.ArtifactID != complete.ArtifactID {
return validationError("artifactId must match artifact transfer")
}
if session.SizeBytes != complete.SizeBytes {
return validationError("sizeBytes must match artifact transfer")
}
if session.Checksum != complete.Checksum {
return validationError("checksum must match artifact transfer")
}
return nil
}
func artifactTransferOpenResult(session domain.ArtifactTransferSession, artifact domain.Artifact, duplicate bool, stamp time.Time) domain.ArtifactTransferOpenResult {
return domain.ArtifactTransferOpenResult{
Accepted: true,
TransferID: session.TransferID,
Direction: session.Direction,
Artifact: artifact,
TotalChunks: session.TotalChunks,
ChunkSizeBytes: session.ChunkSizeBytes,
ReceivedChunkIndexes: receivedArtifactChunkIndexes(session),
NextMissingChunkIndex: nextMissingArtifactChunkIndex(session),
Completed: session.Completed,
Duplicate: duplicate,
ServerTime: stamp,
}
}
func artifactChunkUploadResult(session domain.ArtifactTransferSession, chunkIndex int, duplicate bool, stamp time.Time) domain.ArtifactChunkUploadResult {
return domain.ArtifactChunkUploadResult{
Accepted: true,
TransferID: session.TransferID,
ArtifactID: session.ArtifactID,
ChunkIndex: chunkIndex,
ReceivedChunkIndexes: receivedArtifactChunkIndexes(session),
NextMissingChunkIndex: nextMissingArtifactChunkIndex(session),
Duplicate: duplicate,
ServerTime: stamp,
}
}
func artifactTransferStatusResult(session domain.ArtifactTransferSession, stamp time.Time) domain.ArtifactTransferStatusResult {
return domain.ArtifactTransferStatusResult{
Accepted: true,
TransferID: session.TransferID,
ArtifactID: session.ArtifactID,
Direction: session.Direction,
TotalChunks: session.TotalChunks,
ChunkSizeBytes: session.ChunkSizeBytes,
ReceivedChunkIndexes: receivedArtifactChunkIndexes(session),
NextMissingChunkIndex: nextMissingArtifactChunkIndex(session),
Completed: session.Completed,
ServerTime: stamp,
}
}
func artifactTotalChunks(sizeBytes int64, chunkSizeBytes int) int {
return int((sizeBytes + int64(chunkSizeBytes) - 1) / int64(chunkSizeBytes))
}
func expectedChunkSize(session domain.ArtifactTransferSession, chunkIndex int) int {
offset := int64(chunkIndex) * int64(session.ChunkSizeBytes)
remaining := session.SizeBytes - offset
if remaining < int64(session.ChunkSizeBytes) {
return int(remaining)
}
return session.ChunkSizeBytes
}
func receivedArtifactChunkIndexes(session domain.ArtifactTransferSession) []int {
indexes := make([]int, 0, len(session.ReceivedChunks))
for index := range session.ReceivedChunks {
indexes = append(indexes, index)
}
sort.Ints(indexes)
return indexes
}
func nextMissingArtifactChunkIndex(session domain.ArtifactTransferSession) int {
for index := 0; index < session.TotalChunks; index++ {
if _, exists := session.ReceivedChunks[index]; !exists {
return index
}
}
return session.TotalChunks
}
+205
View File
@@ -0,0 +1,205 @@
package service
import (
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
func TestCoreServiceArtifactTransferWorkflow(t *testing.T) {
svc, sessionToken := newRegisteredArtifactTransferService(t)
payload := []byte("artifact payload for upload")
openRequest := validArtifactTransferOpen(sessionToken, payload, 8)
opened, err := svc.OpenArtifactTransfer(openRequest)
if err != nil {
t.Fatalf("open artifact transfer: %v", err)
}
if !opened.Accepted || opened.TransferID == "" || opened.TotalChunks != 4 || opened.NextMissingChunkIndex != 0 {
t.Fatalf("unexpected open response: %+v", opened)
}
if opened.Artifact.State != domain.ArtifactStateUploading {
t.Fatalf("expected uploading artifact, got %+v", opened.Artifact)
}
duplicateOpen, err := svc.OpenArtifactTransfer(openRequest)
if err != nil {
t.Fatalf("duplicate open artifact transfer: %v", err)
}
if !duplicateOpen.Duplicate || duplicateOpen.TransferID != opened.TransferID {
t.Fatalf("expected duplicate open response, got %+v", duplicateOpen)
}
firstChunk := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8)
firstAck, err := svc.UploadArtifactChunk(firstChunk)
if err != nil {
t.Fatalf("upload first chunk: %v", err)
}
if !firstAck.Accepted || firstAck.NextMissingChunkIndex != 1 || len(firstAck.ReceivedChunkIndexes) != 1 || firstAck.ReceivedChunkIndexes[0] != 0 {
t.Fatalf("unexpected first chunk ack: %+v", firstAck)
}
duplicateAck, err := svc.UploadArtifactChunk(firstChunk)
if err != nil {
t.Fatalf("upload duplicate chunk: %v", err)
}
if !duplicateAck.Duplicate {
t.Fatalf("expected duplicate chunk ack, got %+v", duplicateAck)
}
status, err := svc.QueryArtifactTransferStatus(domain.ArtifactTransferStatusQuery{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1"})
if err != nil {
t.Fatalf("query artifact transfer status: %v", err)
}
if status.NextMissingChunkIndex != 1 || len(status.ReceivedChunkIndexes) != 1 {
t.Fatalf("unexpected status after first chunk: %+v", status)
}
for index := 1; index < opened.TotalChunks; index++ {
if _, err := svc.UploadArtifactChunk(validArtifactChunk(sessionToken, opened.TransferID, payload, index, 8)); err != nil {
t.Fatalf("upload chunk %d: %v", index, err)
}
}
completed, err := svc.CompleteArtifactTransfer(domain.ArtifactTransferComplete{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
if err != nil {
t.Fatalf("complete artifact transfer: %v", err)
}
if !completed.Accepted || !completed.Completed || completed.Artifact.State != domain.ArtifactStateAvailable {
t.Fatalf("unexpected complete response: %+v", completed)
}
artifact, err := svc.GetArtifact("artifact-1")
if err != nil {
t.Fatalf("get completed artifact: %v", err)
}
if artifact.State != domain.ArtifactStateAvailable || artifact.Checksum != validator.BytesChecksum(payload) {
t.Fatalf("expected available artifact, got %+v", artifact)
}
}
func TestCoreServiceRejectsInvalidArtifactTransferChunks(t *testing.T) {
svc, sessionToken := newRegisteredArtifactTransferService(t)
payload := []byte("artifact payload")
opened, err := svc.OpenArtifactTransfer(validArtifactTransferOpen(sessionToken, payload, 8))
if err != nil {
t.Fatalf("open artifact transfer: %v", err)
}
badChecksum := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8)
badChecksum.Checksum = validator.BytesChecksum([]byte("different"))
_, err = svc.UploadArtifactChunk(badChecksum)
if err == nil || !strings.Contains(err.Error(), "checksum") {
t.Fatalf("expected checksum rejection, got %v", err)
}
firstChunk := validArtifactChunk(sessionToken, opened.TransferID, payload, 0, 8)
if _, err := svc.UploadArtifactChunk(firstChunk); err != nil {
t.Fatalf("upload first chunk: %v", err)
}
conflict := firstChunk
conflict.Payload = []byte("ARTIFACT")
conflict.Checksum = validator.BytesChecksum(conflict.Payload)
_, err = svc.UploadArtifactChunk(conflict)
if err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("expected conflicting chunk rejection, got %v", err)
}
_, err = svc.CompleteArtifactTransfer(domain.ArtifactTransferComplete{RunEndpointID: "run-local", SessionToken: sessionToken, TransferID: opened.TransferID, ArtifactID: "artifact-1", Checksum: validator.BytesChecksum(payload), SizeBytes: int64(len(payload))})
if err == nil || !strings.Contains(err.Error(), "missing chunks") {
t.Fatalf("expected missing chunk completion rejection, got %v", err)
}
}
func TestCoreServiceRejectsInvalidArtifactTransferOwnerAndSession(t *testing.T) {
svc, sessionToken := newRegisteredArtifactTransferService(t)
payload := []byte("artifact payload")
invalidSession := validArtifactTransferOpen("stale-token", payload, 8)
_, err := svc.OpenArtifactTransfer(invalidSession)
if err == nil || !strings.Contains(err.Error(), "sessionToken") {
t.Fatalf("expected invalid session rejection, got %v", err)
}
otherHello := validRunControlHello()
otherHello.RunEndpointID = "run-other"
otherHello.DisplayName = "Other Run"
otherHello.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.start", "logs.read"}
otherHello.CapabilityReport.Fingerprint = "cap-other"
other, err := svc.RegisterRunHello(otherHello)
if err != nil {
t.Fatalf("register other run: %v", err)
}
invalidOwner := validArtifactTransferOpen(other.SessionToken, payload, 8)
invalidOwner.RunEndpointID = "run-other"
invalidOwner.ArtifactID = "artifact-other"
invalidOwner.IdempotencyKey = "artifact-upload-other"
_, err = svc.OpenArtifactTransfer(invalidOwner)
if err == nil || !strings.Contains(err.Error(), "owner job") {
t.Fatalf("expected owner mismatch rejection, got %v", err)
}
validOwner := validArtifactTransferOpen(sessionToken, payload, 8)
validOwner.OwnerKind = domain.ArtifactOwnerKindPlatform
_, err = svc.OpenArtifactTransfer(validOwner)
if err == nil || !strings.Contains(err.Error(), "ownerKind") {
t.Fatalf("expected owner kind rejection, got %v", err)
}
}
func newRegisteredArtifactTransferService(t *testing.T) (*CoreService, string) {
t.Helper()
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
instance, err := svc.CreateServerInstance(domain.ServerInstance{
ID: "server-1",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "SCUM #1",
})
if err != nil {
t.Fatalf("create server instance: %v", err)
}
if _, err := svc.CreateJob(domain.Job{ID: "job-1", ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: "process.start", IdempotencyKey: "idem-start"}); err != nil {
t.Fatalf("create job: %v", err)
}
hello, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register run hello: %v", err)
}
return svc, hello.SessionToken
}
func validArtifactTransferOpen(sessionToken string, payload []byte, chunkSize int) domain.ArtifactTransferOpen {
return domain.ArtifactTransferOpen{
RunEndpointID: "run-local",
SessionToken: sessionToken,
ArtifactID: "artifact-1",
Direction: domain.ArtifactTransferDirectionUpload,
OwnerKind: domain.ArtifactOwnerKindJob,
OwnerID: "job-1",
SizeBytes: int64(len(payload)),
ChunkSizeBytes: chunkSize,
Checksum: validator.BytesChecksum(payload),
IdempotencyKey: "artifact-upload-1",
}
}
func validArtifactChunk(sessionToken string, transferID string, payload []byte, index int, chunkSize int) domain.ArtifactChunkUpload {
offset := index * chunkSize
end := offset + chunkSize
if end > len(payload) {
end = len(payload)
}
chunkPayload := payload[offset:end]
return domain.ArtifactChunkUpload{
RunEndpointID: "run-local",
SessionToken: sessionToken,
TransferID: transferID,
ArtifactID: "artifact-1",
ChunkIndex: index,
Offset: int64(offset),
SizeBytes: len(chunkPayload),
Checksum: validator.BytesChecksum(chunkPayload),
Payload: chunkPayload,
}
}
+121
View File
@@ -0,0 +1,121 @@
package service
import (
"errors"
"fmt"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
const (
defaultHeartbeatIntervalSeconds = 15
)
func (svc *CoreService) RegisterRunHello(hello domain.RunControlHello) (domain.RunControlHelloResult, error) {
hello = domain.CopyRunControlHello(hello)
if err := validator.ValidateRunControlHello(hello); err != nil {
return domain.RunControlHelloResult{}, err
}
stamp := svc.now()
endpoint := domain.RunEndpoint{
ID: hello.RunEndpointID,
DisplayName: hello.DisplayName,
Version: hello.Version,
Status: domain.RunEndpointStatusOnline,
Capabilities: domain.CopyStringSlice(hello.CapabilityReport.Capabilities),
Capacity: hello.Capacity,
LastHeartbeatAt: stamp,
}
if err := validator.ValidateRunEndpoint(endpoint); err != nil {
return domain.RunControlHelloResult{}, err
}
svc.controlMu.Lock()
defer svc.controlMu.Unlock()
if err := svc.upsertRunEndpoint(endpoint); err != nil {
return domain.RunControlHelloResult{}, err
}
sessionToken := svc.nextSessionToken(hello.RunEndpointID, stamp)
svc.runSessions[hello.RunEndpointID] = domain.RunControlSession{
RunEndpointID: hello.RunEndpointID,
SessionToken: sessionToken,
CapabilityFingerprint: hello.CapabilityReport.Fingerprint,
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
CreatedAt: stamp,
UpdatedAt: stamp,
}
return domain.CopyRunControlHelloResult(domain.RunControlHelloResult{
Accepted: true,
RunEndpointID: hello.RunEndpointID,
SessionToken: sessionToken,
ServerTime: stamp,
HeartbeatIntervalSeconds: defaultHeartbeatIntervalSeconds,
FeatureFlags: []string{"control.hello", "control.heartbeat"},
}), nil
}
func (svc *CoreService) AcceptRunHeartbeat(heartbeat domain.RunControlHeartbeat) (domain.RunControlHeartbeatResult, error) {
heartbeat = domain.CopyRunControlHeartbeat(heartbeat)
if err := validator.ValidateRunControlHeartbeat(heartbeat); err != nil {
return domain.RunControlHeartbeatResult{}, err
}
stamp := svc.now()
svc.controlMu.Lock()
defer svc.controlMu.Unlock()
session, exists := svc.runSessions[heartbeat.RunEndpointID]
if !exists || session.SessionToken != heartbeat.SessionToken {
return domain.RunControlHeartbeatResult{}, validationError("sessionToken is invalid")
}
endpoint, err := svc.store.RunEndpoints().Get(heartbeat.RunEndpointID)
if err != nil {
return domain.RunControlHeartbeatResult{}, err
}
endpoint.Version = heartbeat.Version
endpoint.Status = heartbeat.Status
endpoint.Capacity = heartbeat.Capacity
endpoint.LastHeartbeatAt = stamp
if err := validator.ValidateRunEndpoint(endpoint); err != nil {
return domain.RunControlHeartbeatResult{}, err
}
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
return domain.RunControlHeartbeatResult{}, err
}
refreshCapabilities := session.CapabilityFingerprint != heartbeat.CapabilityFingerprint
session.CapabilityFingerprint = heartbeat.CapabilityFingerprint
session.UpdatedAt = stamp
svc.runSessions[heartbeat.RunEndpointID] = session
return domain.CopyRunControlHeartbeatResult(domain.RunControlHeartbeatResult{
Accepted: true,
RunEndpointID: heartbeat.RunEndpointID,
NextHeartbeatSeconds: session.HeartbeatIntervalSeconds,
RefreshCapabilities: refreshCapabilities,
ServerTime: stamp,
}), nil
}
func (svc *CoreService) upsertRunEndpoint(endpoint domain.RunEndpoint) error {
if _, err := svc.store.RunEndpoints().Get(endpoint.ID); err != nil {
if errors.Is(err, repo.ErrNotFound) {
return svc.store.RunEndpoints().Create(endpoint)
}
return err
}
return svc.store.RunEndpoints().Update(endpoint)
}
func (svc *CoreService) nextSessionToken(runEndpointID string, stamp time.Time) string {
svc.runSessionSeq++
return fmt.Sprintf("session:%s:%d:%d", runEndpointID, stamp.UnixNano(), svc.runSessionSeq)
}
+189
View File
@@ -0,0 +1,189 @@
package service
import (
"errors"
"strings"
"testing"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestCoreServiceRegistersNewRunControlSession(t *testing.T) {
svc := newTestCoreService()
result, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register hello: %v", err)
}
if !result.Accepted || result.SessionToken == "" || result.HeartbeatIntervalSeconds <= 0 {
t.Fatalf("expected accepted hello response, got %+v", result)
}
endpoint, err := svc.GetRunEndpoint("run-local")
if err != nil {
t.Fatalf("get registered endpoint: %v", err)
}
if endpoint.Status != domain.RunEndpointStatusOnline || !endpoint.LastHeartbeatAt.Equal(fixedTime) {
t.Fatalf("expected online endpoint with heartbeat time, got %+v", endpoint)
}
if len(endpoint.Capabilities) != 2 || endpoint.Capacity.MaxJobs != 4 {
t.Fatalf("expected capabilities and capacity, got %+v", endpoint)
}
}
func TestCoreServiceReRegistersExistingRunEndpoint(t *testing.T) {
svc := newTestCoreService()
first, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register first hello: %v", err)
}
hello := validRunControlHello()
hello.DisplayName = "Local Run Updated"
hello.Version = "0.2.0"
hello.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "jobs.claim"}
hello.CapabilityReport.Fingerprint = "cap-v2"
second, err := svc.RegisterRunHello(hello)
if err != nil {
t.Fatalf("register second hello: %v", err)
}
if second.SessionToken == first.SessionToken {
t.Fatalf("expected re-registration to issue a new token, got %q", second.SessionToken)
}
endpoint, err := svc.GetRunEndpoint("run-local")
if err != nil {
t.Fatalf("get re-registered endpoint: %v", err)
}
if endpoint.DisplayName != "Local Run Updated" || endpoint.Version != "0.2.0" || len(endpoint.Capabilities) != 3 {
t.Fatalf("expected endpoint metadata update, got %+v", endpoint)
}
}
func TestCoreServiceAcceptsRunHeartbeat(t *testing.T) {
svc := newTestCoreService()
hello, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register hello: %v", err)
}
result, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.1",
Status: domain.RunEndpointStatusDegraded,
CapabilityFingerprint: "cap-v1",
Capacity: domain.RunCapacity{MaxJobs: 4, RunningJobs: 2, QueuedJobs: 1},
})
if err != nil {
t.Fatalf("accept heartbeat: %v", err)
}
if !result.Accepted || result.RefreshCapabilities {
t.Fatalf("expected accepted heartbeat without refresh, got %+v", result)
}
endpoint, err := svc.GetRunEndpoint("run-local")
if err != nil {
t.Fatalf("get heartbeat endpoint: %v", err)
}
if endpoint.Status != domain.RunEndpointStatusDegraded || endpoint.Version != "0.1.1" || endpoint.Capacity.RunningJobs != 2 {
t.Fatalf("expected heartbeat metadata update, got %+v", endpoint)
}
}
func TestCoreServiceRejectsInvalidRunHeartbeatToken(t *testing.T) {
svc := newTestCoreService()
if _, err := svc.RegisterRunHello(validRunControlHello()); err != nil {
t.Fatalf("register hello: %v", err)
}
_, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
RunEndpointID: "run-local",
SessionToken: "stale-token",
Version: "0.1.1",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v1",
Capacity: domain.RunCapacity{MaxJobs: 4, RunningJobs: 3},
})
if err == nil || !strings.Contains(err.Error(), "sessionToken is invalid") {
t.Fatalf("expected invalid token rejection, got %v", err)
}
endpoint, err := svc.GetRunEndpoint("run-local")
if err != nil {
t.Fatalf("get endpoint after rejected heartbeat: %v", err)
}
if endpoint.Capacity.RunningJobs != 0 || endpoint.Version != "0.1.0" {
t.Fatalf("heartbeat with invalid token must not update endpoint, got %+v", endpoint)
}
}
func TestCoreServiceRejectsInvalidRunControlHello(t *testing.T) {
svc := newTestCoreService()
invalid := validRunControlHello()
invalid.RegistrationToken = ""
invalid.Capacity.RunningJobs = 8
_, err := svc.RegisterRunHello(invalid)
if err == nil || !strings.Contains(err.Error(), "registrationToken") || !strings.Contains(err.Error(), "runningJobs") {
t.Fatalf("expected validation errors, got %v", err)
}
if _, err := svc.GetRunEndpoint("run-local"); !errors.Is(err, repo.ErrNotFound) {
t.Fatalf("invalid hello must not create endpoint, got %v", err)
}
}
func TestCoreServiceRequestsCapabilityRefreshOnFingerprintDrift(t *testing.T) {
svc := newTestCoreService()
hello, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register hello: %v", err)
}
result, err := svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v2",
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil {
t.Fatalf("accept drift heartbeat: %v", err)
}
if !result.RefreshCapabilities {
t.Fatalf("expected capability refresh request, got %+v", result)
}
result, err = svc.AcceptRunHeartbeat(domain.RunControlHeartbeat{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
CapabilityFingerprint: "cap-v2",
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil {
t.Fatalf("accept stable heartbeat: %v", err)
}
if result.RefreshCapabilities {
t.Fatalf("expected refreshed fingerprint to become known, got %+v", result)
}
}
func validRunControlHello() domain.RunControlHello {
return domain.RunControlHello{
RegistrationToken: "registration-token",
RunEndpointID: "run-local",
DisplayName: "Local Run",
Version: "0.1.0",
Status: domain.RunEndpointStatusOnline,
Platform: "darwin/arm64",
CapabilityReport: domain.RunCapabilityReport{
Capabilities: []string{"control.hello", "control.heartbeat"},
Fingerprint: "cap-v1",
},
Capacity: domain.RunCapacity{MaxJobs: 4},
}
}
+408
View File
@@ -0,0 +1,408 @@
package service
import (
"fmt"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
const defaultJobPollSeconds = 2
func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClaimResult, error) {
claim = domain.CopyRunJobClaim(claim)
if err := validator.ValidateRunJobClaim(claim); err != nil {
return domain.RunJobClaimResult{}, err
}
if err := svc.validateRunSession(claim.RunEndpointID, claim.SessionToken); err != nil {
return domain.RunJobClaimResult{}, err
}
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID, State: domain.JobStateQueued})
if err != nil {
return domain.RunJobClaimResult{}, err
}
job, ok := firstSupportedJob(jobs, claim.Capabilities)
if !ok {
return domain.RunJobClaimResult{
Accepted: true,
RunEndpointID: claim.RunEndpointID,
NextPollSeconds: defaultJobPollSeconds,
ServerTime: stamp,
}, nil
}
lease := svc.newJobLease(job.ID, claim.RunEndpointID, claim.SessionToken, stamp)
svc.jobLeases[job.ID] = lease
job.State = domain.JobStateAccepted
job.UpdatedAt = stamp
if err := validator.ValidateJob(job); err != nil {
return domain.RunJobClaimResult{}, err
}
if err := svc.store.Jobs().Update(job); err != nil {
return domain.RunJobClaimResult{}, err
}
assignment := assignmentFromJob(job, lease)
return domain.CopyRunJobClaimResult(domain.RunJobClaimResult{
Accepted: true,
RunEndpointID: claim.RunEndpointID,
HasJob: true,
Job: &assignment,
NextPollSeconds: defaultJobPollSeconds,
ServerTime: stamp,
}), nil
}
func (svc *CoreService) AckRunJob(ack domain.RunJobAck) (domain.RunJobAckResult, error) {
if err := validator.ValidateRunJobAck(ack); err != nil {
return domain.RunJobAckResult{}, err
}
if err := svc.validateRunSession(ack.RunEndpointID, ack.SessionToken); err != nil {
return domain.RunJobAckResult{}, err
}
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, lease, err := svc.activeLeasedJob(ack.RunEndpointID, ack.SessionToken, ack.JobID, ack.LeaseToken, ack.Attempt)
if err != nil {
return domain.RunJobAckResult{}, err
}
if isTerminalJobState(job.State) {
return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
return domain.RunJobAckResult{}, validationError("job is not claimable for ack")
}
job.State = domain.JobStateRunning
if strings.TrimSpace(ack.Message) != "" {
job.Progress.Message = ack.Message
}
job.UpdatedAt = stamp
if err := validator.ValidateJob(job); err != nil {
return domain.RunJobAckResult{}, err
}
if err := svc.store.Jobs().Update(job); err != nil {
return domain.RunJobAckResult{}, err
}
lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease
return domain.RunJobAckResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
}
func (svc *CoreService) UpdateRunJobProgress(progress domain.RunJobProgress) (domain.RunJobProgressResult, error) {
if err := validator.ValidateRunJobProgress(progress); err != nil {
return domain.RunJobProgressResult{}, err
}
if err := svc.validateRunSession(progress.RunEndpointID, progress.SessionToken); err != nil {
return domain.RunJobProgressResult{}, err
}
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, lease, err := svc.activeLeasedJob(progress.RunEndpointID, progress.SessionToken, progress.JobID, progress.LeaseToken, progress.Attempt)
if err != nil {
return domain.RunJobProgressResult{}, err
}
if job.State != domain.JobStateAccepted && job.State != domain.JobStateRunning {
return domain.RunJobProgressResult{}, validationError("job is not active")
}
job.State = domain.JobStateRunning
job.Progress = domain.JobProgress{Percent: progress.Progress.Percent, Message: progress.Progress.Message}
job.UpdatedAt = stamp
if err := validator.ValidateJob(job); err != nil {
return domain.RunJobProgressResult{}, err
}
if err := svc.store.Jobs().Update(job); err != nil {
return domain.RunJobProgressResult{}, err
}
lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease
return domain.RunJobProgressResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
}
func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJobResultResult, error) {
if err := validator.ValidateRunJobResult(result); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.validateRunSession(result.RunEndpointID, result.SessionToken); err != nil {
return domain.RunJobResultResult{}, err
}
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, lease, err := svc.activeLeasedJob(result.RunEndpointID, result.SessionToken, result.JobID, result.LeaseToken, result.Attempt)
if err != nil {
return domain.RunJobResultResult{}, err
}
fingerprint := terminalFingerprint(result)
if isTerminalJobState(job.State) {
if lease.TerminalFingerprint != "" && lease.TerminalFingerprint == fingerprint {
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
}
return domain.RunJobResultResult{}, validationError("terminal result conflicts with existing job result")
}
job.State = result.State
job.Progress = domain.JobProgress{Percent: result.Progress.Percent, Message: terminalMessage(result)}
job.ResultRef = result.ResultRef
job.UpdatedAt = stamp
if err := validator.ValidateJob(job); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.store.Jobs().Update(job); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.projectLifecycleJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
lease.TerminalFingerprint = fingerprint
lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, lease), ServerTime: stamp}, nil
}
func (svc *CoreService) RequestRunJobCancel(request domain.RunJobCancelRequest) (domain.RunJobCancelRequestResult, error) {
if err := validator.ValidateRunJobCancelRequest(request); err != nil {
return domain.RunJobCancelRequestResult{}, err
}
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
job, err := svc.store.Jobs().Get(request.JobID)
if err != nil {
return domain.RunJobCancelRequestResult{}, err
}
if !isActiveJobState(job.State) {
return domain.RunJobCancelRequestResult{}, validationError("job is not active")
}
lease, exists := svc.jobLeases[job.ID]
if !exists {
return domain.RunJobCancelRequestResult{}, validationError("job lease is missing")
}
lease.CancelReason = request.Reason
lease.CancelRequestedAt = stamp
lease.UpdatedAt = stamp
svc.jobLeases[job.ID] = lease
return domain.RunJobCancelRequestResult{Accepted: true, JobID: job.ID, Reason: request.Reason, RequestedAt: stamp}, nil
}
func (svc *CoreService) PollRunJobCancel(poll domain.RunJobCancelPoll) (domain.RunJobCancelPollResult, error) {
if err := validator.ValidateRunJobCancelPoll(poll); err != nil {
return domain.RunJobCancelPollResult{}, err
}
if err := svc.validateRunSession(poll.RunEndpointID, poll.SessionToken); err != nil {
return domain.RunJobCancelPollResult{}, err
}
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
lease, ok := svc.findCancelLease(poll)
if !ok {
return domain.RunJobCancelPollResult{Accepted: true, RunEndpointID: poll.RunEndpointID, ServerTime: stamp}, nil
}
return domain.RunJobCancelPollResult{
Accepted: true,
RunEndpointID: poll.RunEndpointID,
HasCancel: true,
JobID: lease.JobID,
Reason: lease.CancelReason,
RequestedAt: lease.CancelRequestedAt,
ServerTime: stamp,
}, nil
}
func (svc *CoreService) ReconcileRunJobs(reconcile domain.RunJobReconcile) (domain.RunJobReconcileResult, error) {
reconcile = domain.CopyRunJobReconcile(reconcile)
if err := validator.ValidateRunJobReconcile(reconcile); err != nil {
return domain.RunJobReconcileResult{}, err
}
if err := svc.validateRunSession(reconcile.RunEndpointID, reconcile.SessionToken); err != nil {
return domain.RunJobReconcileResult{}, err
}
stamp := svc.now()
svc.jobMu.Lock()
defer svc.jobMu.Unlock()
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: reconcile.RunEndpointID})
if err != nil {
return domain.RunJobReconcileResult{}, err
}
activeByID := map[string]domain.Job{}
for _, job := range jobs {
if isActiveJobState(job.State) {
activeByID[job.ID] = job
}
}
activeJobs := make([]domain.RunJobAssignment, 0, len(activeByID))
ids := make([]string, 0, len(activeByID))
for id := range activeByID {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
job := activeByID[id]
lease := svc.jobLeases[job.ID]
if lease.JobID == "" || lease.SessionToken != reconcile.SessionToken {
lease = svc.newJobLease(job.ID, reconcile.RunEndpointID, reconcile.SessionToken, stamp)
} else {
lease.UpdatedAt = stamp
}
svc.jobLeases[job.ID] = lease
activeJobs = append(activeJobs, assignmentFromJob(job, lease))
}
unknown := make([]string, 0)
for _, reportedID := range reconcile.ActiveJobIDs {
if _, exists := activeByID[reportedID]; !exists {
unknown = append(unknown, reportedID)
}
}
sort.Strings(unknown)
return domain.CopyRunJobReconcileResult(domain.RunJobReconcileResult{
Accepted: true,
RunEndpointID: reconcile.RunEndpointID,
ActiveJobs: activeJobs,
UnknownJobIDs: unknown,
ServerTime: stamp,
}), nil
}
func (svc *CoreService) validateRunSession(runEndpointID string, sessionToken string) error {
svc.controlMu.Lock()
defer svc.controlMu.Unlock()
session, exists := svc.runSessions[runEndpointID]
if !exists || session.SessionToken != sessionToken {
return validationError("sessionToken is invalid")
}
return nil
}
func (svc *CoreService) newJobLease(jobID string, runEndpointID string, sessionToken string, stamp time.Time) domain.RunJobLease {
svc.jobLeaseSeq++
return domain.RunJobLease{
JobID: jobID,
RunEndpointID: runEndpointID,
SessionToken: sessionToken,
LeaseToken: fmt.Sprintf("job-lease:%s:%d:%d", jobID, stamp.UnixNano(), svc.jobLeaseSeq),
Attempt: int(svc.jobLeaseSeq),
CreatedAt: stamp,
UpdatedAt: stamp,
}
}
func (svc *CoreService) activeLeasedJob(runEndpointID string, sessionToken string, jobID string, leaseToken string, attempt int) (domain.Job, domain.RunJobLease, error) {
job, err := svc.store.Jobs().Get(jobID)
if err != nil {
return domain.Job{}, domain.RunJobLease{}, err
}
if job.RunEndpointID != runEndpointID {
return domain.Job{}, domain.RunJobLease{}, validationError("job runEndpointId does not match request")
}
lease, exists := svc.jobLeases[jobID]
if !exists || lease.SessionToken != sessionToken || lease.LeaseToken != leaseToken || lease.Attempt != attempt {
return domain.Job{}, domain.RunJobLease{}, validationError("leaseToken is invalid")
}
return job, lease, nil
}
func (svc *CoreService) findCancelLease(poll domain.RunJobCancelPoll) (domain.RunJobLease, bool) {
if poll.JobID != "" {
lease, exists := svc.jobLeases[poll.JobID]
if !exists || lease.RunEndpointID != poll.RunEndpointID || lease.SessionToken != poll.SessionToken {
return domain.RunJobLease{}, false
}
if poll.LeaseToken != "" && lease.LeaseToken != poll.LeaseToken {
return domain.RunJobLease{}, false
}
return lease, lease.CancelReason != ""
}
ids := make([]string, 0, len(svc.jobLeases))
for id := range svc.jobLeases {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
lease := svc.jobLeases[id]
if lease.RunEndpointID == poll.RunEndpointID && lease.SessionToken == poll.SessionToken && lease.CancelReason != "" {
return lease, true
}
}
return domain.RunJobLease{}, false
}
func firstSupportedJob(jobs []domain.Job, capabilities []string) (domain.Job, bool) {
capabilitySet := map[string]struct{}{}
for _, capability := range capabilities {
capabilitySet[capability] = struct{}{}
}
for _, job := range jobs {
if len(capabilitySet) == 0 {
return job, true
}
if _, supported := capabilitySet[job.Capability]; supported {
return job, true
}
}
return domain.Job{}, false
}
func assignmentFromJob(job domain.Job, lease domain.RunJobLease) domain.RunJobAssignment {
return domain.RunJobAssignment{
JobID: job.ID,
ServerInstanceID: job.ServerInstanceID,
RunEndpointID: job.RunEndpointID,
Capability: job.Capability,
TargetKey: job.TargetKey,
InputRef: job.InputRef,
IdempotencyKey: job.IdempotencyKey,
State: job.State,
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Message: job.Progress.Message},
ResultRef: job.ResultRef,
LeaseToken: lease.LeaseToken,
Attempt: lease.Attempt,
CreatedAt: job.CreatedAt,
UpdatedAt: job.UpdatedAt,
}
}
func terminalFingerprint(result domain.RunJobResult) string {
return fmt.Sprintf("%s|%d|%s|%s|%s|%s", result.State, result.Progress.Percent, result.ResultRef, result.Message, result.ErrorCode, result.Progress.Message)
}
func terminalMessage(result domain.RunJobResult) string {
if strings.TrimSpace(result.Message) != "" {
return result.Message
}
return result.Progress.Message
}
func isActiveJobState(state domain.JobState) bool {
return state == domain.JobStateAccepted || state == domain.JobStateRunning
}
func isTerminalJobState(state domain.JobState) bool {
return state == domain.JobStateSucceeded || state == domain.JobStateFailed || state == domain.JobStateCancelled
}
+263
View File
@@ -0,0 +1,263 @@
package service
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestCoreServiceRunJobLifecycle(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
RunEndpointID: "run-local",
SessionToken: sessionToken,
Capabilities: []string{"process.start"},
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil {
t.Fatalf("claim job: %v", err)
}
if !claim.Accepted || !claim.HasJob || claim.Job.JobID != "job-1" || claim.Job.State != domain.JobStateAccepted {
t.Fatalf("expected claimed job, got %+v", claim)
}
ack, err := svc.AckRunJob(domain.RunJobAck{
RunEndpointID: "run-local",
SessionToken: sessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Message: "starting",
})
if err != nil {
t.Fatalf("ack job: %v", err)
}
if ack.Job.State != domain.JobStateRunning || ack.Job.Progress.Message != "starting" {
t.Fatalf("expected running ack job, got %+v", ack)
}
progress, err := svc.UpdateRunJobProgress(domain.RunJobProgress{
RunEndpointID: "run-local",
SessionToken: sessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Progress: domain.RunJobProgressReport{Percent: 50, Message: "half"},
})
if err != nil {
t.Fatalf("progress job: %v", err)
}
if progress.Job.Progress.Percent != 50 || progress.Job.Progress.Message != "half" {
t.Fatalf("expected progress update, got %+v", progress)
}
result, err := svc.CompleteRunJob(domain.RunJobResult{
RunEndpointID: "run-local",
SessionToken: sessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"},
ResultRef: "artifact://jobs/job-1/result",
Message: "done",
})
if err != nil {
t.Fatalf("complete job: %v", err)
}
if result.Job.State != domain.JobStateSucceeded || result.Job.ResultRef != "artifact://jobs/job-1/result" {
t.Fatalf("expected succeeded result, got %+v", result)
}
stored, err := svc.GetJob("job-1")
if err != nil {
t.Fatalf("get completed job: %v", err)
}
if stored.State != domain.JobStateSucceeded || stored.Progress.Percent != 100 {
t.Fatalf("expected stored terminal job, got %+v", stored)
}
}
func TestCoreServiceRunJobClaimNoJob(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
RunEndpointID: "run-local",
SessionToken: sessionToken,
Capabilities: []string{"process.start"},
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil {
t.Fatalf("claim no job: %v", err)
}
if !claim.Accepted || claim.HasJob || claim.Job != nil || claim.NextPollSeconds <= 0 {
t.Fatalf("expected empty claim response, got %+v", claim)
}
}
func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
_, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: "stale", Capacity: domain.RunCapacity{MaxJobs: 4}})
if err == nil || !strings.Contains(err.Error(), "sessionToken") {
t.Fatalf("expected invalid session rejection, got %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim job: %v", err)
}
_, err = svc.UpdateRunJobProgress(domain.RunJobProgress{
RunEndpointID: "run-local",
SessionToken: sessionToken,
JobID: claim.Job.JobID,
LeaseToken: "bad-lease",
Attempt: claim.Job.Attempt,
Progress: domain.RunJobProgressReport{Percent: 10},
})
if err == nil || !strings.Contains(err.Error(), "leaseToken") {
t.Fatalf("expected invalid lease rejection, got %v", err)
}
}
func TestCoreServiceRunJobRejectsInvalidProgress(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim job: %v", err)
}
_, err = svc.UpdateRunJobProgress(domain.RunJobProgress{
RunEndpointID: "run-local",
SessionToken: sessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
Progress: domain.RunJobProgressReport{Percent: 101},
})
if err == nil || !strings.Contains(err.Error(), "progress.percent") {
t.Fatalf("expected invalid progress rejection, got %v", err)
}
}
func TestCoreServiceRunJobCancelPoll(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim job: %v", err)
}
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err != nil {
t.Fatalf("ack job: %v", err)
}
cancel, err := svc.RequestRunJobCancel(domain.RunJobCancelRequest{JobID: "job-1", Reason: "operator requested"})
if err != nil {
t.Fatalf("request cancel: %v", err)
}
if !cancel.Accepted || cancel.Reason != "operator requested" {
t.Fatalf("unexpected cancel request: %+v", cancel)
}
poll, err := svc.PollRunJobCancel(domain.RunJobCancelPoll{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: "job-1", LeaseToken: claim.Job.LeaseToken})
if err != nil {
t.Fatalf("poll cancel: %v", err)
}
if !poll.HasCancel || poll.JobID != "job-1" || poll.Reason != "operator requested" {
t.Fatalf("expected cancel poll result, got %+v", poll)
}
}
func TestCoreServiceRunJobTerminalResultIsIdempotent(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim job: %v", err)
}
request := domain.RunJobResult{
RunEndpointID: "run-local",
SessionToken: sessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"},
ResultRef: "artifact://jobs/job-1/result",
Message: "done",
}
first, err := svc.CompleteRunJob(request)
if err != nil {
t.Fatalf("complete first: %v", err)
}
second, err := svc.CompleteRunJob(request)
if err != nil {
t.Fatalf("complete duplicate: %v", err)
}
if second.Job.State != first.Job.State || second.Job.ResultRef != first.Job.ResultRef {
t.Fatalf("expected duplicate result to be idempotent, got %+v %+v", first, second)
}
request.State = domain.JobStateFailed
request.Message = "failed"
_, err = svc.CompleteRunJob(request)
if err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("expected conflicting result rejection, got %v", err)
}
}
func TestCoreServiceRunJobReconcile(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1")
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: sessionToken, Capacity: domain.RunCapacity{MaxJobs: 4}})
if err != nil {
t.Fatalf("claim job: %v", err)
}
if _, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}); err != nil {
t.Fatalf("ack job: %v", err)
}
reconcile, err := svc.ReconcileRunJobs(domain.RunJobReconcile{RunEndpointID: "run-local", SessionToken: sessionToken, ActiveJobIDs: []string{"job-1", "local-only"}})
if err != nil {
t.Fatalf("reconcile jobs: %v", err)
}
if len(reconcile.ActiveJobs) != 1 || reconcile.ActiveJobs[0].JobID != "job-1" {
t.Fatalf("expected platform active job, got %+v", reconcile)
}
if len(reconcile.UnknownJobIDs) != 1 || reconcile.UnknownJobIDs[0] != "local-only" {
t.Fatalf("expected unknown local job, got %+v", reconcile.UnknownJobIDs)
}
}
func newRegisteredRunJobService(t *testing.T) (*CoreService, string) {
t.Helper()
svc := newTestCoreService()
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.start")
helloRequest.CapabilityReport.Fingerprint = "cap-jobs"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register run hello: %v", err)
}
return svc, hello.SessionToken
}
func createQueuedRunJob(t *testing.T, svc *CoreService, id string, idempotencyKey string) domain.Job {
t.Helper()
job, err := svc.CreateJob(domain.Job{
ID: id,
RunEndpointID: "run-local",
Capability: "process.start",
IdempotencyKey: idempotencyKey,
})
if err != nil {
t.Fatalf("create queued job: %v", err)
}
return job
}
+251
View File
@@ -0,0 +1,251 @@
package service
import (
"bufio"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
type LogBodyStore interface {
AppendBatch(streamID string, record domain.LogBatchRecord) error
GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error)
Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error)
}
type MemoryLogBodyStore struct {
mu sync.Mutex
entries map[string][]domain.LogEntry
batches map[string]map[uint64]domain.LogBatchRecord
}
func NewMemoryLogBodyStore() *MemoryLogBodyStore {
return &MemoryLogBodyStore{
entries: map[string][]domain.LogEntry{},
batches: map[string]map[uint64]domain.LogBatchRecord{},
}
}
func (store *MemoryLogBodyStore) AppendBatch(streamID string, record domain.LogBatchRecord) error {
store.mu.Lock()
defer store.mu.Unlock()
records := store.batches[streamID]
if records == nil {
records = map[uint64]domain.LogBatchRecord{}
store.batches[streamID] = records
}
if existing, exists := records[record.FirstSeq]; exists {
if existing.LastSeq == record.LastSeq && existing.Checksum == record.Checksum {
return nil
}
return validationError("log batch conflicts with acknowledged range")
}
records[record.FirstSeq] = domain.CopyLogBatchRecord(record)
store.entries[streamID] = append(store.entries[streamID], domain.CopyLogEntries(record.Entries)...)
return nil
}
func (store *MemoryLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) {
store.mu.Lock()
defer store.mu.Unlock()
record, exists := store.batches[streamID][firstSeq]
if !exists {
return domain.LogBatchRecord{}, false, nil
}
return domain.CopyLogBatchRecord(record), true, nil
}
func (store *MemoryLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) {
store.mu.Lock()
defer store.mu.Unlock()
entries := domain.CopyLogEntries(store.entries[streamID])
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq })
selected := make([]domain.LogEntry, 0, limit)
nextSeq := afterSeq
for _, entry := range entries {
if entry.Seq <= afterSeq {
continue
}
if len(selected) >= limit {
break
}
selected = append(selected, entry)
nextSeq = entry.Seq
}
return selected, nextSeq, nil
}
type FileLogBodyStore struct {
mu sync.Mutex
rootDir string
memory *MemoryLogBodyStore
}
func NewFileLogBodyStore(rootDir string) (*FileLogBodyStore, error) {
rootDir = strings.TrimSpace(rootDir)
if rootDir == "" {
return nil, fmt.Errorf("log directory is required")
}
store := &FileLogBodyStore{
rootDir: rootDir,
memory: NewMemoryLogBodyStore(),
}
if err := os.MkdirAll(rootDir, 0o755); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
if err := store.load(); err != nil {
return nil, err
}
return store, nil
}
func (store *FileLogBodyStore) RootDir() string {
return store.rootDir
}
func (store *FileLogBodyStore) AppendBatch(streamID string, record domain.LogBatchRecord) error {
store.mu.Lock()
defer store.mu.Unlock()
if _, exists, err := store.memory.GetBatch(streamID, record.FirstSeq); err != nil {
return err
} else if exists {
return store.memory.AppendBatch(streamID, record)
}
streamDir := store.streamDir(streamID)
if err := os.MkdirAll(streamDir, 0o755); err != nil {
return fmt.Errorf("create log stream directory: %w", err)
}
segmentPath := store.segmentPath(streamID, record.FirstSeq)
if _, err := os.Stat(segmentPath); err == nil {
return validationError("log batch conflicts with acknowledged range")
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat log segment: %w", err)
}
tmpPath := segmentPath + ".tmp"
file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open log segment: %w", err)
}
encoder := json.NewEncoder(file)
for _, entry := range record.Entries {
if err := encoder.Encode(domain.CopyLogEntry(entry)); err != nil {
_ = file.Close()
return fmt.Errorf("write log segment: %w", err)
}
}
if err := file.Close(); err != nil {
return fmt.Errorf("close log segment: %w", err)
}
if err := os.Rename(tmpPath, segmentPath); err != nil {
return fmt.Errorf("replace log segment: %w", err)
}
return store.memory.AppendBatch(streamID, record)
}
func (store *FileLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) {
return store.memory.GetBatch(streamID, firstSeq)
}
func (store *FileLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) {
return store.memory.Query(streamID, afterSeq, limit)
}
func (store *FileLogBodyStore) load() error {
entries, err := os.ReadDir(store.rootDir)
if err != nil {
return fmt.Errorf("read log root: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
streamID, err := url.PathUnescape(entry.Name())
if err != nil {
return fmt.Errorf("decode log stream directory: %w", err)
}
if err := store.loadStream(streamID, filepath.Join(store.rootDir, entry.Name())); err != nil {
return err
}
}
return nil
}
func (store *FileLogBodyStore) loadStream(streamID string, streamDir string) error {
segments, err := os.ReadDir(streamDir)
if err != nil {
return fmt.Errorf("read log stream directory: %w", err)
}
sort.SliceStable(segments, func(i, j int) bool { return segments[i].Name() < segments[j].Name() })
for _, segment := range segments {
if segment.IsDir() || !strings.HasPrefix(segment.Name(), "segment-") || !strings.HasSuffix(segment.Name(), ".jsonl") {
continue
}
segmentPath := filepath.Join(streamDir, segment.Name())
record, err := readLogSegment(segmentPath)
if err != nil {
return err
}
if len(record.Entries) == 0 {
continue
}
if err := store.memory.AppendBatch(streamID, record); err != nil {
return err
}
}
return nil
}
func (store *FileLogBodyStore) streamDir(streamID string) string {
return filepath.Join(store.rootDir, url.PathEscape(streamID))
}
func (store *FileLogBodyStore) segmentPath(streamID string, firstSeq uint64) string {
return filepath.Join(store.streamDir(streamID), fmt.Sprintf("segment-%020d.jsonl", firstSeq))
}
func readLogSegment(path string) (domain.LogBatchRecord, error) {
file, err := os.Open(path)
if err != nil {
return domain.LogBatchRecord{}, fmt.Errorf("open log segment: %w", err)
}
defer file.Close()
entries := []domain.LogEntry{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var entry domain.LogEntry
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
return domain.LogBatchRecord{}, fmt.Errorf("decode log segment %s: %w", filepath.Base(path), err)
}
entries = append(entries, domain.CopyLogEntry(entry))
}
if err := scanner.Err(); err != nil {
return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), err)
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq })
if len(entries) == 0 {
return domain.LogBatchRecord{}, nil
}
checksum, err := validator.LogEntriesChecksum(entries)
if err != nil {
return domain.LogBatchRecord{}, fmt.Errorf("checksum log segment %s: %w", filepath.Base(path), err)
}
return domain.LogBatchRecord{
Checksum: checksum,
FirstSeq: entries[0].Seq,
LastSeq: entries[len(entries)-1].Seq,
Entries: entries,
}, nil
}
+113
View File
@@ -0,0 +1,113 @@
package service
import (
"browser.local/platform/domain"
"browser.local/platform/validator"
)
const defaultLogQueryLimit = 100
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
batch = domain.CopyLogBatchIngest(batch)
if err := validator.ValidateLogBatchIngest(batch); err != nil {
return domain.LogBatchIngestResult{}, err
}
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
return domain.LogBatchIngestResult{}, err
}
stamp := svc.now()
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
if err != nil {
return domain.LogBatchIngestResult{}, err
}
if err := validateLogBatchStream(batch, stream); err != nil {
return domain.LogBatchIngestResult{}, err
}
if batch.LastSeq <= stream.LatestSeq {
record, exists, err := svc.logStore.GetBatch(batch.LogStreamID, batch.FirstSeq)
if err != nil {
return domain.LogBatchIngestResult{}, err
}
if exists && record.LastSeq == batch.LastSeq && record.Checksum == batch.Checksum {
return domain.LogBatchIngestResult{
Accepted: true,
LogStreamID: batch.LogStreamID,
AcceptedFrom: batch.FirstSeq,
AcceptedTo: batch.LastSeq,
LatestSeq: stream.LatestSeq,
Duplicate: true,
ServerTime: stamp,
}, nil
}
return domain.LogBatchIngestResult{}, validationError("log batch conflicts with acknowledged range")
}
if batch.FirstSeq != stream.LatestSeq+1 {
return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence")
}
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
Checksum: batch.Checksum,
FirstSeq: batch.FirstSeq,
LastSeq: batch.LastSeq,
Entries: batch.Entries,
})
if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil {
return domain.LogBatchIngestResult{}, err
}
stream.LatestSeq = batch.LastSeq
stream.UpdatedAt = stamp
if err := svc.store.LogStreams().Update(stream); err != nil {
return domain.LogBatchIngestResult{}, err
}
return domain.LogBatchIngestResult{
Accepted: true,
LogStreamID: batch.LogStreamID,
AcceptedFrom: batch.FirstSeq,
AcceptedTo: batch.LastSeq,
LatestSeq: stream.LatestSeq,
ServerTime: stamp,
}, nil
}
func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
if err := validator.ValidateLogStreamCursorQuery(query); err != nil {
return domain.LogStreamCursorResult{}, err
}
stream, err := svc.store.LogStreams().Get(query.LogStreamID)
if err != nil {
return domain.LogStreamCursorResult{}, err
}
limit := query.Limit
if limit == 0 {
limit = defaultLogQueryLimit
}
selected, nextSeq, err := svc.logStore.Query(query.LogStreamID, query.AfterSeq, limit)
if err != nil {
return domain.LogStreamCursorResult{}, err
}
return domain.CopyLogStreamCursorResult(domain.LogStreamCursorResult{
LogStreamID: query.LogStreamID,
Entries: selected,
NextSeq: nextSeq,
LatestSeq: stream.LatestSeq,
}), nil
}
func validateLogBatchStream(batch domain.LogBatchIngest, stream domain.LogStream) error {
if stream.ID != batch.LogStreamID {
return validationError("logStreamId must match stream")
}
if stream.ServerInstanceID != batch.ServerInstanceID {
return validationError("serverInstanceId must match stream")
}
if stream.StreamKey != batch.StreamKey {
return validationError("streamKey must match stream")
}
if stream.Source != batch.Source {
return validationError("source must match stream")
}
return nil
}
+198
View File
@@ -0,0 +1,198 @@
package service
import (
"path/filepath"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
func TestCoreServiceIngestsLogBatchAndQueriesCursor(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
batch := validLogBatch(t, sessionToken, 1, 2)
ack, err := svc.IngestLogBatch(batch)
if err != nil {
t.Fatalf("ingest log batch: %v", err)
}
if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 2 || ack.LatestSeq != 2 {
t.Fatalf("unexpected ack: %+v", ack)
}
stream, err := svc.GetLogStream("log-1")
if err != nil {
t.Fatalf("get log stream: %v", err)
}
if stream.LatestSeq != 2 {
t.Fatalf("expected latest seq 2, got %+v", stream)
}
query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: "log-1", AfterSeq: 1, Limit: 10})
if err != nil {
t.Fatalf("query log stream: %v", err)
}
if len(query.Entries) != 1 || query.Entries[0].Seq != 2 || query.NextSeq != 2 || query.LatestSeq != 2 {
t.Fatalf("unexpected query result: %+v", query)
}
}
func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
batch := validLogBatch(t, sessionToken, 1, 2)
if _, err := svc.IngestLogBatch(batch); err != nil {
t.Fatalf("ingest first batch: %v", err)
}
ack, err := svc.IngestLogBatch(batch)
if err != nil {
t.Fatalf("ingest duplicate batch: %v", err)
}
if !ack.Duplicate || ack.LatestSeq != 2 {
t.Fatalf("expected duplicate ack, got %+v", ack)
}
}
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
gap := validLogBatch(t, sessionToken, 2, 2)
_, err := svc.IngestLogBatch(gap)
if err == nil || !strings.Contains(err.Error(), "firstSeq") {
t.Fatalf("expected out-of-order rejection, got %v", err)
}
batch := validLogBatch(t, sessionToken, 1, 2)
if _, err := svc.IngestLogBatch(batch); err != nil {
t.Fatalf("ingest first batch: %v", err)
}
conflict := batch
conflict.Entries[0].Line = "changed"
conflict.Checksum = checksumForEntries(t, conflict.Entries)
_, err = svc.IngestLogBatch(conflict)
if err == nil || !strings.Contains(err.Error(), "conflicts") {
t.Fatalf("expected conflicting duplicate rejection, got %v", err)
}
}
func TestCoreServiceRejectsMissingLogStream(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
_, err := svc.IngestLogBatch(validLogBatch(t, sessionToken, 1, 1))
if err == nil {
t.Fatal("expected missing stream error")
}
}
func TestFileLogBodyStoreReloadsBatchesAndCursorEntries(t *testing.T) {
rootDir := filepath.Join(t.TempDir(), "logs")
store, err := NewFileLogBodyStore(rootDir)
if err != nil {
t.Fatalf("create file log store: %v", err)
}
entries := []domain.LogEntry{
{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "one"},
{Seq: 2, Timestamp: time.Date(2026, 7, 3, 12, 0, 2, 0, time.UTC), Level: "warn", Line: "two"},
}
record := domain.LogBatchRecord{
Checksum: checksumForEntries(t, entries),
FirstSeq: 1,
LastSeq: 2,
Entries: entries,
}
if err := store.AppendBatch("log-1", record); err != nil {
t.Fatalf("append batch: %v", err)
}
reloaded, err := NewFileLogBodyStore(rootDir)
if err != nil {
t.Fatalf("reload file log store: %v", err)
}
got, exists, err := reloaded.GetBatch("log-1", 1)
if err != nil {
t.Fatalf("get reloaded batch: %v", err)
}
if !exists || got.Checksum != record.Checksum || got.LastSeq != 2 {
t.Fatalf("unexpected reloaded batch: exists=%v record=%+v", exists, got)
}
selected, nextSeq, err := reloaded.Query("log-1", 1, 10)
if err != nil {
t.Fatalf("query reloaded entries: %v", err)
}
if len(selected) != 1 || selected[0].Seq != 2 || selected[0].Line != "two" || nextSeq != 2 {
t.Fatalf("unexpected reloaded query: entries=%+v next=%d", selected, nextSeq)
}
}
func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
t.Helper()
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
if _, err := svc.CreateServerInstance(domain.ServerInstance{
ID: "server-1",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "SCUM #1",
}); err != nil {
t.Fatalf("create server instance: %v", err)
}
hello, err := svc.RegisterRunHello(validRunControlHello())
if err != nil {
t.Fatalf("register run hello: %v", err)
}
return svc, hello.SessionToken
}
func createLogStreamFixture(t *testing.T, svc *CoreService) domain.LogStream {
t.Helper()
stream, err := svc.CreateLogStream(domain.LogStream{
ID: "log-1",
ServerInstanceID: "server-1",
Source: domain.LogStreamSourceProcess,
StreamKey: "stdout",
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
})
if err != nil {
t.Fatalf("create log stream: %v", err)
}
return stream
}
func validLogBatch(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) domain.LogBatchIngest {
t.Helper()
entries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1)
for seq := firstSeq; seq <= lastSeq; seq++ {
entries = append(entries, domain.LogEntry{
Seq: seq,
Timestamp: time.Date(2026, 7, 3, 12, 0, int(seq), 0, time.UTC),
Level: "info",
Line: "line",
})
}
return domain.LogBatchIngest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
LogStreamID: "log-1",
ServerInstanceID: "server-1",
StreamKey: "stdout",
Source: domain.LogStreamSourceProcess,
FirstSeq: firstSeq,
LastSeq: lastSeq,
Compression: "none",
Checksum: checksumForEntries(t, entries),
Entries: entries,
}
}
func checksumForEntries(t *testing.T, entries []domain.LogEntry) string {
t.Helper()
checksum, err := validator.LogEntriesChecksum(entries)
if err != nil {
t.Fatalf("checksum entries: %v", err)
}
return checksum
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
package service
import "browser.local/platform/domain"
func (svc *CoreService) authorizeServerLifecycle(sessionID string, serverInstanceID string) error {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return err
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return err
}
if !canAccessServer(user, instance) {
return ErrForbidden
}
return nil
}
func (svc *CoreService) requireServerOwner(sessionID string, serverInstanceID string) (domain.User, domain.ServerInstance, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.User{}, domain.ServerInstance{}, err
}
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
if err != nil {
return domain.User{}, domain.ServerInstance{}, err
}
if instance.OwnerUserID != user.ID {
return domain.User{}, domain.ServerInstance{}, ErrForbidden
}
return user, instance, nil
}
func canAccessServer(user domain.User, instance domain.ServerInstance) bool {
return isPlatformAdmin(user) || instance.OwnerUserID == user.ID || containsString(instance.AdminUserIDs, user.ID)
}
func isPlatformAdmin(user domain.User) bool {
for _, role := range user.Roles {
switch role {
case "admin", "platform-admin", "platformadmin":
return true
}
}
return false
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
+233
View File
@@ -0,0 +1,233 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) CreateServerInstanceWorkflow(create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) {
create = domain.CopyServerLifecycleCreate(create)
if err := validator.ValidateServerLifecycleCreate(create); err != nil {
return domain.ServerLifecycleResult{}, err
}
plugin, endpoint, err := svc.lifecycleDependencies(create.PluginID, create.RunEndpointID)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateLifecycleActionRef(plugin, domain.ServerLifecycleActionCreate); err != nil {
return domain.ServerLifecycleResult{}, err
}
stamp := svc.now()
instance := domain.ServerInstance{
ID: create.ID,
PluginID: create.PluginID,
PluginVersion: plugin.Version,
RunEndpointID: create.RunEndpointID,
Name: create.Name,
OwnerUserID: create.OwnerUserID,
State: domain.ServerInstanceStateInstalling,
ConfigVersion: 1,
CreatedAt: stamp,
UpdatedAt: stamp,
}
if err := validator.ValidateServerInstance(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.validateLifecycleIdempotency(instance.RunEndpointID, create.IdempotencyKey, instance.ID, domain.LifecycleCapabilityForAction(domain.ServerLifecycleActionCreate)); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := svc.store.ServerInstances().Create(instance); err != nil {
return domain.ServerLifecycleResult{}, err
}
job, err := svc.dispatchLifecycleJob(instance, domain.ServerLifecycleActionCreate, create.IdempotencyKey)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{
Accepted: true,
Action: domain.ServerLifecycleActionCreate,
Instance: instance,
Job: job,
}), nil
}
func (svc *CoreService) CreateServerInstanceWorkflowForSession(sessionID string, create domain.ServerLifecycleCreate) (domain.ServerLifecycleResult, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
if strings.TrimSpace(create.OwnerUserID) == "" {
create.OwnerUserID = user.ID
}
if !isPlatformAdmin(user) && create.OwnerUserID != user.ID {
return domain.ServerLifecycleResult{}, ErrForbidden
}
return svc.CreateServerInstanceWorkflow(create)
}
func (svc *CoreService) StartServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStart, []domain.ServerInstanceState{
domain.ServerInstanceStateReady,
domain.ServerInstanceStateStopped,
})
}
func (svc *CoreService) StartServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
}
return svc.StartServerInstance(command)
}
func (svc *CoreService) StopServerInstance(command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
return svc.dispatchExistingServerLifecycle(command, domain.ServerLifecycleActionStop, []domain.ServerInstanceState{
domain.ServerInstanceStateRunning,
})
}
func (svc *CoreService) StopServerInstanceForSession(sessionID string, command domain.ServerLifecycleCommand) (domain.ServerLifecycleResult, error) {
if err := svc.authorizeServerLifecycle(sessionID, command.ServerInstanceID); err != nil {
return domain.ServerLifecycleResult{}, err
}
return svc.StopServerInstance(command)
}
func (svc *CoreService) dispatchExistingServerLifecycle(command domain.ServerLifecycleCommand, action domain.ServerLifecycleAction, allowedStates []domain.ServerInstanceState) (domain.ServerLifecycleResult, error) {
command = domain.CopyServerLifecycleCommand(command)
if err := validator.ValidateServerLifecycleCommand(command); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validator.ValidateServerLifecycleAction(action); err != nil {
return domain.ServerLifecycleResult{}, err
}
instance, err := svc.store.ServerInstances().Get(command.ServerInstanceID)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
if instance.ConfigVersion != command.ExpectedConfigVersion {
return domain.ServerLifecycleResult{}, validationError("expectedConfigVersion must match server instance")
}
if !serverStateAllowed(instance.State, allowedStates) {
return domain.ServerLifecycleResult{}, validationError(fmt.Sprintf("server instance state %q cannot %s", instance.State, action))
}
plugin, endpoint, err := svc.lifecycleDependencies(instance.PluginID, instance.RunEndpointID)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateLifecycleActionRef(plugin, action); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validator.ValidateServerInstanceDependencies(instance, plugin, endpoint); err != nil {
return domain.ServerLifecycleResult{}, err
}
if err := validateRunnableEndpoint(endpoint, domain.LifecycleCapabilityForAction(action)); err != nil {
return domain.ServerLifecycleResult{}, err
}
job, err := svc.dispatchLifecycleJob(instance, action, command.IdempotencyKey)
if err != nil {
return domain.ServerLifecycleResult{}, err
}
return domain.CopyServerLifecycleResult(domain.ServerLifecycleResult{
Accepted: true,
Action: action,
Instance: instance,
Job: job,
}), nil
}
func (svc *CoreService) lifecycleDependencies(pluginID string, runEndpointID string) (domain.GamePlugin, domain.RunEndpoint, error) {
plugin, err := svc.store.GamePlugins().Get(pluginID)
if err != nil {
return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get plugin dependency: %w", err)
}
endpoint, err := svc.store.RunEndpoints().Get(runEndpointID)
if err != nil {
return domain.GamePlugin{}, domain.RunEndpoint{}, fmt.Errorf("get run endpoint dependency: %w", err)
}
return plugin, endpoint, nil
}
func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, action domain.ServerLifecycleAction, idempotencyKey string) (domain.Job, error) {
capability := domain.LifecycleCapabilityForAction(action)
job, err := svc.CreateJob(domain.Job{
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
ServerInstanceID: instance.ID,
RunEndpointID: instance.RunEndpointID,
Capability: capability,
IdempotencyKey: idempotencyKey,
})
if err != nil {
return domain.Job{}, err
}
if job.ServerInstanceID != instance.ID || job.RunEndpointID != instance.RunEndpointID || job.Capability != capability {
return domain.Job{}, validationError("idempotencyKey is already used for a different lifecycle target")
}
return job, nil
}
func (svc *CoreService) validateLifecycleIdempotency(runEndpointID string, idempotencyKey string, serverInstanceID string, capability string) error {
existing, err := svc.store.Jobs().GetByIdempotency(runEndpointID, idempotencyKey)
if errors.Is(err, repo.ErrNotFound) {
return nil
}
if err != nil {
return err
}
if existing.ServerInstanceID == serverInstanceID && existing.Capability == capability {
return nil
}
return validationError("idempotencyKey is already used for a different lifecycle target")
}
func validateLifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) error {
if strings.TrimSpace(lifecycleActionRef(plugin, action)) == "" {
return validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
}
return nil
}
func lifecycleActionRef(plugin domain.GamePlugin, action domain.ServerLifecycleAction) string {
switch action {
case domain.ServerLifecycleActionCreate:
return plugin.LifecycleActions.Install
case domain.ServerLifecycleActionStart:
return plugin.LifecycleActions.Start
case domain.ServerLifecycleActionStop:
return plugin.LifecycleActions.Stop
default:
return ""
}
}
func serverStateAllowed(state domain.ServerInstanceState, allowed []domain.ServerInstanceState) bool {
for _, candidate := range allowed {
if state == candidate {
return true
}
}
return false
}
func lifecycleJobID(serverInstanceID string, action domain.ServerLifecycleAction, idempotencyKey string) string {
sum := sha256.Sum256([]byte(idempotencyKey))
return fmt.Sprintf("server-lifecycle:%s:%s:%s", serverInstanceID, action, hex.EncodeToString(sum[:8]))
}
@@ -0,0 +1,47 @@
package service
import (
"time"
"browser.local/platform/domain"
"browser.local/platform/validator"
)
func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error {
nextState, ok := lifecycleProjectedState(job.Capability, job.State)
if !ok || job.ServerInstanceID == "" {
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
instance.State = nextState
instance.UpdatedAt = stamp
if err := validator.ValidateServerInstance(instance); err != nil {
return err
}
return svc.store.ServerInstances().Update(instance)
}
func lifecycleProjectedState(capability string, jobState domain.JobState) (domain.ServerInstanceState, bool) {
if capability != domain.LifecycleCapabilityInstall && capability != domain.LifecycleCapabilityStart && capability != domain.LifecycleCapabilityStop {
return "", false
}
if jobState == domain.JobStateFailed || jobState == domain.JobStateCancelled {
return domain.ServerInstanceStateFailed, true
}
if jobState != domain.JobStateSucceeded {
return "", false
}
switch capability {
case domain.LifecycleCapabilityInstall:
return domain.ServerInstanceStateReady, true
case domain.LifecycleCapabilityStart:
return domain.ServerInstanceStateRunning, true
case domain.LifecycleCapabilityStop:
return domain.ServerInstanceStateStopped, true
default:
return "", false
}
}
+294
View File
@@ -0,0 +1,294 @@
package service
import (
"strings"
"testing"
"browser.local/platform/domain"
)
func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
created, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{
ID: "server-1",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "SCUM #1",
IdempotencyKey: "idem-create",
})
if err != nil {
t.Fatalf("create lifecycle workflow: %v", err)
}
if created.Action != domain.ServerLifecycleActionCreate || created.Instance.State != domain.ServerInstanceStateInstalling || created.Job.Capability != domain.LifecycleCapabilityInstall {
t.Fatalf("expected install workflow result, got %+v", created)
}
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
ready, err := svc.GetServerInstance("server-1")
if err != nil {
t.Fatalf("get ready instance: %v", err)
}
if ready.State != domain.ServerInstanceStateReady {
t.Fatalf("expected install result to mark ready, got %+v", ready)
}
started, err := svc.StartServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: "server-1",
ExpectedConfigVersion: ready.ConfigVersion,
IdempotencyKey: "idem-start",
})
if err != nil {
t.Fatalf("start lifecycle workflow: %v", err)
}
if started.Action != domain.ServerLifecycleActionStart || started.Job.Capability != domain.LifecycleCapabilityStart {
t.Fatalf("expected start workflow result, got %+v", started)
}
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
running, err := svc.GetServerInstance("server-1")
if err != nil {
t.Fatalf("get running instance: %v", err)
}
if running.State != domain.ServerInstanceStateRunning {
t.Fatalf("expected start result to mark running, got %+v", running)
}
stopped, err := svc.StopServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: "server-1",
ExpectedConfigVersion: running.ConfigVersion,
IdempotencyKey: "idem-stop",
})
if err != nil {
t.Fatalf("stop lifecycle workflow: %v", err)
}
if stopped.Action != domain.ServerLifecycleActionStop || stopped.Job.Capability != domain.LifecycleCapabilityStop {
t.Fatalf("expected stop workflow result, got %+v", stopped)
}
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
final, err := svc.GetServerInstance("server-1")
if err != nil {
t.Fatalf("get stopped instance: %v", err)
}
if final.State != domain.ServerInstanceStateStopped {
t.Fatalf("expected stop result to mark stopped, got %+v", final)
}
}
func TestCoreServiceServerLifecycleRejectsInvalidCommands(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
instance, err := svc.CreateServerInstance(domain.ServerInstance{
ID: "server-ready",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "Ready Server",
State: domain.ServerInstanceStateReady,
})
if err != nil {
t.Fatalf("create ready server: %v", err)
}
_, err = svc.StartServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: instance.ID,
ExpectedConfigVersion: instance.ConfigVersion + 1,
IdempotencyKey: "idem-stale",
})
if err == nil || !strings.Contains(err.Error(), "expectedConfigVersion") {
t.Fatalf("expected stale config rejection, got %v", err)
}
_, err = svc.StopServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: instance.ID,
ExpectedConfigVersion: instance.ConfigVersion,
IdempotencyKey: "idem-stop-invalid",
})
if err == nil || !strings.Contains(err.Error(), "cannot stop") {
t.Fatalf("expected invalid stop state rejection, got %v", err)
}
weakEndpoint := endpoint
weakEndpoint.ID = "run-no-stop"
weakEndpoint.Capabilities = []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, "logs.read"}
if _, err := svc.CreateRunEndpoint(weakEndpoint); err != nil {
t.Fatalf("create weak endpoint: %v", err)
}
running, err := svc.CreateServerInstance(domain.ServerInstance{
ID: "server-running",
PluginID: plugin.ID,
RunEndpointID: weakEndpoint.ID,
Name: "Running Server",
State: domain.ServerInstanceStateRunning,
})
if err == nil {
_, err = svc.StopServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: running.ID,
ExpectedConfigVersion: running.ConfigVersion,
IdempotencyKey: "idem-stop-missing-capability",
})
}
if err == nil || !strings.Contains(err.Error(), domain.LifecycleCapabilityStop) {
t.Fatalf("expected missing stop capability rejection, got %v", err)
}
}
func TestCoreServiceLifecycleFailureProjectsFailedState(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{
ID: "server-1",
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: "SCUM #1",
IdempotencyKey: "idem-create",
}); err != nil {
t.Fatalf("create lifecycle workflow: %v", err)
}
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityInstall, domain.JobStateFailed)
instance, err := svc.GetServerInstance("server-1")
if err != nil {
t.Fatalf("get failed instance: %v", err)
}
if instance.State != domain.ServerInstanceStateFailed {
t.Fatalf("expected failed install result to mark failed, got %+v", instance)
}
}
func TestCoreServicePluginLifecycleManagesMultipleInstancesIndependently(t *testing.T) {
svc, sessionToken := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
for _, id := range []string{"server-alpha", "server-beta"} {
if _, err := svc.CreateServerInstanceWorkflow(domain.ServerLifecycleCreate{
ID: id,
PluginID: "server.scum",
RunEndpointID: "run-local",
Name: id,
IdempotencyKey: "idem-create-" + id,
}); err != nil {
t.Fatalf("create %s: %v", id, err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, id, domain.LifecycleCapabilityInstall, domain.JobStateSucceeded)
}
alpha, err := svc.GetServerInstance("server-alpha")
if err != nil {
t.Fatalf("get alpha: %v", err)
}
beta, err := svc.GetServerInstance("server-beta")
if err != nil {
t.Fatalf("get beta: %v", err)
}
if alpha.State != domain.ServerInstanceStateReady || beta.State != domain.ServerInstanceStateReady || alpha.ID == beta.ID || alpha.PluginID != beta.PluginID {
t.Fatalf("expected distinct ready sibling instances, alpha=%+v beta=%+v", alpha, beta)
}
if _, err := svc.StartServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: alpha.ID,
ExpectedConfigVersion: alpha.ConfigVersion,
IdempotencyKey: "idem-start-alpha",
}); err != nil {
t.Fatalf("start alpha: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, alpha.ID, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
alpha, _ = svc.GetServerInstance("server-alpha")
beta, _ = svc.GetServerInstance("server-beta")
if alpha.State != domain.ServerInstanceStateRunning || beta.State != domain.ServerInstanceStateReady {
t.Fatalf("expected alpha running and beta unchanged, alpha=%+v beta=%+v", alpha, beta)
}
if _, err := svc.StopServerInstance(domain.ServerLifecycleCommand{
ServerInstanceID: alpha.ID,
ExpectedConfigVersion: alpha.ConfigVersion,
IdempotencyKey: "idem-stop-alpha",
}); err != nil {
t.Fatalf("stop alpha: %v", err)
}
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, alpha.ID, domain.LifecycleCapabilityStop, domain.JobStateSucceeded)
alpha, _ = svc.GetServerInstance("server-alpha")
beta, _ = svc.GetServerInstance("server-beta")
if alpha.State != domain.ServerInstanceStateStopped || beta.State != domain.ServerInstanceStateReady {
t.Fatalf("expected alpha stopped and beta still unchanged, alpha=%+v beta=%+v", alpha, beta)
}
}
func newLifecycleRunService(t *testing.T) (*CoreService, string) {
t.Helper()
svc := newTestCoreService()
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities,
domain.LifecycleCapabilityInstall,
domain.LifecycleCapabilityStart,
domain.LifecycleCapabilityStop,
"logs.read",
"files.read",
)
helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register run hello: %v", err)
}
return svc, hello.SessionToken
}
func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin {
t.Helper()
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{
ID: "server.scum",
Name: "SCUM",
Version: "1.0.0",
ServerType: "scum",
ManifestRef: "artifact://manifests/server.scum/1.0.0",
CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0",
RequiredRunCapabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "logs.read"},
LifecycleActions: domain.PluginLifecycleActions{
Install: "actions/install.json",
Start: "actions/start.json",
Stop: "actions/stop.json",
},
Permissions: domain.PluginPermissions{Jobs: true, Logs: true},
})
if err != nil {
t.Fatalf("create lifecycle plugin: %v", err)
}
return plugin
}
func claimAndCompleteLifecycleJob(t *testing.T, svc *CoreService, sessionToken string, capability string, state domain.JobState) {
t.Helper()
claimAndCompleteLifecycleJobForServer(t, svc, sessionToken, "", capability, state)
}
func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessionToken string, serverInstanceID string, capability string, state domain.JobState) {
t.Helper()
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
RunEndpointID: "run-local",
SessionToken: sessionToken,
Capabilities: []string{capability},
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil {
t.Fatalf("claim lifecycle job %s: %v", capability, err)
}
if !claim.HasJob || claim.Job.Capability != capability {
t.Fatalf("expected claimed lifecycle job %s, got %+v", capability, claim)
}
if serverInstanceID != "" && claim.Job.ServerInstanceID != serverInstanceID {
t.Fatalf("expected claimed lifecycle job for %s, got %+v", serverInstanceID, claim.Job)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{
RunEndpointID: "run-local",
SessionToken: sessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: state,
Progress: domain.RunJobProgressReport{Percent: 100, Message: string(state)},
Message: string(state),
}); err != nil {
t.Fatalf("complete lifecycle job %s: %v", capability, err)
}
}