912 lines
29 KiB
Go
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)
|
|
}
|
|
}
|