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

760 lines
30 KiB
Go

package backendapp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"sync"
"time"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/canvas"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/mcp/canvasskill"
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
"github.com/kandev/kandev/internal/plugins"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/state"
"github.com/kandev/kandev/internal/plugins/webapp"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
userstore "github.com/kandev/kandev/internal/user/store"
"go.uber.org/zap"
)
const (
canvasSourceRootPrefix = ".kandev/canvases"
canvasPublishWindow = 5 * time.Minute
canvasPublishAttempts = 10
canvasSourceCleanupTimeout = 10 * time.Second
canvasSourceActor = "agent"
canvasEditOrigin = "canvas_edit"
canvasErrorCodeDefault = "canvas_error"
canvasErrorCodeInvalid = "invalid_canvas"
canvasInvalidRelease = "invalid_release"
// This key is written only by the authenticated canvas edit endpoint. MCP
// canvas authorization treats it as trusted session state, never as tool
// input or task metadata.
canvasEditSessionTargetMetadataKey = "canvas_edit_target"
)
type canvasEditSessionTarget struct {
Origin string `json:"origin"`
TaskID string `json:"task_id"`
CanvasID string `json:"canvas_id"`
ReleaseID string `json:"release_id"`
}
// canvasAuthoringService is the trusted bridge between the in-session MCP
// handler and the canvas/domain services. It deliberately accepts the
// execution context separately from all user-provided canvas fields.
type canvasAuthoringService struct {
canvases *canvas.Service
plugins *plugins.Service
tasks *taskservice.Service
executions canvasExecutionResolver
home string
log *logger.Logger
mu sync.Mutex
attempts map[string][]time.Time
inflight map[string]bool
}
var _ mcphandlers.CanvasAuthoringService = (*canvasAuthoringService)(nil)
func newCanvasAuthoringService(
canvases *canvas.Service,
pluginsSvc *plugins.Service,
tasks *taskservice.Service,
executions canvasExecutionResolver,
home string,
log *logger.Logger,
) *canvasAuthoringService {
return &canvasAuthoringService{
canvases: canvases, plugins: pluginsSvc, tasks: tasks,
executions: executions, home: home, log: log,
attempts: make(map[string][]time.Time), inflight: make(map[string]bool),
}
}
func (s *canvasAuthoringService) ListCanvases(ctx context.Context, request mcphandlers.CanvasListRequest) (any, error) {
_, task, err := s.resolveExecution(ctx, request.Agent)
if err != nil {
return nil, err
}
if target, editSession, err := s.canvasEditTarget(ctx, task, request.Agent); err != nil {
return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err)
} else if editSession {
item, err := s.authorizedCanvasWithTarget(ctx, task, request.Agent, target, target.CanvasID)
if err != nil {
return nil, err
}
return map[string]any{"canvases": []canvas.Canvas{*item}}, nil
}
items, err := s.canvases.ListForTask(ctx, task.WorkspaceID, task.ID, false)
if err != nil {
return nil, canvasOperationError("list_failed", "canvas list is unavailable", err)
}
return map[string]any{"canvases": items}, nil
}
func (s *canvasAuthoringService) ReadCanvasAuthoringSkill(ctx context.Context, request mcphandlers.CanvasReadSkillRequest) (any, error) {
if _, err := s.resolveExecutionOnly(ctx, request.Agent); err != nil {
return nil, err
}
if err := canvasskill.EnsureMaterialized(s.home); err != nil {
return nil, canvasOperationError("skill_unavailable", "canvas authoring skill is unavailable", err)
}
if request.Path == "" {
bundle, err := canvasCoreBundle(s.home)
if err != nil {
return nil, canvasOperationError("skill_unavailable", "canvas authoring skill is unavailable", err)
}
return bundle, nil
}
content, err := canvasskill.ReadMaterialized(s.home, request.Path)
if err != nil {
return nil, canvasOperationError("skill_path_invalid", "canvas authoring skill path is not available", err)
}
return map[string]any{
"slug": canvasskill.Slug, "version": canvasskill.Version,
"path": request.Path, "content": string(content),
}, nil
}
func (s *canvasAuthoringService) CreateCanvas(ctx context.Context, request mcphandlers.CanvasCreateRequest) (any, error) {
execution, task, err := s.resolveExecution(ctx, request.Agent)
if err != nil {
return nil, err
}
if _, editSession, err := s.canvasEditTarget(ctx, task, request.Agent); err != nil {
return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err)
} else if editSession {
return nil, canvasOperationError("canvas_edit_restricted", "canvas edit sessions can only modify their target canvas", nil)
}
ownerID, _, err := s.workspaceOwner(ctx, task.WorkspaceID)
if err != nil {
return nil, canvasOperationError("workspace_owner_unavailable", "the workspace owner could not be verified", err)
}
created, err := s.canvases.CreateCanvas(ctx, canvas.CreateCanvasRequest{
WorkspaceID: task.WorkspaceID, TaskID: task.ID,
OriginTaskID: task.ID, Title: request.Title,
CreatedBySessionID: taskSessionID(request.Agent),
OwnerUserID: ownerID,
})
if err != nil {
return nil, canvasOperationError(canvasErrorCode(err), "canvas could not be created", err)
}
root := canvasSourceRoot(created.ID)
client := execution.GetAgentCtlClient()
if client == nil {
_ = s.canvases.Remove(ctx, created.ID)
return nil, canvasOperationError("agent_unavailable", "the task agent is not ready", errors.New("agentctl client is unavailable"))
}
if err := canvasskill.EnsureMaterialized(s.home); err != nil {
_ = s.canvases.Remove(ctx, created.ID)
return nil, canvasOperationError("skill_unavailable", "canvas authoring skill is unavailable", err)
}
scaffold, err := canvasScaffoldFiles(created, request.Summary)
if err != nil {
_ = s.canvases.Remove(ctx, created.ID)
return nil, canvasOperationError("skill_unavailable", "canvas scaffold is unavailable", err)
}
// Stage the marker and every scaffold file under an authenticated agentctl
// path, then rename the complete directory into its assigned root. This
// keeps local, Docker, SSH, and other executors consistent and lets every
// failure path remove the complete staged tree.
if err := materializeCanvasScaffoldAtomically(ctx, client, created.ID, scaffold); err != nil {
_ = s.canvases.Remove(ctx, created.ID)
return nil, canvasOperationError("source_unavailable", "canvas scaffold could not be created", err)
}
return canvasCreateResponse(created, root, ownerID, scaffold), nil
}
func canvasCreateResponse(created *canvas.Canvas, root, ownerID string, scaffold []canvasScaffoldFile) map[string]any {
return map[string]any{
"canvas": *created,
"canvas_id": created.ID,
"source_path": root,
"source_root": root,
"owner_id": ownerID,
"initial_permission_policy": map[string]any{
"version": canvas.CreationAuthorityPolicyVersion,
"eligible": ownerID != "" && created.TaskID != "" && created.CreatedBySessionID != "",
"scope": canvas.ScopeTask,
"supported_permission_kinds": []string{"api_read", "api_write", "events", "state", "network"},
"exact_https_origins": true,
},
"skill": map[string]any{
"slug": canvasskill.Slug, "version": canvasskill.Version,
"read_tool": "read_canvas_authoring_skill_kandev",
},
"manifest_scaffold": string(scaffold[0].Content),
"scaffold_inventory": canvasskill.ScaffoldInventory(),
}
}
func canvasCoreBundle(home string) (map[string]any, error) {
files := make([]map[string]string, 0, len(canvasskill.CoreInventory()))
for _, rel := range canvasskill.CoreInventory() {
content, err := canvasskill.ReadMaterialized(home, rel)
if err != nil {
return nil, fmt.Errorf("read canvas core file %q: %w", rel, err)
}
files = append(files, map[string]string{"path": rel, "content": string(content)})
}
if len(files) == 0 {
return nil, errors.New("canvas core bundle is empty")
}
return map[string]any{
"slug": canvasskill.Slug, "version": canvasskill.Version,
"path": "SKILL.md", "content": files[0]["content"],
"inventory": canvasskill.Inventory(),
"core_inventory": canvasskill.CoreInventory(),
"scaffold_inventory": canvasskill.ScaffoldInventory(),
"core": map[string]any{
"inventory": canvasskill.CoreInventory(),
"files": files,
},
}, nil
}
type canvasScaffoldFile struct {
Path string
Content []byte
}
func canvasScaffoldFiles(item *canvas.Canvas, summary string) ([]canvasScaffoldFile, error) {
if item == nil {
return nil, errors.New("canvas is required for scaffold generation")
}
manifest := []byte(canvasManifestScaffold(item, summary))
templates, err := canvasskill.ScaffoldTemplateFiles()
if err != nil {
return nil, err
}
files := make([]canvasScaffoldFile, 0, 1+len(templates))
files = append(files, canvasScaffoldFile{Path: "manifest.yaml", Content: manifest})
for _, template := range templates {
files = append(files, canvasScaffoldFile{Path: template.Path, Content: template.Content})
}
return files, nil
}
func materializeCanvasScaffold(ctx context.Context, client canvasAgentCtlClient, root string, files []canvasScaffoldFile) error {
for _, file := range files {
path := filepath.ToSlash(filepath.Join(root, file.Path))
if _, err := client.CreateFile(ctx, path, ""); err != nil {
return fmt.Errorf("create %s: %w", file.Path, err)
}
content := string(file.Content)
if _, err := client.ApplyFileDiff(ctx, path, canvasScaffoldDiff(path, file.Content), "", "", &content); err != nil {
return fmt.Errorf("write %s: %w", file.Path, err)
}
}
return nil
}
func materializeCanvasScaffoldAtomically(ctx context.Context, client canvasAgentCtlClient, canvasID string, files []canvasScaffoldFile) error {
stagingRoot := canvasStagingRoot(canvasID)
finalRoot := canvasSourceRoot(canvasID)
cleanup := func() {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), canvasSourceCleanupTimeout)
defer cancel()
// Delete both paths. A failed rename can leave the staging tree, while a
// transport error after a successful rename can leave the final tree.
// DeleteFile is authenticated by the agentctl session and safely ignores
// whichever path was never created.
for _, root := range []string{stagingRoot, finalRoot} {
// Cleanup is best effort, but the original operation error remains
// the actionable response for the authoring tool.
_, _ = client.DeleteFile(cleanupCtx, root, "")
}
}
marker := filepath.ToSlash(filepath.Join(stagingRoot, ".canvas-root"))
if _, err := client.CreateFile(ctx, marker, ""); err != nil {
cleanup()
return fmt.Errorf("create canvas marker: %w", err)
}
if err := materializeCanvasScaffold(ctx, client, stagingRoot, files); err != nil {
cleanup()
return err
}
if _, err := client.RenameFile(ctx, stagingRoot, finalRoot, ""); err != nil {
cleanup()
return fmt.Errorf("activate canvas source directory: %w", err)
}
return nil
}
func canvasScaffoldDiff(path string, content []byte) string {
value := string(content)
hasFinalNewline := strings.HasSuffix(value, "\n")
value = strings.TrimSuffix(value, "\n")
lines := []string{}
if value != "" {
lines = strings.Split(value, "\n")
}
var diff strings.Builder
if len(lines) == 0 {
return fmt.Sprintf("--- %s\n+++ %s\n@@ -0,0 +0,0 @@\n", path, path)
}
fmt.Fprintf(&diff, "--- %s\n+++ %s\n@@ -0,0 +1,%d @@\n", path, path, len(lines))
for index, line := range lines {
diff.WriteByte('+')
diff.WriteString(line)
diff.WriteByte('\n')
if index == len(lines)-1 && !hasFinalNewline {
diff.WriteString("\\ No newline at end of file\n")
}
}
return diff.String()
}
func (s *canvasAuthoringService) GetCanvas(ctx context.Context, request mcphandlers.CanvasGetRequest) (any, error) {
_, task, err := s.resolveExecution(ctx, request.Agent)
if err != nil {
return nil, err
}
item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID)
if err != nil {
return nil, err
}
return item, nil
}
func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcphandlers.CanvasPublishRequest) (any, error) {
execution, task, err := s.resolveExecution(ctx, request.Agent)
if err != nil {
return nil, err
}
target, editSession, err := s.canvasEditTarget(ctx, task, request.Agent)
if err != nil {
return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err)
}
item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID)
if err != nil {
return nil, err
}
if err := validateCanvasPublishSource(item, request); err != nil {
return nil, err
}
if err := s.beginPublish(request.Agent.SessionID, item.ID); err != nil {
return nil, err
}
defer s.endPublish(request.Agent.SessionID, item.ID)
expectedAuthority := canvasPublishAuthority(item)
expectedBaseReleaseID := ""
if editSession {
expectedBaseReleaseID = target.ReleaseID
}
return s.publishCanvasSource(ctx, execution, task, item, request, expectedAuthority, expectedBaseReleaseID)
}
func canvasPublishAuthority(item *canvas.Canvas) instances.PublishAuthority {
return instances.PublishAuthority{
InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind,
DataScopeKind: item.DataScopeKind, WorkspaceID: item.WorkspaceID,
TaskID: item.TaskID, Status: item.Status,
ActiveReleaseID: item.ActiveReleaseID, GrantGeneration: item.GrantGeneration,
}
}
func validateCanvasPublishSource(item *canvas.Canvas, request mcphandlers.CanvasPublishRequest) error {
if request.SourcePath != canvasSourceRoot(item.ID) {
return canvasOperationError("invalid_source_root", "source_path must be the assigned canvas directory", nil)
}
return nil
}
func (s *canvasAuthoringService) publishCanvasSource(ctx context.Context, execution *canvasAgentExecution, task *models.Task, item *canvas.Canvas, request mcphandlers.CanvasPublishRequest, expectedAuthority instances.PublishAuthority, expectedBaseReleaseID string) (any, error) {
client := execution.GetAgentCtlClient()
if client == nil {
return nil, canvasOperationError("agent_unavailable", "the task agent is not ready", nil)
}
artifacts := s.plugins.WebArtifacts()
store := s.plugins.Instances()
if artifacts == nil || store == nil {
return nil, canvasOperationError("runtime_unavailable", "canvas release storage is unavailable", nil)
}
ownerID, ownerSynthetic, err := s.workspaceOwner(ctx, task.WorkspaceID)
if err != nil {
return nil, canvasOperationError("workspace_owner_unavailable", "the workspace owner could not be verified", err)
}
// Reserve the maximum expanded artifact size before reading agentctl. The
// exact package size is not known until the bounded stream validates, and
// this reservation prevents concurrent publishers from bypassing the
// workspace and installation budget during transfer.
reservation, err := store.ReserveBytes(ctx, item.WorkspaceID, webapp.MaxExpandedBytes, instances.WorkspaceArtifactLimitBytes, instances.InstallationArtifactLimitBytes)
if err != nil {
return nil, canvasOperationError(canvasErrorCode(err), "canvas release storage limit reached", err)
}
defer func() { _ = store.ReleaseBytes(context.WithoutCancel(ctx), reservation.ID) }()
stream, err := client.StreamCanvasSource(ctx, canvasSourceRoot(item.ID))
if err != nil {
return nil, canvasOperationError("source_unavailable", "canvas source could not be read", err)
}
if stream == nil {
return nil, canvasOperationError("source_unavailable", "canvas source could not be read", errors.New("agentctl returned an empty source stream"))
}
defer func() { _ = stream.Close() }()
pkg, err := readCanvasSourcePackage(stream)
if err != nil {
return nil, err
}
if err := store.ResizeReservation(ctx, reservation.ID, pkg.ExpandedBytes, instances.WorkspaceArtifactLimitBytes, instances.InstallationArtifactLimitBytes); err != nil {
return nil, canvasOperationError(canvasErrorCode(err), "canvas release storage limit reached", err)
}
artifact, created, err := artifacts.PutWithCreated(pkg)
if err != nil {
return nil, canvasOperationError("artifact_unavailable", "canvas release could not be stored", err)
}
releasePersisted := false
defer func() {
if created && !releasePersisted {
_ = artifacts.Remove(artifact)
}
}()
result, err := s.canvases.PublishPackage(ctx, canvas.PublishRequest{
CanvasID: item.ID, Package: pkg, Artifact: artifact, ExpectedAuthority: expectedAuthority, ExpectedBaseReleaseID: expectedBaseReleaseID,
SourceActorKind: canvasSourceActor, SourceUserID: ownerID, SourceUserSynthetic: ownerSynthetic,
SourceTaskID: task.ID, SourceSessionID: request.Agent.SessionID,
})
if result != nil && result.ReleasePersisted {
releasePersisted = true
}
if err != nil {
return nil, canvasOperationError(canvasErrorCode(err), "canvas release could not be published", err)
}
return map[string]any{
"canvas": *result.Canvas,
"release": result.Release,
"activated": result.Activated,
"permission_required": result.PermissionRequired,
}, nil
}
func readCanvasSourcePackage(stream io.Reader) (*webapp.Package, error) {
counted := &canvasSourceCountingReader{reader: io.LimitReader(stream, int64(types.MaxCanvasSourceWireBytes)+1)}
pkg, err := webapp.ValidateTarPackageWithLimits(counted, canvasSourcePackageLimits(), counted.bytes)
if err != nil {
if counted.bytes > int64(types.MaxCanvasSourceWireBytes) {
return nil, canvasOperationError("source_limit_exceeded", "canvas source exceeds the transfer limit", nil)
}
return nil, canvasOperationError("invalid_release", "canvas source failed validation", err)
}
if counted.bytes > int64(types.MaxCanvasSourceWireBytes) {
return nil, canvasOperationError("source_limit_exceeded", "canvas source exceeds the transfer limit", nil)
}
pkg.CompressedBytes = counted.bytes
return pkg, nil
}
func (s *canvasAuthoringService) GetCanvasState(ctx context.Context, request mcphandlers.CanvasGetStateRequest) (any, error) {
_, task, err := s.resolveExecution(ctx, request.Agent)
if err != nil {
return nil, err
}
item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID)
if err != nil {
return nil, err
}
store := s.plugins.InstanceState()
if store == nil {
return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", nil)
}
if request.Key == "" {
entries, err := store.List(ctx, item.PluginInstanceID)
if err != nil {
return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", err)
}
return map[string]any{"canvas_id": item.ID, "entries": entries}, nil
}
entry, found, err := store.Get(ctx, item.PluginInstanceID, request.Key)
if err != nil {
return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", err)
}
if !found {
return nil, canvasOperationError("state_not_found", "canvas state key was not found", nil)
}
return map[string]any{"canvas_id": item.ID, "entry": entry}, nil
}
func (s *canvasAuthoringService) SetCanvasState(ctx context.Context, request mcphandlers.CanvasSetStateRequest) (any, error) {
_, task, err := s.resolveExecution(ctx, request.Agent)
if err != nil {
return nil, err
}
item, err := s.authorizedCanvas(ctx, task, request.Agent, request.CanvasID)
if err != nil {
return nil, err
}
store := s.plugins.InstanceState()
if store == nil {
return nil, canvasOperationError("state_unavailable", "canvas state is unavailable", nil)
}
entry, err := store.Set(ctx, item.PluginInstanceID, request.Key, request.Value, request.ExpectedRevision, "agent")
if err != nil {
var conflict *state.ConflictError
if errors.As(err, &conflict) {
return nil, &mcphandlers.CanvasAuthoringError{Code: "canvas_state_conflict", Message: "canvas state revision is stale", Details: map[string]interface{}{"current_revision": conflict.CurrentRevision}}
}
return nil, canvasOperationError("state_write_failed", "canvas state could not be written", err)
}
return map[string]any{"canvas_id": item.ID, "entry": entry}, nil
}
func (s *canvasAuthoringService) resolveExecution(ctx context.Context, agent mcphandlers.CanvasAgentContext) (*canvasAgentExecution, *models.Task, error) {
execution, err := s.resolveExecutionOnly(ctx, agent)
if err != nil {
return nil, nil, err
}
if s.tasks == nil || agent.TaskID == "" {
return nil, nil, canvasOperationError("execution_invalid", "canvas authoring requires a task execution", nil)
}
task, err := s.tasks.GetTask(ctx, agent.TaskID)
if err != nil || task == nil || task.WorkspaceID == "" {
return nil, nil, canvasOperationError("task_unavailable", "the task execution is no longer available", err)
}
return execution, task, nil
}
func (s *canvasAuthoringService) resolveExecutionOnly(_ context.Context, agent mcphandlers.CanvasAgentContext) (*canvasAgentExecution, error) {
if s == nil || s.executions == nil || agent.ExecutionID == "" || agent.TaskID == "" || agent.SessionID == "" {
return nil, canvasOperationError("execution_invalid", "canvas authoring requires a bound task execution", nil)
}
execution, err := s.executions.ResolveCanvasExecution(agent.ExecutionID)
if err != nil || execution == nil || execution.TaskID != agent.TaskID || execution.SessionID != agent.SessionID {
return nil, canvasOperationError("execution_invalid", "canvas authoring execution is not valid", nil)
}
return execution, nil
}
func (s *canvasAuthoringService) authorizedCanvas(ctx context.Context, task *models.Task, agent mcphandlers.CanvasAgentContext, canvasID string) (*canvas.Canvas, error) {
target, editSession, err := s.canvasEditTarget(ctx, task, agent)
if err != nil {
return nil, canvasOperationError("execution_invalid", "the canvas edit session is no longer valid", err)
}
if editSession {
return s.authorizedCanvasWithTarget(ctx, task, agent, target, canvasID)
}
return s.authorizedTaskCanvas(ctx, task, agent, canvasID)
}
func (s *canvasAuthoringService) authorizedCanvasWithTarget(ctx context.Context, task *models.Task, agent mcphandlers.CanvasAgentContext, target canvasEditSessionTarget, canvasID string) (*canvas.Canvas, error) {
if !canvasEditTargetMatches(target, task.ID, canvasID) {
return nil, canvasOperationError("canvas_not_found", "canvas was not found for this edit session", nil)
}
item, err := s.canvases.GetCanvas(ctx, canvasID)
if err != nil || item == nil || item.WorkspaceID != task.WorkspaceID || item.TaskID != "" || item.ScopeKind != canvas.ScopeWorkspace {
return nil, canvasOperationError("canvas_not_found", "canvas was not found for this edit session", nil)
}
return item, nil
}
func (s *canvasAuthoringService) authorizedTaskCanvas(ctx context.Context, task *models.Task, _ mcphandlers.CanvasAgentContext, canvasID string) (*canvas.Canvas, error) {
if strings.TrimSpace(canvasID) == "" {
return nil, canvasOperationError("canvas_not_found", "canvas was not found", nil)
}
item, err := s.canvases.GetCanvas(ctx, canvasID)
if err != nil || !taskCanvasMatchesTask(item, task) {
return nil, canvasOperationError("canvas_not_found", "canvas was not found for this task", nil)
}
return item, nil
}
func taskCanvasMatchesTask(item *canvas.Canvas, task *models.Task) bool {
return item != nil && task != nil &&
item.WorkspaceID == task.WorkspaceID &&
item.TaskID == task.ID &&
item.ScopeKind == canvas.ScopeTask
}
// canvasEditTarget loads the server-written target binding for an edit
// session. A present but malformed binding remains an edit session and is
// therefore denied by the callers below; it must never fall back to the
// broader task-canvas authorization path.
func (s *canvasAuthoringService) canvasEditTarget(ctx context.Context, task *models.Task, agent mcphandlers.CanvasAgentContext) (canvasEditSessionTarget, bool, error) {
if s.tasks == nil || task == nil || agent.SessionID == "" {
return canvasEditSessionTarget{}, false, errors.New("canvas edit session context is unavailable")
}
session, err := s.tasks.GetTaskSession(ctx, agent.SessionID)
if err != nil {
return canvasEditSessionTarget{}, false, err
}
if session == nil || session.TaskID != task.ID {
return canvasEditSessionTarget{}, false, errors.New("canvas edit session does not belong to task")
}
raw, present := session.Metadata[canvasEditSessionTargetMetadataKey]
if !present {
return canvasEditSessionTarget{}, false, nil
}
target, err := decodeCanvasEditSessionTarget(raw)
if err != nil {
if s.log != nil {
s.log.Warn("malformed canvas edit session target", zap.String("session_id", agent.SessionID), zap.Error(err))
}
return canvasEditSessionTarget{}, true, nil
}
return target, true, nil
}
func decodeCanvasEditSessionTarget(raw interface{}) (canvasEditSessionTarget, error) {
data, err := json.Marshal(raw)
if err != nil {
return canvasEditSessionTarget{}, err
}
var target canvasEditSessionTarget
if err := json.Unmarshal(data, &target); err != nil {
return canvasEditSessionTarget{}, err
}
return target, nil
}
func canvasEditTargetMatches(target canvasEditSessionTarget, taskID, canvasID string) bool {
return target.Origin == canvasEditOrigin &&
target.TaskID != "" && target.TaskID == taskID &&
target.CanvasID != "" && target.CanvasID == canvasID &&
target.ReleaseID != ""
}
func (s *canvasAuthoringService) workspaceOwner(ctx context.Context, workspaceID string) (string, bool, error) {
if s.tasks == nil || strings.TrimSpace(workspaceID) == "" {
return "", false, errors.New("task service is unavailable")
}
workspace, err := s.tasks.GetWorkspace(ctx, workspaceID)
if err != nil {
return "", false, err
}
if workspace != nil && strings.TrimSpace(workspace.OwnerID) != "" {
return strings.TrimSpace(workspace.OwnerID), false, nil
}
identity, ok := authn.IdentityFromContext(ctx)
if ok && identity.Synthetic && strings.TrimSpace(identity.UserID) != "" {
return strings.TrimSpace(identity.UserID), true, nil
}
if !ok {
// Auth-disabled in-session MCP dispatches do not carry the HTTP
// middleware identity. Preserve the single-user owner used by the
// pre-auth workspace contract, while marking the source synthetic so
// the publication transaction still requires an unowned workspace.
return userstore.DefaultUserID, true, nil
}
return "", false, errors.New("workspace has no persisted owner")
}
func (s *canvasAuthoringService) beginPublish(sessionID, canvasID string) error {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if s.inflight[canvasID] {
return canvasOperationError("publish_in_progress", "a canvas publish is already in progress", nil)
}
cutoff := now.Add(-canvasPublishWindow)
window := s.attempts[sessionID][:0]
for _, attempt := range s.attempts[sessionID] {
if attempt.After(cutoff) {
window = append(window, attempt)
}
}
if len(window) >= canvasPublishAttempts {
s.attempts[sessionID] = window
return canvasOperationError("publish_rate_limited", "canvas publish rate limit exceeded", nil)
}
s.attempts[sessionID] = append(window, now)
s.inflight[canvasID] = true
return nil
}
func (s *canvasAuthoringService) endPublish(_ string, canvasID string) {
s.mu.Lock()
delete(s.inflight, canvasID)
s.mu.Unlock()
}
func canvasSourceRoot(canvasID string) string {
return filepath.ToSlash(filepath.Join(canvasSourceRootPrefix, canvasID))
}
func canvasStagingRoot(canvasID string) string {
return filepath.ToSlash(filepath.Join(canvasSourceRootPrefix, ".canvas-"+canvasID+".staging"))
}
func canvasSourcePackageLimits() webapp.Limits {
limits := webapp.DefaultLimits()
limits.MaxCompressedBytes = int64(types.MaxCanvasSourceWireBytes)
limits.MaxExpandedBytes = int64(types.MaxCanvasSourceFileData)
limits.MaxFiles = types.MaxCanvasSourceFiles
return limits
}
func canvasManifestScaffold(item *canvas.Canvas, summary string) string {
compactID := strings.ReplaceAll(item.ID, "-", "")
if len(compactID) > 12 {
compactID = compactID[:12]
}
if compactID == "" {
compactID = "app"
}
id := "canvas-" + compactID
title := strings.TrimSpace(item.Title)
if title == "" {
title = "Canvas"
}
return fmt.Sprintf("id: %q\napi_version: 2\nversion: %q\ndisplay_name: %q\ndescription: %q\nauthor: %q\nui:\n web_apps:\n - key: main\n title: %q\n entry: index.html\n placements:\n - task-canvas\n - workspace-canvas\ncapabilities:\n api_read:\n - tasks\n - workflows\n api_write:\n - messages\n", id, "1.0.0", title, strings.TrimSpace(summary), "Kandev task agent", title)
}
func taskSessionID(agent mcphandlers.CanvasAgentContext) string { return agent.SessionID }
func canvasOperationError(code, message string, cause error) *mcphandlers.CanvasAuthoringError {
if cause != nil && code == "" {
code = canvasErrorCodeDefault
}
if message == "" {
message = "canvas operation failed"
}
return &mcphandlers.CanvasAuthoringError{Code: code, Message: message}
}
func canvasErrorCode(err error) string {
switch {
case errors.Is(err, canvas.ErrTaskCanvasLimit), errors.Is(err, canvas.ErrWorkspaceCanvasLimit):
return "canvas_limit_exceeded"
case errors.Is(err, instances.ErrWorkspaceStorageLimit), errors.Is(err, instances.ErrInstallationStorageLimit):
return "canvas_storage_limit_exceeded"
case errors.Is(err, instances.ErrInvalidRelease):
return canvasInvalidRelease
case errors.Is(err, canvas.ErrStalePromotionReview):
return "promotion_review_stale"
case errors.Is(err, canvas.ErrStaleCanvasEdit):
return "canvas_edit_stale"
case errors.Is(err, canvas.ErrStaleCanvasPublish):
return "canvas_publish_stale"
case errors.Is(err, canvas.ErrInvalidLifecycleState):
return canvasErrorCodeInvalid
case errors.Is(err, canvas.ErrCanvasNotFound):
return "canvas_not_found"
default:
return canvasErrorCodeDefault
}
}
type canvasSourceCountingReader struct {
reader io.Reader
bytes int64
}
func (r *canvasSourceCountingReader) Read(p []byte) (int, error) {
n, err := r.reader.Read(p)
r.bytes += int64(n)
return n, err
}