Files
kandev/apps/backend/internal/canvas/service.go
T
2026-09-23 20:51:55 +01:00

912 lines
29 KiB
Go

package canvas
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
)
// Service coordinates canvas metadata with its plugin instance. The mutex
// covers admission and lifecycle mutations so a create, restore, or cleanup
// cannot observe a partially completed operation in this process. The plugin
// instance store remains the durable admission authority.
type Service struct {
repo *Repository
instances PluginInstanceStore
mu sync.Mutex
clock func() time.Time
publisher EventPublisher
stateCleanup func(context.Context, string) error
}
// NewService constructs a canvas lifecycle service. The instance store is
// normally *instances.Store from internal/plugins/instances.
func NewService(repo *Repository, instanceStore PluginInstanceStore) *Service {
return &Service{repo: repo, instances: instanceStore, clock: time.Now}
}
// SetEventPublisher attaches the content-free lifecycle notification sink.
// Wire it before serving requests. Publishing occurs after the database
// mutation has committed and never determines mutation success.
func (s *Service) SetEventPublisher(publisher EventPublisher) {
if s == nil {
return
}
s.mu.Lock()
s.publisher = publisher
s.mu.Unlock()
}
// SetInstanceStateCleanup wires the instance-state owner into canvas removal.
// The callback is optional so the lifecycle service remains usable with the
// narrow in-memory stores used by tests and migrations.
func (s *Service) SetInstanceStateCleanup(cleanup func(context.Context, string) error) {
if s == nil {
return
}
s.mu.Lock()
s.stateCleanup = cleanup
s.mu.Unlock()
}
// Create creates a pending canvas and its task- or workspace-scoped plugin
// instance. A task ID creates a task canvas; an empty task ID is reserved for
// trusted internal callers creating a workspace-scoped canvas.
func (s *Service) Create(ctx context.Context, request CreateCanvasRequest) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
request, err := normalizeCreateRequest(request)
if err != nil {
return nil, err
}
s.mu.Lock()
canvas, event, err := s.createLocked(ctx, request)
publisher := s.publisher
s.mu.Unlock()
if err != nil {
return nil, err
}
publishEvent(ctx, publisher, event)
return &canvas, nil
}
// CreateCanvas is the descriptive alias used by HTTP and MCP adapters.
func (s *Service) CreateCanvas(ctx context.Context, request CreateCanvasRequest) (*Canvas, error) {
return s.Create(ctx, request)
}
func (s *Service) createLocked(ctx context.Context, request CreateCanvasRequest) (Canvas, LifecycleEvent, error) {
now := s.nowUTC()
canvasID := uuid.NewString()
instanceID := uuid.NewString()
scopeKind := ScopeWorkspace
if request.TaskID != "" {
scopeKind = ScopeTask
}
metadata := CanvasMetadata{
ID: canvasID,
PluginInstanceID: instanceID,
WorkspaceID: request.WorkspaceID,
TaskID: request.TaskID,
OriginTaskID: request.OriginTaskID,
Title: request.Title,
CreatedBySessionID: request.CreatedBySessionID,
CreationOwnerUserID: request.OwnerUserID,
CreatedAt: now,
UpdatedAt: now,
}
instance := plugininstances.Instance{
ID: instanceID,
PluginID: request.PluginID,
SourceKind: plugininstances.SourceLocalCanvas,
ScopeKind: scopeKind,
WorkspaceID: request.WorkspaceID,
TaskID: request.TaskID,
Status: plugininstances.StatusPending,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.createAuthority(ctx, instance, metadata); err != nil {
return Canvas{}, LifecycleEvent{}, err
}
canvas := canvasFromMetadataInstance(metadata, instance)
return canvas, lifecycleEvent(EventCreated, canvas), nil
}
func (s *Service) createAuthority(ctx context.Context, instance plugininstances.Instance, metadata CanvasMetadata) error {
transactional, ok := s.instances.(transactionalPluginInstanceStore)
if ok {
return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
if err := transactional.CreateTx(ctx, tx, instance); err != nil {
return err
}
return s.repo.CreateTx(ctx, tx, metadata)
})
}
if err := s.instances.Create(ctx, instance); err != nil {
return err
}
if err := s.repo.Create(ctx, metadata); err != nil {
cleanupErr := s.compensateCreate(instance.ID)
if cleanupErr != nil {
return errors.Join(err, fmt.Errorf("remove admitted plugin instance: %w", cleanupErr))
}
return err
}
return nil
}
// Get returns one non-removed canvas and reports an unavailable active release
// in the projection without attempting to execute or remove that release.
func (s *Service) Get(ctx context.Context, id string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
metadata, instance, err := s.load(ctx, id)
if err != nil {
return nil, err
}
if instance.Status == StatusRemoved {
return nil, ErrCanvasNotFound
}
canvas, err := s.buildCanvas(ctx, metadata, instance)
if err != nil {
return nil, err
}
return &canvas, nil
}
// GetCanvas is the descriptive alias used by API adapters.
func (s *Service) GetCanvas(ctx context.Context, id string) (*Canvas, error) {
return s.Get(ctx, id)
}
// Rename updates the instance title without changing its release or runtime.
func (s *Service) Rename(ctx context.Context, id, title string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
title = strings.TrimSpace(title)
if title == "" || len([]rune(title)) > MaxTitleLength {
return nil, ErrInvalidCanvas
}
s.mu.Lock()
_, instance, err := s.load(ctx, id)
if err == nil && instance.Status == StatusRemoved {
err = ErrCanvasNotFound
}
if err == nil {
err = s.repo.Rename(ctx, id, title, s.nowUTC())
}
var updated Canvas
if err == nil {
metadata, current, loadErr := s.load(ctx, id)
err = loadErr
if err == nil {
updated, err = s.buildCanvas(ctx, metadata, current)
}
}
publisher := s.publisher
s.mu.Unlock()
if err != nil {
return nil, err
}
publishEvent(ctx, publisher, lifecycleEvent(EventUpdated, updated))
return &updated, nil
}
// ListTaskCanvases lists task-scoped canvases. Removed canvases are never
// returned; archived canvases are returned only when includeArchived is true.
func (s *Service) ListTaskCanvases(ctx context.Context, taskID string, includeArchived bool) ([]Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
metadata, err := s.repo.ListByTask(ctx, taskID)
if err != nil {
return nil, err
}
return s.listMetadata(ctx, metadata, includeArchived, isTaskCanvas)
}
// ListWorkspaceCanvases lists promoted or internally-created workspace-scoped
// canvases. Task-only canvases are not included.
func (s *Service) ListWorkspaceCanvases(ctx context.Context, workspaceID string, includeArchived bool) ([]Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
metadata, err := s.repo.ListByWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
return s.listMetadata(ctx, metadata, includeArchived, isWorkspaceCanvas)
}
// ListForTask combines task canvases with workspace canvases applicable to
// the task's workspace.
func (s *Service) ListForTask(ctx context.Context, workspaceID, taskID string, includeArchived bool) ([]Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
if strings.TrimSpace(taskID) == "" || strings.TrimSpace(workspaceID) == "" {
return nil, ErrInvalidCanvas
}
taskMetadata, err := s.repo.ListByTask(ctx, taskID)
if err != nil {
return nil, err
}
workspaceMetadata, err := s.repo.ListByWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
workspaceOnly := make([]CanvasMetadata, 0, len(workspaceMetadata))
for _, item := range workspaceMetadata {
if item.TaskID == "" {
workspaceOnly = append(workspaceOnly, item)
}
}
metadata := make([]CanvasMetadata, 0, len(taskMetadata)+len(workspaceOnly))
metadata = append(metadata, taskMetadata...)
metadata = append(metadata, workspaceOnly...)
return s.listMetadata(ctx, metadata, includeArchived, func(item CanvasMetadata, instance plugininstances.Instance) bool {
if item.WorkspaceID != workspaceID {
return false
}
return (isTaskCanvas(item, instance) && item.TaskID == taskID) || isWorkspaceCanvas(item, instance)
})
}
// ListCanvasesForTask is an alias for ListForTask.
func (s *Service) ListCanvasesForTask(ctx context.Context, workspaceID, taskID string, includeArchived bool) ([]Canvas, error) {
return s.ListForTask(ctx, workspaceID, taskID, includeArchived)
}
// Archive changes a canvas to the archived instance state. Archived canvases
// remain admitted and therefore continue to count against both limits.
func (s *Service) Archive(ctx context.Context, id string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
s.mu.Lock()
canvas, event, changed, err := s.archiveLocked(ctx, id)
publisher := s.publisher
s.mu.Unlock()
if err != nil {
return nil, err
}
if changed {
publishEvent(ctx, publisher, event)
}
return &canvas, nil
}
// ArchiveCanvas is the descriptive alias used by API adapters.
func (s *Service) ArchiveCanvas(ctx context.Context, id string) (*Canvas, error) {
return s.Archive(ctx, id)
}
func (s *Service) archiveLocked(ctx context.Context, id string) (Canvas, LifecycleEvent, bool, error) {
metadata, instance, err := s.load(ctx, id)
if err != nil {
return Canvas{}, LifecycleEvent{}, false, err
}
if instance.Status == StatusRemoved {
return Canvas{}, LifecycleEvent{}, false, ErrCanvasNotFound
}
if instance.Status == StatusArchived {
canvas, err := s.buildCanvas(ctx, metadata, instance)
return canvas, LifecycleEvent{}, false, err
}
if err := s.instances.Archive(ctx, instance.ID); err != nil {
return Canvas{}, LifecycleEvent{}, false, err
}
instance, err = s.instances.Get(ctx, instance.ID)
if err != nil {
return Canvas{}, LifecycleEvent{}, false, err
}
canvas, err := s.buildCanvas(ctx, metadata, instance)
if err != nil {
return Canvas{}, LifecycleEvent{}, false, err
}
return canvas, lifecycleEvent(EventArchived, canvas), true, nil
}
// Restore re-admits an archived canvas through the instance store's atomic
// limit check. A full task or workspace returns the stable admission error.
func (s *Service) Restore(ctx context.Context, id string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
s.mu.Lock()
canvas, event, err := s.restoreLocked(ctx, id)
publisher := s.publisher
s.mu.Unlock()
if err != nil {
return nil, err
}
publishEvent(ctx, publisher, event)
return &canvas, nil
}
// RestoreCanvas is the descriptive alias used by API adapters.
func (s *Service) RestoreCanvas(ctx context.Context, id string) (*Canvas, error) {
return s.Restore(ctx, id)
}
func (s *Service) restoreLocked(ctx context.Context, id string) (Canvas, LifecycleEvent, error) {
metadata, instance, err := s.load(ctx, id)
if err != nil {
return Canvas{}, LifecycleEvent{}, err
}
if instance.Status == StatusRemoved {
return Canvas{}, LifecycleEvent{}, ErrCanvasNotFound
}
if instance.Status != StatusArchived {
return Canvas{}, LifecycleEvent{}, fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status)
}
if err := s.instances.Restore(ctx, instance.ID); err != nil {
return Canvas{}, LifecycleEvent{}, err
}
instance, err = s.instances.Get(ctx, instance.ID)
if err != nil {
return Canvas{}, LifecycleEvent{}, err
}
canvas, err := s.buildCanvas(ctx, metadata, instance)
if err != nil {
return Canvas{}, LifecycleEvent{}, err
}
return canvas, lifecycleEvent(EventRestored, canvas), nil
}
// Remove first asks the plugin instance store to record cleanup jobs and
// remove live instance authority, then deletes canvas metadata. This ordering
// makes artifact cleanup durable if the process stops between both commits.
func (s *Service) Remove(ctx context.Context, id string) error {
if err := s.ready(); err != nil {
return err
}
s.mu.Lock()
event, err := s.removeLocked(ctx, id)
publisher := s.publisher
s.mu.Unlock()
if err != nil {
return err
}
publishEvent(ctx, publisher, event)
return nil
}
// RemoveCanvas is the descriptive alias used by API adapters.
func (s *Service) RemoveCanvas(ctx context.Context, id string) error {
return s.Remove(ctx, id)
}
func (s *Service) removeLocked(ctx context.Context, id string) (LifecycleEvent, error) {
metadata, instance, err := s.load(ctx, id)
if err != nil {
return LifecycleEvent{}, err
}
if err := s.removeAuthority(ctx, metadata, instance); err != nil {
return LifecycleEvent{}, err
}
instance.Status = StatusRemoved
instance.ActiveReleaseID = ""
if s.stateCleanup != nil {
if err := s.stateCleanup(ctx, instance.ID); err != nil {
return LifecycleEvent{}, err
}
}
canvas := canvasFromMetadataInstance(metadata, instance)
return lifecycleEvent(EventRemoved, canvas), nil
}
func (s *Service) removeAuthority(ctx context.Context, metadata CanvasMetadata, instance plugininstances.Instance) error {
if instance.Status == StatusRemoved {
if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil {
return err
}
err := s.repo.Delete(ctx, metadata.ID)
if errors.Is(err, ErrCanvasNotFound) {
return nil
}
return err
}
transactional, ok := s.instances.(transactionalPluginInstanceStore)
if ok {
return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
if err := transactional.RemoveInstanceTx(ctx, tx, instance.ID); err != nil {
return err
}
if err := s.repo.DeleteInstallReceiptTx(ctx, tx, metadata.ID); err != nil {
return err
}
return s.repo.DeleteTx(ctx, tx, metadata.ID)
})
}
if err := s.instances.RemoveInstance(ctx, instance.ID); err != nil {
return err
}
if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil {
return err
}
return s.repo.Delete(ctx, metadata.ID)
}
// CleanupTask removes all current task-scoped canvases. Promotion clears the
// task_id in metadata, so promoted canvases are intentionally untouched.
func (s *Service) CleanupTask(ctx context.Context, taskID string) error {
if err := s.ready(); err != nil {
return err
}
if strings.TrimSpace(taskID) == "" {
return ErrInvalidCanvas
}
s.mu.Lock()
metadata, err := s.repo.ListByTask(ctx, taskID)
if err != nil {
s.mu.Unlock()
return err
}
events, err := s.cleanupMetadataLocked(ctx, metadata)
if err == nil {
err = s.repo.ClearOriginTask(ctx, taskID)
}
publisher := s.publisher
s.mu.Unlock()
for _, event := range events {
publishEvent(ctx, publisher, event)
}
return err
}
// CleanupTaskCanvases is an explicit alias for task-service wiring.
func (s *Service) CleanupTaskCanvases(ctx context.Context, taskID string) error {
return s.CleanupTask(ctx, taskID)
}
// CleanupWorkspace removes every canvas in a workspace, including archived
// and task-scoped canvases. Each plugin instance removal inventories its
// retained release artifacts before its metadata is deleted.
func (s *Service) CleanupWorkspace(ctx context.Context, workspaceID string) error {
if err := s.ready(); err != nil {
return err
}
if strings.TrimSpace(workspaceID) == "" {
return ErrInvalidCanvas
}
s.mu.Lock()
metadata, err := s.repo.ListByWorkspace(ctx, workspaceID)
if err != nil {
s.mu.Unlock()
return err
}
events, err := s.cleanupMetadataLocked(ctx, metadata)
publisher := s.publisher
s.mu.Unlock()
for _, event := range events {
publishEvent(ctx, publisher, event)
}
return err
}
// CleanupWorkspaceCanvases is an explicit alias for workspace-service wiring.
func (s *Service) CleanupWorkspaceCanvases(ctx context.Context, workspaceID string) error {
return s.CleanupWorkspace(ctx, workspaceID)
}
// Reconcile repairs lifecycle rows left across a process boundary. Canvas
// creation and removal are transactional for the durable store, but this
// pass also handles databases written by an older binary or a crash during
// the compatibility fallback. It never executes a release.
func (s *Service) Reconcile(ctx context.Context) error {
if err := s.ready(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
metadata, err := s.repo.ListAll(ctx)
if err != nil {
return err
}
knownInstances, errs := s.reconcileMetadata(ctx, metadata)
inventory, ok := s.instances.(interface {
ListBySource(context.Context, string, bool) ([]plugininstances.Instance, error)
})
if !ok {
return errors.Join(errs...)
}
instances, err := inventory.ListBySource(ctx, plugininstances.SourceLocalCanvas, true)
if err != nil {
errs = append(errs, fmt.Errorf("list canvas instances: %w", err))
return errors.Join(errs...)
}
errs = append(errs, s.reconcileOrphanInstances(ctx, instances, knownInstances)...)
return errors.Join(errs...)
}
func (s *Service) reconcileMetadata(ctx context.Context, metadata []CanvasMetadata) (map[string]struct{}, []error) {
knownInstances := make(map[string]struct{}, len(metadata))
for _, item := range metadata {
knownInstances[item.PluginInstanceID] = struct{}{}
}
instancesByID, err := s.loadMetadataInstances(ctx, metadata)
if err != nil {
return knownInstances, []error{fmt.Errorf("inspect canvas instances: %w", err)}
}
var errs []error
for _, item := range metadata {
instance, exists := instancesByID[item.PluginInstanceID]
if exists && instance.Status != plugininstances.StatusRemoved {
continue
}
if _, err := s.removeMetadataLocked(ctx, item); err != nil {
errs = append(errs, fmt.Errorf("remove orphan canvas metadata %s: %w", item.ID, err))
}
}
return knownInstances, errs
}
func (s *Service) reconcileOrphanInstances(ctx context.Context, instances []plugininstances.Instance, known map[string]struct{}) []error {
var errs []error
for _, instance := range instances {
if _, exists := known[instance.ID]; exists {
continue
}
if instance.Status != plugininstances.StatusRemoved {
if err := s.instances.RemoveInstance(ctx, instance.ID); err != nil && !errors.Is(err, plugininstances.ErrNotFound) {
errs = append(errs, fmt.Errorf("remove orphan canvas instance %s: %w", instance.ID, err))
continue
}
}
if s.stateCleanup != nil {
if err := s.stateCleanup(ctx, instance.ID); err != nil {
errs = append(errs, fmt.Errorf("remove orphan canvas state %s: %w", instance.ID, err))
}
}
}
return errs
}
func (s *Service) cleanupMetadataLocked(ctx context.Context, metadata []CanvasMetadata) ([]LifecycleEvent, error) {
var errs []error
events := make([]LifecycleEvent, 0, len(metadata))
for _, item := range metadata {
event, err := s.removeMetadataLocked(ctx, item)
if err == nil {
events = append(events, event)
continue
}
if !errors.Is(err, ErrCanvasNotFound) {
errs = append(errs, fmt.Errorf("remove canvas %s: %w", item.ID, err))
}
}
return events, errors.Join(errs...)
}
func (s *Service) load(ctx context.Context, id string) (CanvasMetadata, plugininstances.Instance, error) {
metadata, err := s.repo.Get(ctx, id)
if err != nil {
return CanvasMetadata{}, plugininstances.Instance{}, err
}
instance, err := s.instances.Get(ctx, metadata.PluginInstanceID)
if errors.Is(err, plugininstances.ErrNotFound) {
return CanvasMetadata{}, plugininstances.Instance{}, ErrCanvasNotFound
}
if err != nil {
return CanvasMetadata{}, plugininstances.Instance{}, err
}
return metadata, instance, nil
}
func (s *Service) removeMetadataLocked(ctx context.Context, metadata CanvasMetadata) (LifecycleEvent, error) {
instance, err := s.instances.Get(ctx, metadata.PluginInstanceID)
if errors.Is(err, plugininstances.ErrNotFound) {
// A crash can happen after instance removal commits but before the
// metadata transaction commits. There is no remaining instance
// authority to remove, so clear its state and finish metadata cleanup.
if s.stateCleanup != nil {
if err := s.stateCleanup(ctx, metadata.PluginInstanceID); err != nil {
return LifecycleEvent{}, err
}
}
if err := s.repo.DeleteInstallReceipt(ctx, metadata.ID); err != nil {
return LifecycleEvent{}, err
}
if err := s.repo.Delete(ctx, metadata.ID); err != nil && !errors.Is(err, ErrCanvasNotFound) {
return LifecycleEvent{}, err
}
instance = plugininstances.Instance{
ID: metadata.PluginInstanceID, PluginID: CanvasPluginID,
ScopeKind: ScopeWorkspace, WorkspaceID: metadata.WorkspaceID,
Status: StatusRemoved,
}
canvas := canvasFromMetadataInstance(metadata, instance)
return lifecycleEvent(EventRemoved, canvas), nil
}
if err != nil {
return LifecycleEvent{}, err
}
if err := s.removeAuthority(ctx, metadata, instance); err != nil {
return LifecycleEvent{}, err
}
instance.Status = StatusRemoved
instance.ActiveReleaseID = ""
if s.stateCleanup != nil {
if err := s.stateCleanup(ctx, instance.ID); err != nil {
return LifecycleEvent{}, err
}
}
canvas := canvasFromMetadataInstance(metadata, instance)
return lifecycleEvent(EventRemoved, canvas), nil
}
func (s *Service) listMetadata(
ctx context.Context,
metadata []CanvasMetadata,
includeArchived bool,
allowed func(CanvasMetadata, plugininstances.Instance) bool,
) ([]Canvas, error) {
instancesByID, err := s.loadMetadataInstances(ctx, metadata)
if err != nil {
return nil, err
}
canvases := make([]Canvas, 0, len(metadata))
for _, item := range metadata {
instance, exists := instancesByID[item.PluginInstanceID]
if !exists || instance.Status == StatusRemoved {
continue
}
if !includeArchived && instance.Status == StatusArchived {
continue
}
if allowed != nil && !allowed(item, instance) {
continue
}
canvas, err := s.buildCanvas(ctx, item, instance)
if err != nil {
return nil, err
}
canvases = append(canvases, canvas)
}
sort.SliceStable(canvases, func(i, j int) bool {
if canvases[i].CreatedAt.Equal(canvases[j].CreatedAt) {
return canvases[i].ID < canvases[j].ID
}
return canvases[i].CreatedAt.Before(canvases[j].CreatedAt)
})
return canvases, nil
}
type bulkPluginInstanceStore interface {
GetMany(context.Context, []string) ([]plugininstances.Instance, error)
}
func (s *Service) loadMetadataInstances(ctx context.Context, metadata []CanvasMetadata) (map[string]plugininstances.Instance, error) {
ids := make([]string, 0, len(metadata))
seen := make(map[string]struct{}, len(metadata))
for _, item := range metadata {
if _, exists := seen[item.PluginInstanceID]; exists {
continue
}
seen[item.PluginInstanceID] = struct{}{}
ids = append(ids, item.PluginInstanceID)
}
result := make(map[string]plugininstances.Instance, len(ids))
if bulk, ok := s.instances.(bulkPluginInstanceStore); ok {
instances, err := bulk.GetMany(ctx, ids)
if err != nil {
return nil, err
}
for _, instance := range instances {
result[instance.ID] = instance
}
return result, nil
}
for _, id := range ids {
instance, err := s.instances.Get(ctx, id)
if errors.Is(err, plugininstances.ErrNotFound) {
continue
}
if err != nil {
return nil, err
}
result[instance.ID] = instance
}
return result, nil
}
func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, instance plugininstances.Instance) (Canvas, error) {
if err := validateInstanceBinding(metadata, instance); err != nil {
return Canvas{}, err
}
canvas := canvasFromMetadataInstance(metadata, instance)
grants, err := s.listGrants(ctx, instance.ID)
if err != nil {
return Canvas{}, err
}
if instance.ActiveReleaseID == "" {
return s.addPendingRelease(ctx, &canvas, instance.ID, instance.EffectiveDataScopeKind(), grants)
}
release, err := s.instances.GetRelease(ctx, instance.ActiveReleaseID)
if errors.Is(err, plugininstances.ErrNotFound) {
canvas.ActiveReleaseStatus = ValidationUnavailable
canvas.ActiveReleaseError = "active_release_missing"
canvas.ActiveRelease = &ReleaseMetadata{ID: instance.ActiveReleaseID, ValidationStatus: ValidationUnavailable, ValidationError: canvas.ActiveReleaseError}
return canvas, nil
}
if err != nil {
return Canvas{}, err
}
if release.InstanceID != instance.ID {
return Canvas{}, fmt.Errorf("%w: active release %s belongs to another instance", ErrCanvasMetadataBroken, release.ID)
}
canvas.ActiveReleaseStatus = release.ValidationStatus
canvas.ActiveReleaseError = release.ValidationError
canvas.EffectiveGrants = effectiveGrantProjection(instance, ReleasePermissionSummary(release), grants)
canvas.ActiveRelease = releaseMetadata(release, instance.EffectiveDataScopeKind(), grants)
return canvas, nil
}
type releaseLister interface {
ListReleases(context.Context, string) ([]plugininstances.Release, error)
}
func (s *Service) addPendingRelease(ctx context.Context, canvas *Canvas, instanceID, scope string, grants []plugininstances.Grant) (Canvas, error) {
lister, ok := s.instances.(releaseLister)
if !ok {
return *canvas, nil
}
releases, err := lister.ListReleases(ctx, instanceID)
if err != nil {
return Canvas{}, err
}
for _, release := range releases {
if release.ValidationStatus != ValidationPendingPermission {
continue
}
canvas.PendingRelease = releaseMetadata(release, scope, grants)
break
}
return *canvas, nil
}
type grantLister interface {
ListGrants(context.Context, string) ([]plugininstances.Grant, error)
}
func (s *Service) listGrants(ctx context.Context, instanceID string) ([]plugininstances.Grant, error) {
lister, ok := s.instances.(grantLister)
if !ok {
return nil, nil
}
return lister.ListGrants(ctx, instanceID)
}
func (s *Service) compensateCreate(instanceID string) error {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), 5*time.Second)
defer cancel()
return s.instances.RemoveInstance(cleanupCtx, instanceID)
}
func (s *Service) ready() error {
if s == nil || s.repo == nil || s.instances == nil {
return ErrCanvasNotConfigured
}
return nil
}
func (s *Service) nowUTC() time.Time {
if s.clock == nil {
return time.Now().UTC()
}
return s.clock().UTC()
}
func normalizeCreateRequest(request CreateCanvasRequest) (CreateCanvasRequest, error) {
request.WorkspaceID = strings.TrimSpace(request.WorkspaceID)
request.TaskID = strings.TrimSpace(request.TaskID)
request.OriginTaskID = strings.TrimSpace(request.OriginTaskID)
request.CreatedBySessionID = strings.TrimSpace(request.CreatedBySessionID)
request.OwnerUserID = strings.TrimSpace(request.OwnerUserID)
request.PluginID = strings.TrimSpace(request.PluginID)
request.Title = strings.TrimSpace(request.Title)
if request.PluginID == "" {
request.PluginID = CanvasPluginID
}
if request.WorkspaceID == "" {
return CreateCanvasRequest{}, fmt.Errorf("%w: workspace_id is required", ErrInvalidCanvas)
}
if request.Title == "" || len([]rune(request.Title)) > MaxTitleLength {
return CreateCanvasRequest{}, fmt.Errorf("%w: title must contain 1-%d characters", ErrInvalidCanvas, MaxTitleLength)
}
if request.TaskID != "" && request.OriginTaskID == "" {
request.OriginTaskID = request.TaskID
}
return request, nil
}
func validateInstanceBinding(metadata CanvasMetadata, instance plugininstances.Instance) error {
if metadata.PluginInstanceID != instance.ID || metadata.WorkspaceID != instance.WorkspaceID {
return ErrCanvasMetadataBroken
}
if metadata.TaskID == "" {
if instance.ScopeKind != ScopeWorkspace || instance.TaskID != "" {
return ErrCanvasMetadataBroken
}
return nil
}
if instance.ScopeKind != ScopeTask || instance.TaskID != metadata.TaskID {
return ErrCanvasMetadataBroken
}
return nil
}
func canvasFromMetadataInstance(metadata CanvasMetadata, instance plugininstances.Instance) Canvas {
updatedAt := metadata.UpdatedAt
if instance.UpdatedAt.After(updatedAt) {
updatedAt = instance.UpdatedAt
}
return Canvas{
ID: metadata.ID,
PluginInstanceID: instance.ID,
PluginID: instance.PluginID,
WorkspaceID: metadata.WorkspaceID,
TaskID: metadata.TaskID,
OriginTaskID: metadata.OriginTaskID,
ScopeKind: instance.ScopeKind,
DataScopeKind: instance.EffectiveDataScopeKind(),
Title: metadata.Title,
CreatedBySessionID: metadata.CreatedBySessionID,
PromotedByUserID: metadata.PromotedByUserID,
PromotedAt: metadata.PromotedAt,
Status: instance.Status,
ActiveReleaseID: instance.ActiveReleaseID,
GrantGeneration: instance.GrantGeneration,
CreatedAt: metadata.CreatedAt,
UpdatedAt: updatedAt,
}
}
func isTaskCanvas(_ CanvasMetadata, instance plugininstances.Instance) bool {
return instance.ScopeKind == ScopeTask
}
func isWorkspaceCanvas(metadata CanvasMetadata, instance plugininstances.Instance) bool {
return metadata.TaskID == "" && instance.ScopeKind == ScopeWorkspace
}
func lifecycleEvent(eventType string, canvas Canvas) LifecycleEvent {
return LifecycleEvent{
Type: eventType,
Title: canvas.Title,
UpdatedAt: canvas.UpdatedAt,
CanvasID: canvas.ID,
PluginInstanceID: canvas.PluginInstanceID,
WorkspaceID: canvas.WorkspaceID,
TaskID: canvas.TaskID,
ScopeKind: canvas.ScopeKind,
DataScopeKind: canvas.DataScopeKind,
Status: canvas.Status,
ActiveReleaseID: canvas.ActiveReleaseID,
ActiveReleaseStatus: canvas.ActiveReleaseStatus,
}
}
func publishEvent(ctx context.Context, publisher EventPublisher, event LifecycleEvent) {
if publisher != nil {
publisher(ctx, event)
}
}