feat: preview task canvases with workspace data (#3887)

This commit is contained in:
Carlos Florêncio
2026-09-23 20:51:55 +01:00
committed by GitHub
parent 9874ac4cf5
commit 4bebf7c511
79 changed files with 3369 additions and 282 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ desktop boundaries. Link to an existing UI contract when the feature reuses one.
## Mobile Design Contract
When a task changes composition, navigation, overlays, touch behavior, scrolling, or breakpoint behavior, state these choices in the working plan or task notes. Keep them brief; do not create a separate document unless the task already uses a spec or committed plan. For copy, icon, color, or content-only styling inside an unchanged surface, identify the nearest mobile exemplar and the rendered mobile check instead of forcing the full contract.
When a task changes composition, navigation, overlays, touch behavior, scrolling, or breakpoint behavior, state these choices in the working plan or task notes. Keep them brief; do not create a separate document unless the task already uses a spec or plan. For copy, icon, color, or content-only styling inside an unchanged surface, identify the nearest mobile exemplar and the rendered mobile check instead of forcing the full contract.
- desktop user outcome and mobile entry point
- nearest shipped mobile exemplar and which interaction/geometry it contributes
+6 -1
View File
@@ -1,6 +1,6 @@
---
name: plan
description: Create a committed implementation plan and work orders from approved requirements and current system designs. Use after specification work and before implementation.
description: Create an implementation plan and work orders from approved requirements and current system designs. Leave the design package uncommitted for review before implementation.
---
# Create an Implementation Plan
@@ -259,6 +259,11 @@ review after the PR opens.
### 5. End the design turn
Leave the full design package, including requirements, system designs, ADRs,
plan, and work orders, unstaged and uncommitted so the user can review its diff
in the workspace. Do not stage or commit these files unless the user explicitly
asks. Check `git status --short` and include the changed paths in the handoff.
Report the requirement IDs, system designs, plan, work orders, dependency
order, exact checks, and open risks. For UI changes, also render a compact
ASCII preview inline in the final conversation summary; links alone are not
+9 -9
View File
@@ -97,13 +97,14 @@ apps/backend/
│ └── worktree/ # Git worktree management for workspace isolation
```
Canvas creation authority is recorded only by the trusted task authoring
adapter. It binds the workspace owner, creating session, task scope, and
policy version to a new draft. The first valid static web-app release may
consume that single-use authority for exact task-scoped grants in the same
transaction as release insertion and activation. Existing drafts, imports,
later permission increases, and revoked grants remain on the human review
path. Never use source metadata or a manifest trust field as authority.
Canvas creation authority comes only from the trusted task adapter and binds
the owner, session, task, and policy version. Its first valid static release
may consume that authority for exact workspace-ceiling grants in the activation
transaction. Existing drafts, imports, later permission increases, and
revoked grants still require human review; source metadata and manifests never
grant authority.
Canvas scopes expose `canvas_data_scope_transition_total` with fixed transition
and result labels. Lifecycle events carry IDs; metrics never label task data.
## Key Concepts
@@ -293,8 +294,7 @@ Enforced by `apps/backend/.golangci.yml` (errors on new code only):
When a PR fixup touches backend code, run `golangci-lint run ./... --new-from-rev="<base-sha>" --timeout=5m` from `apps/backend` with the PR base SHA before pushing; CI enforces changed-file complexity thresholds.
- `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `<repoRoot>/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`).
- `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol
- `internal/agentctl/AGENTS.md` — server routes, adapters, ACP; `cmd/mock-agent/AGENTS.md` — E2E scenario patterns and rebuild requirements
- `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping
- `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern)
- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser: the SPA-unavailable error pages and the shared-task artifacts (`share.html`, gist README and description). Both are complete. Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. Use `i18n.Tf` for anything carrying a value — never `fmt.Sprintf` a translated string, and never build a plural in Go. A locale for output that outlives the request is resolved once at write time and threaded as an argument, not a context value (ADR `2026-08-01-share-artifact-locale.md`).
- `cmd/mock-agent/AGENTS.md` — predefined `/e2e:<name>` scenarios vs inline `e2e:...` scripts, recipe for adding a scenario, and the rebuild-before-e2e requirement
+4 -2
View File
@@ -16,8 +16,10 @@ canvas identity and whether its release is active, awaits permission review,
or was unsuccessful.
If publication is unsuccessful, report the failure and do not claim that the
canvas is published. If workspace access requires promotion, explain the user
action that is still required.
canvas is published. A new owner-authorized task canvas can use its declared
supported permissions across the current workspace before promotion. Promotion
adds workspace navigation. Do not claim that promotion is required to preview
workspace task data.
A local build alone does not publish a canvas inside Kandev. If the tools remain
unavailable, report the limitation instead of claiming that workspace files
@@ -351,12 +351,7 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph
return nil, err
}
defer s.endPublish(request.Agent.SessionID, item.ID)
expectedAuthority := instances.PublishAuthority{
InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind,
WorkspaceID: item.WorkspaceID, TaskID: item.TaskID,
Status: item.Status, ActiveReleaseID: item.ActiveReleaseID,
GrantGeneration: item.GrantGeneration,
}
expectedAuthority := canvasPublishAuthority(item)
expectedBaseReleaseID := ""
if editSession {
expectedBaseReleaseID = target.ReleaseID
@@ -364,6 +359,15 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph
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)
@@ -0,0 +1,28 @@
package backendapp
import (
"testing"
"github.com/kandev/kandev/internal/canvas"
"github.com/kandev/kandev/internal/plugins/instances"
)
func TestCanvasPublishAuthorityCapturesPlacementAndDataScope(t *testing.T) {
item := &canvas.Canvas{
PluginInstanceID: "instance-1",
ScopeKind: canvas.ScopeTask,
DataScopeKind: instances.ScopeWorkspace,
WorkspaceID: "workspace-1",
TaskID: "task-1",
Status: canvas.StatusActive,
GrantGeneration: 4,
}
authority := canvasPublishAuthority(item)
if authority.ScopeKind != canvas.ScopeTask || authority.DataScopeKind != instances.ScopeWorkspace {
t.Fatalf("publish authority scopes = placement %q, data %q; want task placement and workspace data", authority.ScopeKind, authority.DataScopeKind)
}
if authority.InstanceID != item.PluginInstanceID || authority.WorkspaceID != item.WorkspaceID || authority.TaskID != item.TaskID || authority.Status != item.Status || authority.GrantGeneration != item.GrantGeneration {
t.Fatalf("publish authority = %+v, missing current canvas state", authority)
}
}
+111 -17
View File
@@ -23,6 +23,7 @@ import (
"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"
"github.com/kandev/kandev/internal/worktree/copyfiles"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -38,6 +39,11 @@ type canvasHTTPHandler struct {
distribution *canvasservice.DistributionService
}
type canvasWorkspaceOwnerAccess interface {
AuthorizeWorkspaceAccess(context.Context, string) error
GetWorkspace(context.Context, string) (*models.Workspace, error)
}
type canvasEditTaskStore interface {
AuthorizeWorkspaceAccess(context.Context, string) error
GetWorkspace(context.Context, string) (*models.Workspace, error)
@@ -370,6 +376,8 @@ func registerCanvasRoutes(p routeParams) {
p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases)
p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview)
p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote)
p.router.GET("/api/v1/canvases/:canvasID/workspace-data-preview", h.workspaceDataPreview)
p.router.POST("/api/v1/canvases/:canvasID/workspace-data", h.enableWorkspaceData)
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease)
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease)
p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback)
@@ -390,6 +398,7 @@ type canvasHTTPResponse struct {
TaskID string `json:"task_id,omitempty"`
OriginTaskID string `json:"origin_task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
Title string `json:"title"`
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
@@ -442,6 +451,7 @@ func canvasResponse(value canvasservice.Canvas) canvasHTTPResponse {
TaskID: value.TaskID,
OriginTaskID: value.OriginTaskID,
ScopeKind: value.ScopeKind,
DataScopeKind: value.DataScopeKind,
Title: value.Title,
CreatedBySessionID: value.CreatedBySessionID,
PromotedByUserID: value.PromotedByUserID,
@@ -633,23 +643,25 @@ func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) {
preview.SourceSessionID,
)
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
"canvas_id": preview.Canvas.ID,
"title": preview.Canvas.Title,
"origin_task_id": preview.Canvas.OriginTaskID,
"active_release": releaseResponseFromCanvas(preview.Canvas),
"source_actor_kind": preview.SourceActorKind,
"source_user_id": preview.SourceUserID,
"source_task_id": preview.SourceTaskID,
"source_task_title": sourceLabels.TaskTitle,
"source_session_id": preview.SourceSessionID,
"source_session_name": sourceLabels.SessionName,
"permissions": preview.Permissions,
"active_release_id": preview.ActiveReleaseID,
"permission_digest": preview.PermissionDigest,
"grant_generation": preview.GrantGeneration,
"current_scope": preview.CurrentScope,
"target_scope": preview.TargetScope,
"placement": preview.Placement,
"canvas_id": preview.Canvas.ID,
"title": preview.Canvas.Title,
"origin_task_id": preview.Canvas.OriginTaskID,
"active_release": releaseResponseFromCanvas(preview.Canvas),
"source_actor_kind": preview.SourceActorKind,
"source_user_id": preview.SourceUserID,
"source_task_id": preview.SourceTaskID,
"source_task_title": sourceLabels.TaskTitle,
"source_session_id": preview.SourceSessionID,
"source_session_name": sourceLabels.SessionName,
"permissions": preview.Permissions,
"active_release_id": preview.ActiveReleaseID,
"permission_digest": preview.PermissionDigest,
"grant_generation": preview.GrantGeneration,
"current_scope": preview.CurrentScope,
"target_scope": preview.TargetScope,
"current_data_scope_kind": preview.CurrentDataScope,
"target_data_scope_kind": preview.TargetDataScope,
"placement": preview.Placement,
})
}
@@ -688,6 +700,56 @@ func (h *canvasHTTPHandler) promote(c *gin.Context) {
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
}
func (h *canvasHTTPHandler) workspaceDataPreview(c *gin.Context) {
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
if err != nil {
h.writeError(c, err)
return
}
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
return
}
preview, err := h.canvases.WorkspaceDataPreview(c.Request.Context(), item.ID)
if err != nil {
h.writeError(c, err)
return
}
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
"canvas": canvasResponse(*preview.Canvas),
"active_release_id": preview.ActiveReleaseID,
"permission_digest": preview.PermissionDigest,
"grant_generation": preview.GrantGeneration,
"current_data_scope_kind": preview.CurrentDataScopeKind,
"target_data_scope_kind": preview.TargetDataScopeKind,
"permissions": preview.Permissions,
})
}
func (h *canvasHTTPHandler) enableWorkspaceData(c *gin.Context) {
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
if err != nil {
h.writeError(c, err)
return
}
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
return
}
var request canvasPromotionRequest
if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 {
writeCanvasError(c, http.StatusBadRequest, "workspace_data_review_required", nil)
return
}
updated, err := h.canvases.EnableWorkspaceDataReviewed(
c.Request.Context(), item.ID, canvasRuntimeUser(c), request.ExpectedReleaseID,
request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration,
)
if err != nil {
h.writeError(c, err)
return
}
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
}
func (h *canvasHTTPHandler) approveRelease(c *gin.Context) {
h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) {
return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID)
@@ -886,6 +948,38 @@ func (h *canvasHTTPHandler) authorizeWorkspaceManage(c *gin.Context, workspaceID
return true
}
func (h *canvasHTTPHandler) authorizeWorkspaceOwner(c *gin.Context, workspaceID string) bool {
owner, err := checkCanvasWorkspaceOwner(c.Request.Context(), h.tasks, workspaceID, canvasRuntimeUser(c))
if err != nil {
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
return false
}
if !owner {
writeCanvasError(c, http.StatusForbidden, "workspace_owner_required", nil)
return false
}
return true
}
func checkCanvasWorkspaceOwner(ctx context.Context, access canvasWorkspaceOwnerAccess, workspaceID, userID string) (bool, error) {
if access == nil {
return false, errors.New("canvas workspace access is unavailable")
}
if err := access.AuthorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return false, err
}
workspace, err := access.GetWorkspace(ctx, workspaceID)
if err != nil {
return false, err
}
if workspace == nil {
return false, errors.New("canvas workspace was not found")
}
actorID := strings.TrimSpace(userID)
ownerID := strings.TrimSpace(workspace.OwnerID)
return actorID != "" && (ownerID == actorID || (ownerID == "" && actorID == userstore.DefaultUserID)), nil
}
func canvasRouteParam(c *gin.Context, names ...string) string {
for _, name := range names {
if value := c.Param(name); value != "" {
@@ -0,0 +1,70 @@
package backendapp
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/task/models"
userstore "github.com/kandev/kandev/internal/user/store"
)
type canvasWorkspaceOwnerAccessStub struct {
workspace *models.Workspace
accessErr error
workspaceErr error
workspaceRead bool
}
func (s *canvasWorkspaceOwnerAccessStub) AuthorizeWorkspaceAccess(context.Context, string) error {
return s.accessErr
}
func (s *canvasWorkspaceOwnerAccessStub) GetWorkspace(context.Context, string) (*models.Workspace, error) {
s.workspaceRead = true
return s.workspace, s.workspaceErr
}
func TestCheckCanvasWorkspaceOwnerRequiresCurrentWorkspaceOwner(t *testing.T) {
tests := []struct {
name string
userID string
workspace *models.Workspace
wantOwner bool
}{
{name: "owner", userID: "owner-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}, wantOwner: true},
{name: "workspace member", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}},
{name: "empty user", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}},
{name: "auth-disabled default user owns an unowned workspace", userID: userstore.DefaultUserID, workspace: &models.Workspace{ID: "workspace-1"}, wantOwner: true},
{name: "ordinary user cannot own an unowned workspace", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
access := &canvasWorkspaceOwnerAccessStub{workspace: tt.workspace}
owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", tt.userID)
if err != nil {
t.Fatalf("checkCanvasWorkspaceOwner() error = %v", err)
}
if owner != tt.wantOwner {
t.Fatalf("owner = %t, want %t", owner, tt.wantOwner)
}
if !access.workspaceRead {
t.Fatal("workspace owner was not checked")
}
})
}
}
func TestCheckCanvasWorkspaceOwnerDoesNotReadWorkspaceAfterAccessDenial(t *testing.T) {
access := &canvasWorkspaceOwnerAccessStub{accessErr: errors.New("workspace access denied")}
owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", "owner-1")
if err == nil {
t.Fatal("checkCanvasWorkspaceOwner() error = nil, want access denial")
}
if owner {
t.Fatal("owner = true after workspace access denial")
}
if access.workspaceRead {
t.Fatal("workspace details were read after access denial")
}
}
@@ -39,12 +39,13 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.
WebAppKey: app.Key,
Placement: placement,
ScopeKind: instance.ScopeKind,
DataScopeKind: instance.EffectiveDataScopeKind(),
WorkspaceID: instance.WorkspaceID,
TaskID: instance.TaskID,
SessionID: instance.SessionID,
RepositoryID: instance.RepositoryID,
GrantGeneration: instance.GrantGeneration,
Permissions: runtimeGrantedPermissions(appManifest, instance.ScopeKind, grants),
Permissions: runtimeGrantedPermissions(appManifest, instance.EffectiveDataScopeKind(), grants),
Artifact: webapp.Artifact{
Digest: release.PackageDigest,
RelativePath: release.ArtifactPath,
@@ -52,7 +53,7 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.
Available: true,
},
Entry: app.Entry,
NetworkOrigins: runtimeNetworkOrigins(app, instance.ScopeKind, grants),
NetworkOrigins: runtimeNetworkOrigins(app, instance.EffectiveDataScopeKind(), grants),
}
runtime := h.plugins.WebRuntime()
if runtime == nil {
@@ -141,6 +142,10 @@ func (h *canvasHTTPHandler) writeError(c *gin.Context, err error) {
status, code = http.StatusBadRequest, canvasErrorCodeInvalid
case errors.Is(err, canvasservice.ErrStalePromotionReview):
status, code = http.StatusConflict, "promotion_review_stale"
case errors.Is(err, canvasservice.ErrStaleWorkspaceDataReview):
status, code = http.StatusConflict, "workspace_data_review_stale"
case errors.Is(err, instances.ErrWorkspaceOwnerRequired):
status, code = http.StatusForbidden, "workspace_owner_required"
case errors.Is(err, canvasservice.ErrStaleCanvasEdit):
status, code = http.StatusConflict, "canvas_edit_stale"
case errors.Is(err, canvasservice.ErrInvalidCanvas), errors.Is(err, canvasservice.ErrInvalidCanvasState):
+130 -10
View File
@@ -74,9 +74,23 @@ type PromotionPreview struct {
GrantGeneration int64 `json:"grant_generation"`
CurrentScope string `json:"current_scope"`
TargetScope string `json:"target_scope"`
CurrentDataScope string `json:"current_data_scope_kind"`
TargetDataScope string `json:"target_data_scope_kind"`
Placement string `json:"placement"`
}
// WorkspaceDataPreview is the owner-facing snapshot for widening a retained
// task canvas's data scope without changing its placement.
type WorkspaceDataPreview struct {
Canvas *Canvas `json:"canvas"`
Permissions PermissionSummary `json:"permissions"`
ActiveReleaseID string `json:"active_release_id"`
PermissionDigest string `json:"permission_digest"`
GrantGeneration int64 `json:"grant_generation"`
CurrentDataScopeKind string `json:"current_data_scope_kind"`
TargetDataScopeKind string `json:"target_data_scope_kind"`
}
// authoringInstanceStore is the release/governance extension implemented by
// the durable plugin instance store. Keeping it optional preserves the small
// lifecycle fake used by the basic canvas service tests.
@@ -106,12 +120,20 @@ type reviewedPromotionStore interface {
PromoteScopeAndGrantsReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error
}
type reviewedWorkspaceDataStore interface {
EnableWorkspaceDataReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error
}
type conditionalReleaseStore interface {
CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error
}
type initialGrantStore interface {
AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, []plugininstances.Grant) error
AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error
}
type initialPublicationDataScopeStore interface {
SetInitialPublicationDataScopeTx(context.Context, *sqlx.Tx, plugininstances.PublishAuthority, string) error
}
type creationAuthorityStore interface {
@@ -294,7 +316,7 @@ func (s *Service) preparePublishedRelease(ctx context.Context, store authoringIn
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
}
activated := permissionsFit(permissions, instance.ScopeKind, grants)
activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants)
if creationAuthority.CanvasID != "" {
activated = true
}
@@ -326,7 +348,7 @@ func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, err
}
activated := permissionsFit(permissions, instance.ScopeKind, grants)
activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants)
release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated)
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, err
@@ -383,7 +405,7 @@ func (s *Service) resolveCreationAuthority(ctx context.Context, request PublishR
if !creationAuthorityEligible(candidate, instance, request, grants, releases) {
return CreationAuthority{}, nil, nil
}
return candidate, grantsForManifest(permissions, candidate.OwnerUserID, ScopeTask), nil
return candidate, grantsForManifest(permissions, candidate.OwnerUserID, creationAuthorityDataScope(candidate)), nil
}
func (s *Service) buildPublishedRelease(request PublishRequest, instanceID string, permissions PermissionSummary, activated bool) (plugininstances.Release, error) {
@@ -422,6 +444,9 @@ func persistPublishedRelease(ctx context.Context, store authoringInstanceStore,
return false, ErrStaleCanvasPublish
}
err := persistAuthorityRelease(ctx, transactional, conditional, authorityStore, canvasID, instanceID, release, activated, expectedAuthority, creationAuthority, initialGrants, ownerUserID, allowUnownedWorkspace, sessionID, taskID)
if err == nil && activated && creationAuthority.CanvasID != "" && creationAuthorityDataScope(creationAuthority) == ScopeWorkspace {
recordCanvasScopeTransition(canvasScopeFirstPublication, canvasScopeEnabled)
}
return err == nil, err
}
if expectedBaseReleaseID != "" {
@@ -460,10 +485,19 @@ func persistAuthorityRelease(ctx context.Context, transactional transactionalAut
if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID {
return ErrStaleCanvasPublish
}
if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, ownerUserID, initialGrants); err != nil {
if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil {
return err
}
if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil {
if creationAuthorityDataScope(creationAuthority) == ScopeWorkspace {
scopeStore, ok := transactional.(initialPublicationDataScopeStore)
if !ok {
return ErrStaleCanvasPublish
}
if err := scopeStore.SetInitialPublicationDataScopeTx(ctx, tx, expectedAuthority, release.ID); err != nil {
return err
}
}
if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, release.ID, ownerUserID, initialGrants); err != nil {
return err
}
}
@@ -568,10 +602,91 @@ func (s *Service) PromotionPreview(ctx context.Context, canvasID string) (*Promo
GrantGeneration: canvas.GrantGeneration,
CurrentScope: ScopeTask,
TargetScope: ScopeWorkspace,
CurrentDataScope: canvas.DataScopeKind,
TargetDataScope: ScopeWorkspace,
Placement: manifest.WebAppPlacementWorkspace,
}, nil
}
// WorkspaceDataPreview describes the active release and exact permissions a
// workspace owner would enable for a retained task canvas.
func (s *Service) WorkspaceDataPreview(ctx context.Context, canvasID string) (*WorkspaceDataPreview, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
canvas, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
if canvas.ScopeKind != ScopeTask || canvas.Status != StatusActive || canvas.ActiveReleaseID == "" ||
canvas.ActiveReleaseStatus != ValidationValid || canvas.DataScopeKind != ScopeTask {
return nil, fmt.Errorf("%w: canvas must have an active task-only release", ErrInvalidCanvasState)
}
release, err := store.GetRelease(ctx, canvas.ActiveReleaseID)
if err != nil {
return nil, err
}
if release.InstanceID != canvas.PluginInstanceID || release.ValidationStatus != ValidationValid {
return nil, plugininstances.ErrInvalidRelease
}
m, err := manifestFromRelease(release)
if err != nil {
return nil, err
}
return &WorkspaceDataPreview{
Canvas: canvas,
Permissions: ManifestPermissions(m),
ActiveReleaseID: release.ID,
PermissionDigest: PermissionDigest(release),
GrantGeneration: canvas.GrantGeneration,
CurrentDataScopeKind: canvas.DataScopeKind,
TargetDataScopeKind: ScopeWorkspace,
}, nil
}
// EnableWorkspaceDataReviewed changes only data scope and declared grants.
// Placement, task ownership, and the active package remain unchanged.
func (s *Service) EnableWorkspaceDataReviewed(ctx context.Context, canvasID, userID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
if strings.TrimSpace(userID) == "" {
return nil, fmt.Errorf("%w: approving user is required", ErrInvalidCanvas)
}
preview, err := s.WorkspaceDataPreview(ctx, canvasID)
if err != nil {
return nil, err
}
grants := grantsForManifest(preview.Permissions, userID, ScopeWorkspace)
transactional, ok := s.instances.(transactionalAuthoringStore)
if !ok {
return nil, ErrStaleWorkspaceDataReview
}
reviewed, ok := s.instances.(reviewedWorkspaceDataStore)
if !ok {
return nil, ErrStaleWorkspaceDataReview
}
if err := transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
return reviewed.EnableWorkspaceDataReviewedTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
}); err != nil {
return nil, err
}
recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled)
updated, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
s.mu.Lock()
publisher := s.publisher
s.mu.Unlock()
publishEvent(ctx, publisher, lifecycleEvent(EventWorkspaceDataEnabled, *updated))
return updated, nil
}
// PromoteCanvas atomically approves the active declaration and changes the
// instance scope with the canvas metadata provenance. The durable store
// transaction keeps both lifecycle authorities consistent after a crash.
@@ -605,6 +720,11 @@ func (s *Service) PromoteCanvasReviewed(ctx context.Context, canvasID, userID, e
if err != nil {
return nil, err
}
result := canvasScopePreserved
if preview.CurrentDataScope != ScopeWorkspace && updated.DataScopeKind == ScopeWorkspace {
result = canvasScopeExpanded
}
recordCanvasScopeTransition(canvasScopePromotion, result)
s.mu.Lock()
publisher := s.publisher
s.mu.Unlock()
@@ -675,7 +795,7 @@ func (s *Service) ApproveRelease(ctx context.Context, canvasID, releaseID, userI
if err != nil {
return nil, err
}
if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.ScopeKind)); err != nil {
if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.DataScopeKind)); err != nil {
return nil, err
}
if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil {
@@ -768,7 +888,7 @@ func (s *Service) RollbackRelease(ctx context.Context, canvasID, releaseID strin
if err != nil {
return nil, err
}
if !permissionsFit(ManifestPermissions(m), instance.ScopeKind, grants) {
if !permissionsFit(ManifestPermissions(m), instance.EffectiveDataScopeKind(), grants) {
return nil, fmt.Errorf("%w: rollback requires permission review", plugininstances.ErrInvalidRelease)
}
if err := store.ActivateRelease(ctx, instance.ID, release.ID); err != nil {
@@ -1012,11 +1132,11 @@ func invalidLocalCanvasManifest() error {
}
func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool {
return authority.PolicyVersion == CreationAuthorityPolicyVersion && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != ""
return creationAuthorityPolicyVersionSupported(authority.PolicyVersion) && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != ""
}
func creationAuthorityInstanceMatches(authority CreationAuthority, instance plugininstances.Instance, grants []plugininstances.Grant, releases []plugininstances.Release) bool {
return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0
return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.EffectiveDataScopeKind() == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0
}
func creationAuthoritySourceMatches(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest) bool {
+104 -7
View File
@@ -85,6 +85,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
if err != nil {
t.Fatalf("get instance: %v", err)
}
if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("new owner-authorized canvas scope = explicit %q/effective %q, want task until its first publication is consumed", instance.DataScopeKind, instance.EffectiveDataScopeKind())
}
pkg := testCanvasPackage("owner-first", []string{"tasks"})
pkg.Manifest.Capabilities.APIWrite = []string{"messages"}
pkg.Manifest.Capabilities.Events = []string{"task.updated"}
@@ -114,6 +117,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
if updated.ActiveReleaseID != result.Release.ID || updated.PluginID != result.Release.PluginID {
t.Fatalf("updated instance = %+v, want active release and package identity", updated)
}
if updated.ScopeKind != ScopeTask || updated.EffectiveDataScopeKind() != ScopeWorkspace {
t.Fatalf("updated scopes = placement %q, effective data %q, want task and workspace", updated.ScopeKind, updated.EffectiveDataScopeKind())
}
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("list initial grants: %v", err)
@@ -136,8 +142,8 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
if grant.PermissionKind == "network" {
key += grant.NetworkOrigin
}
if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeTask || grant.ApprovedBy != "owner-1" {
t.Fatalf("initial grant = %+v, want task-scoped owner grant", grant)
if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeWorkspace || grant.ApprovedBy != "owner-1" {
t.Fatalf("initial grant = %+v, want workspace-scoped owner grant", grant)
}
wantGrants[key] = true
}
@@ -156,6 +162,73 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
}
}
func TestCanvasCreationAuthorityVersionOneRetainsTaskDataCeiling(t *testing.T) {
service, instanceStore, pool := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy task authority",
CreatedBySessionID: "session-1", OwnerUserID: "owner-1",
})
if _, err := pool.Writer().Exec(`UPDATE canvas_creation_authority SET policy_version = 1 WHERE canvas_id = ?`, created.ID); err != nil {
t.Fatalf("set legacy authority version: %v", err)
}
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get instance: %v", err)
}
result, err := service.PublishPackage(context.Background(), PublishRequest{
CanvasID: created.ID, Package: testCanvasPackage("legacy-task-authority", []string{"tasks"}),
Artifact: webapp.Artifact{Digest: "legacy-task-authority", RelativePath: "releases/legacy-task-authority", Bytes: 1},
ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1",
SourceTaskID: "task-1", SourceSessionID: "session-1",
})
if err != nil {
t.Fatalf("publish with legacy authority: %v", err)
}
if !result.Activated {
t.Fatalf("legacy authority result = %+v, want active task-scoped first release", result)
}
updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get published instance: %v", err)
}
if updated.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("legacy authority effective data scope = %q, want task", updated.EffectiveDataScopeKind())
}
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("list legacy initial grants: %v", err)
}
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask {
t.Fatalf("legacy authority grants = %+v, want one task-scoped task-read grant", grants)
}
}
func TestPublishCanvasAuthorityNormalizesLegacyNullDataScope(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy data scope"})
canvas, err := service.Get(context.Background(), created.ID)
if err != nil {
t.Fatalf("get canvas: %v", err)
}
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get instance: %v", err)
}
expected := instance.PublishAuthority()
expected.DataScopeKind = canvas.DataScopeKind
result, err := service.PublishPackage(context.Background(), PublishRequest{
CanvasID: created.ID, Package: testCanvasPackage("legacy-null-data-scope", []string{"tasks"}),
Artifact: webapp.Artifact{Digest: "legacy-null-data-scope", RelativePath: "releases/legacy-null-data-scope", Bytes: 1},
ExpectedAuthority: expected, SourceActorKind: "agent",
})
if err != nil {
t.Fatalf("publish with projected legacy data scope: %v", err)
}
if result.Activated || !result.PermissionRequired {
t.Fatalf("legacy task-scope publication = %+v, want task permission review", result)
}
}
func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
@@ -185,12 +258,26 @@ func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) {
if result.Activated || !result.PermissionRequired {
t.Fatalf("mismatched owner result = %+v, want manual review", result)
}
updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get mismatched-owner instance: %v", err)
}
if updated.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("mismatched-owner effective data scope = %q, want task", updated.EffectiveDataScopeKind())
}
approved, err := service.ApproveRelease(context.Background(), created.ID, result.Release.ID, "member-1")
if err != nil {
t.Fatalf("member approves task-scoped fallback release: %v", err)
}
if approved.DataScopeKind != ScopeTask {
t.Fatalf("member approval widened effective data scope to %q, want task", approved.DataScopeKind)
}
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("list mismatched-owner grants: %v", err)
t.Fatalf("list fallback grants: %v", err)
}
if len(grants) != 0 {
t.Fatalf("mismatched-owner grants = %+v, want none", grants)
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask {
t.Fatalf("member approval grants = %+v, want one task-scoped grant", grants)
}
authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID)
if err != nil {
@@ -252,12 +339,22 @@ func TestCanvasCreationAuthorityDoesNotApproveLaterIncrease(t *testing.T) {
if second.Activated || !second.PermissionRequired {
t.Fatalf("later owner release = %+v, want pending review", second)
}
if _, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "member-1"); !errors.Is(err, plugininstances.ErrWorkspaceOwnerRequired) {
t.Fatalf("non-owner approval of workspace-data increase = %v, want ErrWorkspaceOwnerRequired", err)
}
approved, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "owner-1")
if err != nil {
t.Fatalf("owner approves workspace-data increase: %v", err)
}
if approved.ActiveReleaseID != second.Release.ID {
t.Fatalf("active release after owner approval = %q, want %q", approved.ActiveReleaseID, second.Release.ID)
}
current, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get current instance: %v", err)
}
if current.ActiveReleaseID != first.Release.ID {
t.Fatalf("active release after later increase = %q, want %q", current.ActiveReleaseID, first.Release.ID)
if current.ActiveReleaseID != second.Release.ID {
t.Fatalf("active release after owner-approved increase = %q, want %q", current.ActiveReleaseID, second.Release.ID)
}
}
@@ -82,7 +82,7 @@ func effectiveGrantProjection(instance plugininstances.Instance, summary Permiss
declared := permissionKeys(summary)
result := make([]GrantProjection, 0, len(grants))
for _, grant := range grants {
if !grantScopeCovers(grant.ScopeCeiling, instance.ScopeKind) {
if !grantScopeCovers(grant.ScopeCeiling, instance.EffectiveDataScopeKind()) {
continue
}
permission := grant.PermissionKind + ":" + grant.Resource
+2 -2
View File
@@ -304,7 +304,7 @@ WHERE a.canvas_id = ?`,
// while rechecking its owner, session, task, policy, task scope, and current
// workspace ownership.
func (r *Repository) ConsumeCreationAuthorityTx(ctx context.Context, tx *sqlx.Tx, authority CreationAuthority, ownerUserID, sessionID, taskID string, allowUnownedWorkspace bool) error {
if authority.CanvasID == "" || ownerUserID == "" || sessionID == "" || taskID == "" {
if authority.CanvasID == "" || !creationAuthorityPolicyVersionSupported(authority.PolicyVersion) || ownerUserID == "" || sessionID == "" || taskID == "" {
return ErrStaleCanvasPublish
}
now := time.Now().UTC().Format(time.RFC3339Nano)
@@ -321,7 +321,7 @@ WHERE canvas_creation_authority.canvas_id = ? AND owner_user_id = ? AND creating
AND m.task_id = ?
AND (w.owner_id = ? OR (? AND COALESCE(w.owner_id, '') = ''))
)`,
), now, authority.CanvasID, ownerUserID, sessionID, CreationAuthorityPolicyVersion, taskID, ownerUserID, allowUnownedWorkspace)
), now, authority.CanvasID, ownerUserID, sessionID, authority.PolicyVersion, taskID, ownerUserID, allowUnownedWorkspace)
if err != nil {
return err
}
@@ -0,0 +1,24 @@
package canvas
import "expvar"
var canvasDataScopeTransitions = expvar.NewMap("canvas_data_scope_transition_total")
type canvasScopeTransition string
type canvasScopeTransitionResult string
const (
canvasScopeFirstPublication canvasScopeTransition = "first_publication"
canvasScopeReviewedUpgrade canvasScopeTransition = "reviewed_upgrade"
canvasScopePromotion canvasScopeTransition = "promotion"
)
const (
canvasScopeEnabled canvasScopeTransitionResult = "data_scope_enabled"
canvasScopeExpanded canvasScopeTransitionResult = "data_scope_expanded"
canvasScopePreserved canvasScopeTransitionResult = "data_scope_preserved"
)
func recordCanvasScopeTransition(transition canvasScopeTransition, result canvasScopeTransitionResult) {
canvasDataScopeTransitions.Add("transition="+string(transition)+";result="+string(result), 1)
}
@@ -0,0 +1,42 @@
package canvas
import (
"expvar"
"strconv"
"testing"
)
func canvasScopeMetricCount(t *testing.T, label string) int64 {
t.Helper()
value := expvar.Get("canvas_data_scope_transition_total")
if value == nil {
t.Fatal("canvas data-scope transition counter is not registered")
}
metrics, ok := value.(*expvar.Map)
if !ok {
t.Fatalf("canvas data-scope transition metric has type %T, want *expvar.Map", value)
}
var count int64
metrics.Do(func(keyValue expvar.KeyValue) {
if keyValue.Key != label {
return
}
parsed, err := strconv.ParseInt(keyValue.Value.String(), 10, 64)
if err != nil {
t.Errorf("canvas data-scope metric %q value = %q: %v", label, keyValue.Value.String(), err)
return
}
count = parsed
})
return count
}
func TestRecordCanvasScopeTransitionUsesFixedLabels(t *testing.T) {
label := "transition=reviewed_upgrade;result=data_scope_enabled"
before := canvasScopeMetricCount(t, label)
recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled)
after := canvasScopeMetricCount(t, label)
if after-before != 1 {
t.Fatalf("transition counter delta = %d, want 1", after-before)
}
}
+4 -2
View File
@@ -737,7 +737,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst
return Canvas{}, err
}
if instance.ActiveReleaseID == "" {
return s.addPendingRelease(ctx, &canvas, instance.ID, instance.ScopeKind, grants)
return s.addPendingRelease(ctx, &canvas, instance.ID, instance.EffectiveDataScopeKind(), grants)
}
release, err := s.instances.GetRelease(ctx, instance.ActiveReleaseID)
if errors.Is(err, plugininstances.ErrNotFound) {
@@ -755,7 +755,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst
canvas.ActiveReleaseStatus = release.ValidationStatus
canvas.ActiveReleaseError = release.ValidationError
canvas.EffectiveGrants = effectiveGrantProjection(instance, ReleasePermissionSummary(release), grants)
canvas.ActiveRelease = releaseMetadata(release, instance.ScopeKind, grants)
canvas.ActiveRelease = releaseMetadata(release, instance.EffectiveDataScopeKind(), grants)
return canvas, nil
}
@@ -866,6 +866,7 @@ func canvasFromMetadataInstance(metadata CanvasMetadata, instance plugininstance
TaskID: metadata.TaskID,
OriginTaskID: metadata.OriginTaskID,
ScopeKind: instance.ScopeKind,
DataScopeKind: instance.EffectiveDataScopeKind(),
Title: metadata.Title,
CreatedBySessionID: metadata.CreatedBySessionID,
PromotedByUserID: metadata.PromotedByUserID,
@@ -896,6 +897,7 @@ func lifecycleEvent(eventType string, canvas Canvas) LifecycleEvent {
WorkspaceID: canvas.WorkspaceID,
TaskID: canvas.TaskID,
ScopeKind: canvas.ScopeKind,
DataScopeKind: canvas.DataScopeKind,
Status: canvas.Status,
ActiveReleaseID: canvas.ActiveReleaseID,
ActiveReleaseStatus: canvas.ActiveReleaseStatus,
+28 -12
View File
@@ -16,14 +16,26 @@ import (
const (
// CanvasPluginID identifies the synthetic plugin binding used by local
// canvas instances. The package identity remains in the release metadata.
CanvasPluginID = "kandev.canvas"
MaxTitleLength = 200
CreationAuthorityPolicyVersion = 1
CanvasPluginID = "kandev.canvas"
MaxTitleLength = 200
creationAuthorityTaskPolicyVersion = 1
CreationAuthorityPolicyVersion = 2
MaxTaskCanvases = plugininstances.MaxTaskInstances
MaxWorkspaceCanvases = plugininstances.MaxWorkspaceInstances
)
func creationAuthorityPolicyVersionSupported(version int) bool {
return version == creationAuthorityTaskPolicyVersion || version == CreationAuthorityPolicyVersion
}
func creationAuthorityDataScope(authority CreationAuthority) string {
if authority.PolicyVersion == CreationAuthorityPolicyVersion {
return ScopeWorkspace
}
return ScopeTask
}
const (
ScopeTask = plugininstances.ScopeTask
ScopeWorkspace = plugininstances.ScopeWorkspace
@@ -55,15 +67,16 @@ const (
)
var (
ErrCanvasNotFound = errors.New("canvas not found")
ErrInvalidCanvas = errors.New("invalid canvas")
ErrInvalidCanvasState = errors.New("invalid canvas state")
ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance")
ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured")
ErrStalePromotionReview = plugininstances.ErrStalePromotionReview
ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit
ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish
ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState
ErrCanvasNotFound = errors.New("canvas not found")
ErrInvalidCanvas = errors.New("invalid canvas")
ErrInvalidCanvasState = errors.New("invalid canvas state")
ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance")
ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured")
ErrStalePromotionReview = plugininstances.ErrStalePromotionReview
ErrStaleWorkspaceDataReview = plugininstances.ErrStaleWorkspaceDataReview
ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit
ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish
ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState
// These aliases preserve the stable plugin admission errors while keeping
// the canvas package convenient for service callers and API adapters.
@@ -109,6 +122,7 @@ type Canvas struct {
TaskID string `json:"task_id,omitempty"`
OriginTaskID string `json:"origin_task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
Title string `json:"title"`
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
@@ -215,6 +229,7 @@ type LifecycleEvent struct {
WorkspaceID string `json:"workspace_id"`
TaskID string `json:"task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
Status string `json:"status"`
ActiveReleaseID string `json:"active_release_id,omitempty"`
ActiveReleaseStatus string `json:"active_release_status,omitempty"`
@@ -226,6 +241,7 @@ const (
EventReleaseActivated = "canvas.release.activated"
EventReleasePermissionRequired = "canvas.release.permission_required"
EventPromoted = "canvas.promoted"
EventWorkspaceDataEnabled = "canvas.workspace_data_enabled"
EventArchived = "canvas.archived"
EventRestored = "canvas.restored"
EventRemoved = "canvas.removed"
@@ -0,0 +1,143 @@
package canvas
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/webapp"
)
func TestFirstPublicationRecordsWorkspaceDataTransition(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Owner-authorized canvas",
CreatedBySessionID: "session-1", OwnerUserID: "owner-1",
})
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get instance: %v", err)
}
metric := "transition=first_publication;result=data_scope_enabled"
before := canvasScopeMetricCount(t, metric)
result, err := service.PublishPackage(context.Background(), PublishRequest{
CanvasID: created.ID,
Package: testCanvasPackage("workspace-metric", []string{"tasks"}),
Artifact: webapp.Artifact{Digest: "workspace-metric", RelativePath: "releases/workspace-metric", Bytes: 1},
ExpectedAuthority: instance.PublishAuthority(),
SourceActorKind: "agent",
SourceUserID: "owner-1",
SourceTaskID: "task-1",
SourceSessionID: "session-1",
})
if err != nil {
t.Fatalf("publish owner-authorized canvas: %v", err)
}
if !result.Activated {
t.Fatalf("publish result = %+v, want activated first release", result)
}
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
t.Fatalf("first-publication scope metric delta = %d, want 1", after-before)
}
}
func TestWorkspaceDataUpgradePreservesPlacementAndRequiresFreshOwnerReview(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
canvas := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy canvas",
})
published := publishTestPackage(t, service, canvas.ID, "legacy-workspace-review", []string{"tasks"})
if _, err := service.ApproveRelease(context.Background(), canvas.ID, published.Release.ID, "owner-1"); err != nil {
t.Fatalf("approve legacy task release: %v", err)
}
preview, err := service.WorkspaceDataPreview(context.Background(), canvas.ID)
if err != nil {
t.Fatalf("workspace data preview: %v", err)
}
if preview.CurrentDataScopeKind != instances.ScopeTask || preview.TargetDataScopeKind != instances.ScopeWorkspace ||
preview.ActiveReleaseID != published.Release.ID || preview.Permissions.Reads[0] != "tasks" {
t.Fatalf("workspace data preview = %+v, want task-to-workspace review of the active task read", preview)
}
if err := instanceStore.AddGrant(context.Background(), instances.Grant{
InstanceID: canvas.PluginInstanceID, PermissionKind: "api_read", Resource: "tasks",
ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1",
}); err != nil {
t.Fatalf("advance grant generation: %v", err)
}
if err := instanceStore.AddGrant(context.Background(), instances.Grant{
InstanceID: canvas.PluginInstanceID, PermissionKind: "api_write", Resource: "messages",
ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1",
}); err != nil {
t.Fatalf("seed stale task-ceiling grant: %v", err)
}
if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrStaleWorkspaceDataReview) {
t.Fatalf("stale workspace data confirmation = %v, want ErrStaleWorkspaceDataReview", err)
}
unchanged, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID)
if err != nil {
t.Fatalf("get unchanged instance: %v", err)
}
if unchanged.ScopeKind != ScopeTask || unchanged.EffectiveDataScopeKind() != instances.ScopeTask {
t.Fatalf("stale review changed scopes to placement %q/data %q", unchanged.ScopeKind, unchanged.EffectiveDataScopeKind())
}
preview, err = service.WorkspaceDataPreview(context.Background(), canvas.ID)
if err != nil {
t.Fatalf("refresh workspace data preview: %v", err)
}
if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-2", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrWorkspaceOwnerRequired) {
t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err)
}
metric := "transition=reviewed_upgrade;result=data_scope_enabled"
before := canvasScopeMetricCount(t, metric)
upgraded, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration)
if err != nil {
t.Fatalf("owner enables workspace data: %v", err)
}
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
t.Fatalf("reviewed-upgrade scope metric delta = %d, want 1", after-before)
}
if upgraded.ScopeKind != ScopeTask || upgraded.DataScopeKind != instances.ScopeWorkspace ||
upgraded.TaskID != "task-1" || upgraded.ActiveReleaseID != published.Release.ID || upgraded.PromotedAt != nil {
t.Fatalf("upgraded canvas = %+v, want unchanged task placement and active release", upgraded)
}
grants, err := instanceStore.ListGrants(context.Background(), canvas.PluginInstanceID)
if err != nil {
t.Fatalf("list upgraded grants: %v", err)
}
if len(grants) != 1 || grants[0].PermissionKind != "api_read" || grants[0].Resource != "tasks" || grants[0].ScopeCeiling != instances.ScopeWorkspace || grants[0].ApprovedBy != "owner-1" {
t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants)
}
}
func TestPromotionRecordsExpandedDataScope(t *testing.T) {
service, _, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Promoted canvas",
})
published := publishTestPackage(t, service, created.ID, "promotion-scope-metric", []string{"tasks"})
if _, err := service.ApproveRelease(context.Background(), created.ID, published.Release.ID, "owner-1"); err != nil {
t.Fatalf("approve task-scoped release: %v", err)
}
preview, err := service.PromotionPreview(context.Background(), created.ID)
if err != nil {
t.Fatalf("promotion preview: %v", err)
}
metric := "transition=promotion;result=data_scope_expanded"
before := canvasScopeMetricCount(t, metric)
promoted, err := service.PromoteCanvasReviewed(
context.Background(), created.ID, "owner-1", preview.ActiveReleaseID,
preview.PermissionDigest, preview.GrantGeneration,
)
if err != nil {
t.Fatalf("promote task-scoped canvas: %v", err)
}
if promoted.ScopeKind != ScopeWorkspace || promoted.DataScopeKind != instances.ScopeWorkspace {
t.Fatalf("promoted scopes = placement %q, data %q, want workspace/workspace", promoted.ScopeKind, promoted.DataScopeKind)
}
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
t.Fatalf("promotion scope metric delta = %d, want 1", after-before)
}
}
+1
View File
@@ -41,6 +41,7 @@ const (
CanvasReleaseActivated = "canvas.release.activated"
CanvasReleasePermissionRequired = "canvas.release.permission_required"
CanvasPromoted = "canvas.promoted"
CanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled"
CanvasArchived = "canvas.archived"
CanvasRestored = "canvas.restored"
CanvasRemoved = "canvas.removed"
@@ -44,6 +44,7 @@ func RegisterCanvasNotifications(ctx context.Context, eventBus bus.EventBus, hub
{events.CanvasReleaseActivated, ws.ActionCanvasReleaseActivated},
{events.CanvasReleasePermissionRequired, ws.ActionCanvasReleasePermissionRequired},
{events.CanvasPromoted, ws.ActionCanvasPromoted},
{events.CanvasWorkspaceDataEnabled, ws.ActionCanvasWorkspaceDataEnabled},
{events.CanvasArchived, ws.ActionCanvasArchived},
{events.CanvasRestored, ws.ActionCanvasRestored},
{events.CanvasRemoved, ws.ActionCanvasRemoved},
@@ -27,12 +27,13 @@ core bundle again during the same authoring task.
ID and source path. Read validation diagnostics and correct rejected source
before publishing again.
The first valid release of a new owner-created task canvas uses the returned
The first valid release of a new owner-authorized task canvas uses the returned
initial permission policy. It can activate without a second approval for its
declared supported task-scoped data, event, state, and exact HTTPS-origin
permissions. A later permission increase, imported package, or workspace
promotion still requires human review. Do not add a trust flag to the
manifest, and do not request permissions outside the policy.
declared supported permissions. Kandev data access is limited to the current
workspace, while the canvas remains placed in its creating task. Imported
packages and later permission increases need human review. Promotion changes
workspace navigation. Do not add a trust flag to the manifest, and do not
request permissions outside the policy.
## Core application contract
@@ -66,7 +67,8 @@ Use the returned `manifest_scaffold` as the starting point. New manifests use
least one `task-canvas` or `workspace-canvas` placement. Declare only the
`api_read`, `api_write`, `events`, `state`, and `network_origins` permissions
that the application needs. The owner-authorized first release can receive
only these supported task-scoped grants. The entry and all relative assets
only its declared supported permissions, with Kandev data limited to the
current workspace. The entry and all relative assets
must be in the published package.
## Browser protocol summary
@@ -24,6 +24,7 @@ show a retry state for transient reads.
"web_app_key": "main",
"placement": "task-canvas",
"scope_kind": "task",
"data_scope_kind": "workspace",
"workspace_id": "workspace-id",
"task_id": "task-id",
"session_id": "session-id",
@@ -34,7 +35,9 @@ show a retry state for transient reads.
Scope identifiers are omitted when they do not apply. `capabilities` contains
the effective, approved permission keys. It is not a replacement for handling
permission errors from later requests.
permission errors from later requests. `scope_kind` describes canvas placement
and lifecycle. `data_scope_kind` describes the Kandev data boundary. A task
canvas can have `scope_kind: "task"` and `data_scope_kind: "workspace"`.
## Data routes
@@ -51,9 +54,11 @@ All data responses use JSON. Collection responses use this envelope:
and accepts any value from 1 to 200. Unlike the gRPC Host API, this surface
does not clamp an out-of-range `limit` into that window: a supplied value
outside 1..200 is rejected outright with HTTP 400 `invalid_request`, and only
an omitted `limit` falls back to the 50-row default. A task-scoped canvas is
restricted to its task. A workspace-scoped canvas is restricted to its
workspace.
an omitted `limit` falls back to the 50-row default. Task data scope returns
only the bound task. Workspace data scope returns tasks from the instance's
trusted workspace, including when the canvas is still placed in one task. A
`workspace_id` filter must match that workspace; another workspace is denied.
Follow `page_info.next_cursor` until it is omitted to load every page.
| Method | Route | Permission | Use |
| --- | --- | --- | --- |
@@ -67,10 +67,11 @@ the old direct requests cannot continue under the old binding.
Capabilities are declarations, not grants. Use `api_read` for `tasks` and
`workflows`, `api_write` for `tasks` and `messages`, `events` for event
subscriptions, and `state: true` for instance state. The owner-authorized
first release can receive only these supported task-scoped grants. The
operator reviews a later permission increase, an imported package, or a
workspace promotion. Request only the capabilities used by the application.
subscriptions, and `state: true` for instance state. The first owner-authorized
release can receive only its declared supported permissions, with Kandev data
limited to the current workspace. The canvas remains placed in its creating
task until promotion. Imported packages and later permission increases need
human review. Request only the capabilities used by the application.
The package must include the declared entry document and every local asset it
references. Bundle executable dependencies. A build tool, package manager, or
+209 -34
View File
@@ -21,6 +21,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
userstore "github.com/kandev/kandev/internal/user/store"
)
const (
@@ -66,6 +67,8 @@ var (
ErrInstallationStorageLimit = errors.New("canvas installation storage limit exceeded")
ErrInvalidRelease = errors.New("invalid plugin release")
ErrStalePromotionReview = errors.New("canvas promotion review is stale")
ErrStaleWorkspaceDataReview = errors.New("canvas workspace data review is stale")
ErrWorkspaceOwnerRequired = errors.New("current workspace owner is required")
ErrStaleCanvasEdit = errors.New("canvas edit base release is stale")
ErrStaleCanvasPublish = errors.New("canvas publish authority is stale")
ErrInvalidLifecycleState = errors.New("plugin instance lifecycle state does not allow this operation")
@@ -100,6 +103,16 @@ func ValidateScope(kind string, ids ScopeIdentifiers) error {
return nil
}
func validateDataScope(sourceKind, placementScope, dataScope string) error {
if dataScope == "" || dataScope == placementScope {
return nil
}
if sourceKind == SourceLocalCanvas && placementScope == ScopeTask && dataScope == ScopeWorkspace {
return nil
}
return ErrInvalidScope
}
func scopeIdentifiersFor(kind string) map[string]bool {
switch kind {
case ScopeInstance:
@@ -122,6 +135,7 @@ type Instance struct {
PluginID string
SourceKind string
ScopeKind string
DataScopeKind string
WorkspaceID string
TaskID string
SessionID string
@@ -133,6 +147,21 @@ type Instance struct {
UpdatedAt time.Time
}
// EffectiveDataScopeKind preserves the placement-bound behavior of stored
// instances whose nullable data scope predates the independent scope field.
func (i Instance) EffectiveDataScopeKind() string {
return EffectiveDataScopeKind(i.ScopeKind, i.DataScopeKind)
}
// EffectiveDataScopeKind resolves legacy rows and runtime bindings that do
// not carry a separate data scope.
func EffectiveDataScopeKind(placementScope, dataScope string) string {
if dataScope == "" {
return placementScope
}
return dataScope
}
// PublishAuthority is the durable identity captured when a trusted authoring
// request is authorized. A publish must match every field in this snapshot at
// the release insertion boundary so a scope transition, archive, or competing
@@ -141,6 +170,7 @@ type Instance struct {
type PublishAuthority struct {
InstanceID string
ScopeKind string
DataScopeKind string
WorkspaceID string
TaskID string
SessionID string
@@ -152,14 +182,14 @@ type PublishAuthority struct {
func (i Instance) PublishAuthority() PublishAuthority {
return PublishAuthority{
InstanceID: i.ID, ScopeKind: i.ScopeKind, WorkspaceID: i.WorkspaceID,
InstanceID: i.ID, ScopeKind: i.ScopeKind, DataScopeKind: i.EffectiveDataScopeKind(), WorkspaceID: i.WorkspaceID,
TaskID: i.TaskID, SessionID: i.SessionID, RepositoryID: i.RepositoryID,
Status: i.Status, ActiveReleaseID: i.ActiveReleaseID, GrantGeneration: i.GrantGeneration,
}
}
func (a PublishAuthority) IsZero() bool {
return a.InstanceID == "" && a.ScopeKind == "" && a.WorkspaceID == "" &&
return a.InstanceID == "" && a.ScopeKind == "" && a.DataScopeKind == "" && a.WorkspaceID == "" &&
a.TaskID == "" && a.SessionID == "" && a.RepositoryID == "" &&
a.Status == "" && a.ActiveReleaseID == "" && a.GrantGeneration == 0
}
@@ -247,6 +277,7 @@ CREATE TABLE IF NOT EXISTS plugin_instances (
plugin_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
scope_kind TEXT NOT NULL,
data_scope_kind TEXT,
workspace_id TEXT NOT NULL DEFAULT '',
task_id TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
@@ -321,6 +352,9 @@ func (s *Store) initSchema() error {
return err
}
}
if _, err := s.db.Exec(`ALTER TABLE plugin_instances ADD COLUMN data_scope_kind TEXT`); err != nil && !db.IsDuplicateColumnError(err) {
return err
}
return nil
}
@@ -358,6 +392,9 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er
if err := ValidateScope(instance.ScopeKind, ScopeIdentifiers{WorkspaceID: instance.WorkspaceID, TaskID: instance.TaskID, SessionID: instance.SessionID, RepositoryID: instance.RepositoryID}); err != nil {
return err
}
if err := validateDataScope(instance.SourceKind, instance.ScopeKind, instance.DataScopeKind); err != nil {
return err
}
if err := s.checkAdmission(ctx, tx, instance.ScopeKind, instance.WorkspaceID, instance.TaskID); err != nil {
return err
}
@@ -370,8 +407,8 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er
updatedAt = createdAt
}
_, err := tx.ExecContext(ctx, tx.Rebind(`
INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano))
INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at)
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.DataScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano))
if err != nil {
return err
}
@@ -385,7 +422,7 @@ func (s *Store) ListBySource(ctx context.Context, sourceKind string, includeRemo
if strings.TrimSpace(sourceKind) == "" {
return nil, ErrInvalidScope
}
query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?`
query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?`
args := []any{sourceKind}
if !includeRemoved {
query += ` AND status <> ?`
@@ -453,7 +490,7 @@ func (s *Store) checkAdmissionExcept(ctx context.Context, tx *sqlx.Tx, scopeKind
func (s *Store) Get(ctx context.Context, id string) (Instance, error) {
var row instanceRow
err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id)
err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id)
if errors.Is(err, sql.ErrNoRows) {
return Instance{}, ErrNotFound
}
@@ -487,7 +524,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) {
for i, id := range unique {
args[i] = id
}
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...)
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...)
if err != nil {
return nil, err
}
@@ -508,7 +545,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) {
}
func (s *Store) List(ctx context.Context, workspaceID string, includeRemoved bool) ([]Instance, error) {
query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?`
query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?`
args := []any{workspaceID}
if !includeRemoved {
query += ` AND status <> ?`
@@ -558,7 +595,7 @@ func (s *Store) Restore(ctx context.Context, id string) error {
}
defer func() { _ = tx.Rollback() }()
var row instanceRow
if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -611,7 +648,7 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde
}
defer func() { _ = tx.Rollback() }()
var current instanceRow
if err := tx.GetContext(ctx, &current, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if err := tx.GetContext(ctx, &current, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -620,6 +657,9 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde
if current.Status == StatusRemoved {
return ErrNotFound
}
if err := validateDataScope(current.SourceKind, scopeKind, current.DataScopeKind.String); err != nil {
return err
}
if err := s.checkAdmissionExcept(ctx, tx, scopeKind, ids.WorkspaceID, ids.TaskID, id); err != nil {
return err
}
@@ -718,7 +758,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID,
}
var current instanceRow
if err := tx.GetContext(ctx, &current, tx.Rebind(
`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
), instanceID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
@@ -743,7 +783,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID,
}
return err
}
if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, current.ScopeKind, release.DeclaredPermissionsJSON) {
if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String), release.DeclaredPermissionsJSON) {
return ErrInvalidRelease
}
result, err := tx.ExecContext(ctx, tx.Rebind(
@@ -1106,6 +1146,79 @@ func (s *Store) PromoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx
return s.promoteScopeAndGrantsReviewedTx(ctx, tx, instanceID, workspaceID, approvedBy, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
}
// EnableWorkspaceDataReviewedTx widens only a retained task canvas's data
// scope after the current workspace owner confirms its current release.
func (s *Store) EnableWorkspaceDataReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if err := validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
return err
}
current, declaredJSON, err := loadPromotionState(ctx, tx, instanceID)
if err != nil {
return err
}
if err := validateWorkspaceDataReviewState(current, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
return err
}
if err := validateGrantsForDeclaration(declaredJSON, ScopeWorkspace, grants); err != nil {
return err
}
if err := verifyWorkspaceOwnerTx(ctx, tx, workspaceID, approvedBy); err != nil {
return err
}
return updateWorkspaceDataScopeTx(ctx, tx, current, workspaceID, approvedBy, grants, expectedReleaseID, expectedGrantGeneration)
}
func validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(approvedBy) == "" ||
strings.TrimSpace(expectedReleaseID) == "" || strings.TrimSpace(expectedPermissionDigest) == "" || expectedGrantGeneration < 0 {
return ErrStaleWorkspaceDataReview
}
return nil
}
func validateWorkspaceDataReviewState(current instanceRow, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if current.SourceKind != SourceLocalCanvas || current.ScopeKind != ScopeTask || current.WorkspaceID != workspaceID ||
current.TaskID == "" || current.Status != StatusActive || current.DataScopeKind.Valid && current.DataScopeKind.String == ScopeWorkspace {
return ErrInvalidLifecycleState
}
if current.ActiveReleaseID != expectedReleaseID || current.GrantGeneration != expectedGrantGeneration ||
permissionDigest(declaredJSON) != expectedPermissionDigest || EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String) != ScopeTask {
return ErrStaleWorkspaceDataReview
}
return nil
}
func verifyWorkspaceOwnerTx(ctx context.Context, tx *sqlx.Tx, workspaceID, approvedBy string) error {
ownerUpdate, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE workspaces SET owner_id = owner_id WHERE id = ? AND (COALESCE(owner_id, '') = ? OR (COALESCE(owner_id, '') = '' AND ? = ?))`,
), workspaceID, approvedBy, approvedBy, userstore.DefaultUserID)
if err != nil {
return err
}
if affected, _ := ownerUpdate.RowsAffected(); affected == 0 {
return ErrWorkspaceOwnerRequired
}
return nil
}
func updateWorkspaceDataScopeTx(ctx context.Context, tx *sqlx.Tx, current instanceRow, workspaceID, approvedBy string, grants []Grant, expectedReleaseID string, expectedGrantGeneration int64) error {
result, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE plugin_instances SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND source_kind = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ? AND COALESCE(data_scope_kind, '') = ?`,
), ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), current.ID, SourceLocalCanvas, ScopeTask, workspaceID, current.TaskID, StatusActive, expectedReleaseID, expectedGrantGeneration, current.DataScopeKind.String)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return ErrStaleWorkspaceDataReview
}
if _, err := tx.ExecContext(ctx, tx.Rebind(
`DELETE FROM plugin_instance_grants WHERE plugin_instance_id = ?`,
), current.ID); err != nil {
return err
}
return insertInstanceGrantsTx(ctx, tx, current.ID, approvedBy, grants)
}
func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if err := validatePromotionRequest(instanceID, workspaceID, approvedBy); err != nil {
return err
@@ -1129,7 +1242,7 @@ func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx
func loadPromotionState(ctx context.Context, tx *sqlx.Tx, instanceID string) (instanceRow, string, error) {
var current instanceRow
if err := tx.GetContext(ctx, &current, tx.Rebind(
`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
), instanceID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return instanceRow{}, "", ErrNotFound
@@ -1231,14 +1344,20 @@ func (s *Store) ApproveReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, r
if release.ValidationStatus != ValidationPendingPermission {
return ErrInvalidRelease
}
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, grants); err != nil {
effectiveScope := EffectiveDataScopeKind(release.ScopeKind, release.DataScopeKind)
if release.SourceKind == SourceLocalCanvas && effectiveScope == ScopeWorkspace {
if err := verifyWorkspaceOwnerTx(ctx, tx, release.WorkspaceID, approvedBy); err != nil {
return err
}
}
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, effectiveScope, grants); err != nil {
return err
}
existingGrants, err := loadInstanceGrants(ctx, tx, instanceID)
if err != nil {
return err
}
if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, append(existingGrants, grants...)) {
if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, effectiveScope, append(existingGrants, grants...)) {
return ErrInvalidScope
}
if err := insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants); err != nil {
@@ -1262,14 +1381,17 @@ type pendingReleaseRow struct {
PluginID string `db:"plugin_id"`
DeclaredPermissionsJSON string `db:"declared_permissions_json"`
ValidationStatus string `db:"validation_status"`
SourceKind string `db:"source_kind"`
WorkspaceID string `db:"workspace_id"`
ScopeKind string `db:"scope_kind"`
DataScopeKind string `db:"data_scope_kind"`
Status string `db:"status"`
}
func loadPendingReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) (pendingReleaseRow, error) {
var release pendingReleaseRow
if err := tx.GetContext(ctx, &release, tx.Rebind(
`SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`,
`SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.source_kind, i.workspace_id, i.scope_kind, COALESCE(i.data_scope_kind, '') AS data_scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`,
), releaseID, instanceID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return pendingReleaseRow{}, ErrNotFound
@@ -1383,8 +1505,8 @@ func (s *Store) CreateReleaseIfAuthorityTx(ctx context.Context, tx *sqlx.Tx, ins
return ErrInvalidRelease
}
result, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`,
), instanceID, expected.ScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration)
`UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`,
), instanceID, expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration)
if err != nil {
return err
}
@@ -1659,8 +1781,8 @@ func (s *Store) AddGrant(ctx context.Context, grant Grant) error {
// canvas creation. It does not advance grant_generation because SetPluginIDTx
// increments it before the public ActivateReleaseTx call in the same
// activation transaction. The empty-grant case is valid.
func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, approvedBy string, grants []Grant) error {
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(approvedBy) == "" {
func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID, approvedBy string, grants []Grant) error {
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" {
return ErrInvalidScope
}
var exists int
@@ -1672,6 +1794,27 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID,
if exists == 0 {
return ErrNotFound
}
var current struct {
ScopeKind string `db:"scope_kind"`
DataScopeKind string `db:"data_scope_kind"`
}
if err := tx.GetContext(ctx, &current, tx.Rebind(
`SELECT scope_kind, COALESCE(data_scope_kind, '') AS data_scope_kind FROM plugin_instances WHERE id = ?`,
), instanceID); err != nil {
return err
}
var release struct {
DeclaredPermissionsJSON string `db:"declared_permissions_json"`
ValidationStatus string `db:"validation_status"`
}
if err := tx.GetContext(ctx, &release, tx.Rebind(
`SELECT declared_permissions_json, validation_status FROM plugin_releases WHERE id = ? AND instance_id = ?`,
), releaseID, instanceID); err != nil || release.ValidationStatus != ValidationValid {
return ErrInvalidRelease
}
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind), grants); err != nil {
return err
}
var existing int
if err := tx.GetContext(ctx, &existing, tx.Rebind(
`SELECT COUNT(*) FROM plugin_instance_grants WHERE plugin_instance_id = ?`,
@@ -1684,6 +1827,37 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID,
return insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants)
}
// SetInitialPublicationDataScopeTx widens a pending task canvas only after its
// creation authority has been consumed in the caller's transaction.
func (s *Store) SetInitialPublicationDataScopeTx(ctx context.Context, tx *sqlx.Tx, expected PublishAuthority, releaseID string) error {
if expected.InstanceID == "" || expected.ScopeKind != ScopeTask || expected.DataScopeKind != ScopeTask ||
expected.Status != StatusPending || expected.ActiveReleaseID != "" || expected.WorkspaceID == "" ||
expected.TaskID == "" || strings.TrimSpace(releaseID) == "" {
return ErrStaleCanvasPublish
}
result, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE plugin_instances
SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ?
WHERE id = ? AND source_kind = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ?
AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ?
AND status = ? AND active_release_id = ? AND grant_generation = ?
AND NOT EXISTS (SELECT 1 FROM plugin_instance_grants WHERE plugin_instance_id = ?)
AND EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id = ? AND validation_status = ?)
AND NOT EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id <> ?)`),
ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), expected.InstanceID, SourceLocalCanvas,
expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID,
expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration,
expected.InstanceID, expected.InstanceID, releaseID, ValidationValid, expected.InstanceID, releaseID,
)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return ErrStaleCanvasPublish
}
return nil
}
func (s *Store) ListGrants(ctx context.Context, instanceID string) ([]Grant, error) {
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at FROM plugin_instance_grants WHERE plugin_instance_id = ? ORDER BY permission_kind, resource, network_origin`), instanceID)
if err != nil {
@@ -2210,19 +2384,20 @@ func (s *Store) RemoveInstanceTx(ctx context.Context, tx *sqlx.Tx, id string) er
}
type instanceRow struct {
ID string `db:"id"`
PluginID string `db:"plugin_id"`
SourceKind string `db:"source_kind"`
ScopeKind string `db:"scope_kind"`
WorkspaceID string `db:"workspace_id"`
TaskID string `db:"task_id"`
SessionID string `db:"session_id"`
RepositoryID string `db:"repository_id"`
Status string `db:"status"`
ActiveReleaseID string `db:"active_release_id"`
GrantGeneration int64 `db:"grant_generation"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
ID string `db:"id"`
PluginID string `db:"plugin_id"`
SourceKind string `db:"source_kind"`
ScopeKind string `db:"scope_kind"`
DataScopeKind sql.NullString `db:"data_scope_kind"`
WorkspaceID string `db:"workspace_id"`
TaskID string `db:"task_id"`
SessionID string `db:"session_id"`
RepositoryID string `db:"repository_id"`
Status string `db:"status"`
ActiveReleaseID string `db:"active_release_id"`
GrantGeneration int64 `db:"grant_generation"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
func (r instanceRow) instance() (Instance, error) {
@@ -2234,7 +2409,7 @@ func (r instanceRow) instance() (Instance, error) {
if err != nil {
return Instance{}, err
}
return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil
return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, DataScopeKind: r.DataScopeKind.String, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil
}
type releaseRow struct {
@@ -14,6 +14,7 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/db"
userstore "github.com/kandev/kandev/internal/user/store"
)
func newTestStore(t *testing.T) *Store {
@@ -44,6 +45,181 @@ func TestValidateScopeRequiresOnlyIdentifiersForScope(t *testing.T) {
}
}
func TestInstanceDataScopeIsIndependentAndDefaultsToPlacement(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if err := store.Create(ctx, Instance{
ID: "workspace-data", PluginID: "canvas-board", SourceKind: SourceLocalCanvas,
ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1",
TaskID: "task-1", Status: StatusActive,
}); err != nil {
t.Fatalf("Create workspace-data task canvas: %v", err)
}
workspaceData, err := store.Get(ctx, "workspace-data")
if err != nil {
t.Fatalf("Get workspace-data task canvas: %v", err)
}
if workspaceData.ScopeKind != ScopeTask || workspaceData.EffectiveDataScopeKind() != ScopeWorkspace {
t.Fatalf("scopes = placement %q, effective data %q, want task and workspace", workspaceData.ScopeKind, workspaceData.EffectiveDataScopeKind())
}
if err := store.Create(ctx, Instance{
ID: "legacy", PluginID: "canvas-board", SourceKind: SourceLocalCanvas,
ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive,
}); err != nil {
t.Fatalf("Create legacy task canvas: %v", err)
}
legacy, err := store.Get(ctx, "legacy")
if err != nil {
t.Fatalf("Get legacy task canvas: %v", err)
}
if legacy.DataScopeKind != "" || legacy.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", legacy.DataScopeKind, legacy.EffectiveDataScopeKind())
}
}
func TestInstanceRejectsUntrustedDataScopeCombinations(t *testing.T) {
store := newTestStore(t)
for _, instance := range []Instance{
{ID: "installed", PluginID: "plugin", SourceKind: SourceInstalled, ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive},
{ID: "session-data", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeSession, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive},
{ID: "instance", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeInstance, WorkspaceID: "workspace-1", TaskID: "task-3", Status: StatusActive},
} {
if err := store.Create(context.Background(), instance); !errors.Is(err, ErrInvalidScope) {
t.Errorf("Create(%s) = %v, want ErrInvalidScope", instance.ID, err)
}
}
}
func TestExistingInstanceTableMigratesWithTaskDataScopeFallback(t *testing.T) {
connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "legacy-instances.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
connection.SetMaxOpenConns(1)
pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3"))
t.Cleanup(func() { _ = pool.Close() })
if _, err := pool.Writer().Exec(`CREATE TABLE plugin_instances (
id TEXT PRIMARY KEY, plugin_id TEXT NOT NULL, source_kind TEXT NOT NULL, scope_kind TEXT NOT NULL,
workspace_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL DEFAULT '',
repository_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, active_release_id TEXT NOT NULL DEFAULT '',
grant_generation INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
)`); err != nil {
t.Fatalf("create legacy plugin_instances schema: %v", err)
}
created := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := pool.Writer().Exec(`INSERT INTO plugin_instances
(id, plugin_id, source_kind, scope_kind, workspace_id, task_id, status, created_at, updated_at)
VALUES ('legacy', 'canvas', 'local_canvas', 'task', 'workspace-1', 'task-1', 'active', ?, ?)`, created, created); err != nil {
t.Fatalf("insert legacy instance: %v", err)
}
store, err := NewStore(pool)
if err != nil {
t.Fatalf("NewStore migrates legacy table: %v", err)
}
instance, err := store.Get(context.Background(), "legacy")
if err != nil {
t.Fatalf("Get legacy instance: %v", err)
}
if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", instance.DataScopeKind, instance.EffectiveDataScopeKind())
}
}
func TestEnableWorkspaceDataRequiresCurrentOwnerReview(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil {
t.Fatalf("create workspaces table: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', 'owner-1')`); err != nil {
t.Fatalf("insert workspace: %v", err)
}
if err := store.Create(ctx, Instance{
ID: "instance-1", PluginID: "canvas", SourceKind: SourceLocalCanvas,
ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive,
}); err != nil {
t.Fatalf("create task canvas: %v", err)
}
declared := json.RawMessage(`{"reads":["tasks"]}`)
if err := store.CreateRelease(ctx, Release{
ID: "release-1", PluginID: "canvas", InstanceID: "instance-1", PackageDigest: "digest-1",
SourceKind: SourceLocalCanvas, ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: declared,
ArtifactPath: "releases/one", ArtifactBytes: 1, ValidationStatus: ValidationValid,
}); err != nil {
t.Fatalf("create active release: %v", err)
}
if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil {
t.Fatalf("add task grant: %v", err)
}
if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil {
t.Fatalf("add stale task grant: %v", err)
}
if err := store.SetActiveRelease(ctx, "instance-1", "release-1"); err != nil {
t.Fatalf("activate release: %v", err)
}
instanceBeforeReview, err := store.Get(ctx, "instance-1")
if err != nil {
t.Fatalf("get instance before review: %v", err)
}
workspaceGrant := []Grant{{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeWorkspace}}
apply := func(userID string, generation int64, grants []Grant) error {
return store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
return store.EnableWorkspaceDataReviewedTx(ctx, tx, "instance-1", "workspace-1", userID, grants, "release-1", permissionDigest(string(declared)), generation)
})
}
if err := apply("owner-2", instanceBeforeReview.GrantGeneration, workspaceGrant); !errors.Is(err, ErrWorkspaceOwnerRequired) {
t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err)
}
if err := apply("owner-1", instanceBeforeReview.GrantGeneration+1, workspaceGrant); !errors.Is(err, ErrStaleWorkspaceDataReview) {
t.Fatalf("stale review confirmation = %v, want ErrStaleWorkspaceDataReview", err)
}
undeclared := append(append([]Grant(nil), workspaceGrant...), Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeWorkspace})
if err := apply("owner-1", instanceBeforeReview.GrantGeneration, undeclared); !errors.Is(err, ErrInvalidScope) {
t.Fatalf("undeclared grant confirmation = %v, want ErrInvalidScope", err)
}
if err := apply("owner-1", instanceBeforeReview.GrantGeneration, workspaceGrant); err != nil {
t.Fatalf("owner confirmation: %v", err)
}
instance, err := store.Get(ctx, "instance-1")
if err != nil {
t.Fatalf("get upgraded instance: %v", err)
}
if instance.ScopeKind != ScopeTask || instance.DataScopeKind != ScopeWorkspace || instance.ActiveReleaseID != "release-1" || instance.GrantGeneration != instanceBeforeReview.GrantGeneration+1 {
t.Fatalf("upgraded instance = %+v, want task placement, workspace data, same release, generation increment", instance)
}
grants, err := store.ListGrants(ctx, "instance-1")
if err != nil {
t.Fatalf("list upgraded grants: %v", err)
}
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeWorkspace || grants[0].ApprovedBy != "owner-1" {
t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants)
}
}
func TestVerifyWorkspaceOwnerAllowsAuthDisabledUnownedWorkspace(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil {
t.Fatalf("create workspaces table: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', '')`); err != nil {
t.Fatalf("insert unowned workspace: %v", err)
}
if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", userstore.DefaultUserID)
}); err != nil {
t.Fatalf("default user review of unowned workspace: %v", err)
}
if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", "member-1")
}); !errors.Is(err, ErrWorkspaceOwnerRequired) {
t.Fatalf("ordinary user review of unowned workspace = %v, want ErrWorkspaceOwnerRequired", err)
}
}
func TestCreateInstanceAdmissionIsAtomicAtTaskAndWorkspaceLimits(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
@@ -34,6 +34,7 @@ type CapabilityBinding struct {
WebAppKey string
Placement string
ScopeKind string
DataScopeKind string
WorkspaceID string
TaskID string
SessionID string
@@ -143,7 +143,7 @@ func projectWebAppEvent(source *bus.Event) (webapp.EventInput, bool) {
func isProjectableWebAppEvent(eventType string) bool {
switch eventType {
case events.CanvasCreated, events.CanvasReleaseActivated, events.CanvasReleasePermissionRequired,
events.CanvasPromoted, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved,
events.CanvasPromoted, events.CanvasWorkspaceDataEnabled, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved,
events.TaskCreated, events.TaskUpdated, events.TaskStateChanged, events.TaskDeleted, events.TaskMoved,
events.TaskQueuePromoted, events.TaskDependenciesResolved, events.TaskDependencyFailed,
events.WorkspaceCreated, events.WorkspaceUpdated, events.WorkspaceDeleted,
@@ -160,7 +160,7 @@ func isProjectableWebAppEvent(eventType string) bool {
func publicEventFields(eventType string) []string {
canvasFields := []string{
"canvas_id", "workspace_id", "task_id", "scope_kind", "status", "title", "plugin_id", "plugin_instance_id",
"canvas_id", "workspace_id", "task_id", "scope_kind", "data_scope_kind", "status", "title", "plugin_id", "plugin_instance_id",
"release_id", "active_release_id", "validation_status", "validation_error", "source_actor_kind", "source_user_id",
"source_task_id", "source_session_id", "placement", "protocol_version",
}
@@ -234,7 +234,7 @@ func webAppBusEventMatchesInstance(item instances.Instance, scope webapp.EventSc
if scope.WorkspaceID != "" && item.WorkspaceID != scope.WorkspaceID {
return false
}
switch item.ScopeKind {
switch item.EffectiveDataScopeKind() {
case instances.ScopeInstance:
return true
case instances.ScopeWorkspace:
@@ -290,7 +290,7 @@ func webAppEventMatchesBinding(scope webapp.EventScope, binding webapp.Capabilit
if scope.InstanceID != "" && scope.InstanceID != binding.InstanceID {
return false
}
switch binding.ScopeKind {
switch instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) {
case instances.ScopeInstance:
return true
case instances.ScopeWorkspace:
@@ -10,9 +10,11 @@ import (
)
func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
event := bus.NewEvent(events.CanvasReleaseActivated, "canvas", map[string]any{
event := bus.NewEvent(events.CanvasWorkspaceDataEnabled, "canvas", map[string]any{
"canvas_id": "canvas-1",
"workspace_id": "workspace-1",
"scope_kind": "task",
"data_scope_kind": "workspace",
"release_id": "release-1",
"active_release_id": "release-1",
"secret": strings.Repeat("s", 128),
@@ -36,6 +38,10 @@ func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
if projected.Scope.WorkspaceID != "workspace-1" || projected.Scope.InstanceID != "" {
t.Fatalf("projected scope = %+v", projected.Scope)
}
projectedData, ok := projected.Data.(map[string]any)
if !ok || projectedData["data_scope_kind"] != "workspace" {
t.Fatalf("projected data = %#v, want workspace data scope", projected.Data)
}
}
// TestProjectWebAppEventDropsDependencyProjectionFields covers the refresh
@@ -166,6 +166,7 @@ type webAppContext struct {
WebAppKey string `json:"web_app_key"`
Placement string `json:"placement"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
WorkspaceID string `json:"workspace_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
@@ -190,6 +191,7 @@ func (s *Service) handleWebAppContext(w http.ResponseWriter, r *http.Request, bi
WebAppKey: binding.WebAppKey,
Placement: binding.Placement,
ScopeKind: binding.ScopeKind,
DataScopeKind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind),
WorkspaceID: binding.WorkspaceID,
TaskID: binding.TaskID,
SessionID: binding.SessionID,
@@ -364,6 +366,9 @@ func (s *Service) validateWebAppBinding(ctx context.Context, binding webapp.Capa
}
instance, err := store.Get(ctx, binding.InstanceID)
if err != nil || instance.Status != instances.StatusActive || instance.PluginID != binding.PluginID ||
instance.ScopeKind != binding.ScopeKind ||
instance.EffectiveDataScopeKind() != instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) ||
instance.WorkspaceID != binding.WorkspaceID || instance.TaskID != binding.TaskID ||
instance.ActiveReleaseID != binding.ReleaseID || instance.GrantGeneration != binding.GrantGeneration {
return webapp.ErrRuntimeTokenStale
}
@@ -395,13 +400,14 @@ func (s *Service) validateWebAppPermissions(ctx context.Context, binding webapp.
if err != nil {
return err
}
dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind)
for _, permission := range webAppDeclaredPermissions(m, binding.WebAppKey) {
if !webAppGrantCovers(permission, binding.ScopeKind, grants) {
if !webAppGrantCovers(permission, dataScope, grants) {
return errors.New("web app permission grant is stale")
}
}
for _, origin := range binding.NetworkOrigins {
if !webAppOriginGranted(origin, binding.ScopeKind, grants) {
if !webAppOriginGranted(origin, dataScope, grants) {
return errors.New("web app network grant is stale")
}
}
@@ -40,7 +40,7 @@ func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r
IncludeArchived: includeArchived,
IncludeEphemeral: true,
}
if binding.ScopeKind == instances.ScopeTask {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
// Resolved via the non-attaching fetchTask, and dependencies attached
// only below, after the discard checks: matching the attachment rule
// the non-task-scope branch below already documents for itself.
@@ -278,7 +278,7 @@ func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter
}
workflows = append(workflows, items...)
}
if binding.ScopeKind == instances.ScopeTask {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
@@ -318,7 +318,7 @@ func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWr
}
}
}
if binding.ScopeKind == instances.ScopeTask {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil || task.WorkflowID != workflowID {
writeWebAppError(w, http.StatusNotFound, "not_found")
@@ -380,13 +380,14 @@ func (s *Service) webAppWorkflowWorkspaceIDs(ctx context.Context, r *http.Reques
}
func webAppTaskMatches(ctx context.Context, host *pluginHost, binding webapp.CapabilityBinding, task pluginsdk.Task) bool {
if binding.ScopeKind == instances.ScopeInstance {
dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind)
if dataScope == instances.ScopeInstance {
return true
}
if task.WorkspaceID != binding.WorkspaceID {
return false
}
switch binding.ScopeKind {
switch dataScope {
case instances.ScopeWorkspace:
return true
case instances.ScopeTask:
@@ -135,6 +135,47 @@ func TestListWebAppTasks_DerivesDependenciesAfterScopeNarrowing(t *testing.T) {
require.Contains(t, recorder.Body.String(), `"blocked":true`)
}
func TestListWebAppTasks_TaskPlacementUsesWorkspaceDataScopeAndPagination(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}, {ID: "ws-2"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{
"ws-1": {
{ID: "task-a", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)},
{ID: "task-b", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)},
},
"ws-2": {{ID: "foreign", WorkspaceID: "ws-2", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeTask, DataScopeKind: instances.ScopeWorkspace,
WorkspaceID: "ws-1", TaskID: "task-a", Permissions: []string{"api_read:tasks"},
}
first := httptest.NewRecorder()
svc.handleWebAppProtocol(first, httptest.NewRequest(http.MethodGet, "/?limit=1", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
var firstPage webAppPage[webAppTask]
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &firstPage))
require.Len(t, firstPage.Items, 1)
require.Equal(t, "task-a", firstPage.Items[0].ID)
require.True(t, firstPage.PageInfo.HasMore)
require.NotEmpty(t, firstPage.PageInfo.NextCursor)
second := httptest.NewRecorder()
secondRequest := httptest.NewRequest(http.MethodGet, "/?limit=1&cursor="+firstPage.PageInfo.NextCursor, nil)
svc.handleWebAppProtocol(second, secondRequest, "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
var secondPage webAppPage[webAppTask]
require.NoError(t, json.Unmarshal(second.Body.Bytes(), &secondPage))
require.Len(t, secondPage.Items, 1)
require.Equal(t, "task-b", secondPage.Items[0].ID)
foreign := httptest.NewRecorder()
foreignRequest := httptest.NewRequest(http.MethodGet, "/?workspace_id=ws-2", nil)
svc.handleWebAppProtocol(foreign, foreignRequest, "", binding, "v1/data/tasks")
require.Equal(t, http.StatusForbidden, foreign.Code)
}
func TestListWebAppTasks_FanOutRefusalBecomesResponseTooLarge(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
@@ -119,7 +119,11 @@ func dependencyRedactionScopeFromBinding(binding webapp.CapabilityBinding, direc
for _, id := range directlyReadableIDs {
ids[id] = struct{}{}
}
return dependencyRedactionScope{kind: binding.ScopeKind, workspaceID: binding.WorkspaceID, directlyReadableIDs: ids}
return dependencyRedactionScope{
kind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind),
workspaceID: binding.WorkspaceID,
directlyReadableIDs: ids,
}
}
// admits reports whether ref may be disclosed as a directly readable task
@@ -30,7 +30,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) {
binding := webapp.CapabilityBinding{
UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1",
ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas",
ScopeKind: "task", WorkspaceID: "workspace-1", TaskID: "task-1",
ScopeKind: "task", DataScopeKind: "workspace", WorkspaceID: "workspace-1", TaskID: "task-1",
Permissions: []string{"api_read:tasks", "state"},
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -49,7 +49,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) {
if _, ok := body["token"]; ok {
t.Fatal("context exposed token")
}
if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) {
if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) || body["scope_kind"] != "task" || body["data_scope_kind"] != "workspace" {
t.Fatalf("context = %#v", body)
}
}
@@ -85,7 +85,7 @@ func (s *Service) listWebAppWorkflowTransitionGroups(ctx context.Context, w http
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
if binding.ScopeKind != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) {
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
return
}
@@ -71,14 +71,16 @@ func TestWebAppTransitionGroupsRequireWorkspaceAndBothGrants(t *testing.T) {
return recorder
}
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
binding.ScopeKind = instances.ScopeWorkspace
binding.DataScopeKind = instances.ScopeWorkspace
require.Equal(t, http.StatusOK, call("wf-1").Code)
require.Equal(t, 1, source.groupCalls)
binding.Permissions = []string{"api_read:workflows"}
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
binding.Permissions = []string{"api_read:tasks", "api_read:workflows"}
require.Equal(t, http.StatusNotFound, call("foreign-wf").Code)
require.Zero(t, source.groupCalls)
require.Equal(t, 1, source.groupCalls)
allowed := call("wf-1")
require.Equal(t, http.StatusOK, allowed.Code, allowed.Body.String())
require.Equal(t, 1, source.groupCalls)
require.Equal(t, 2, source.groupCalls)
require.Contains(t, allowed.Body.String(), `"count":2`)
}
@@ -28,7 +28,8 @@ func TestCreateCanvasBuiltin(t *testing.T) {
"source directory",
"authorized live Kandev data",
"awaits permission review",
"requires promotion",
"adds workspace navigation",
"Do not claim that promotion is required to preview",
"local build alone does not publish",
} {
if !strings.Contains(prompt.Content, want) {
+1
View File
@@ -107,6 +107,7 @@ const (
ActionCanvasReleaseActivated = "canvas.release.activated"
ActionCanvasReleasePermissionRequired = "canvas.release.permission_required"
ActionCanvasPromoted = "canvas.promoted"
ActionCanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled"
ActionCanvasArchived = "canvas.archived"
ActionCanvasRestored = "canvas.restored"
ActionCanvasRemoved = "canvas.removed"
@@ -4,12 +4,18 @@ import { IconDots } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs";
import {
CanvasPromotionDialog,
CanvasReleaseDialog,
CanvasWorkspaceDataDialog,
} from "./canvas-lifecycle-dialogs";
export function CanvasHostDialogs({
canvas,
promotionOpen,
onPromotionOpenChange,
workspaceDataOpen,
onWorkspaceDataOpenChange,
releasesOpen,
onReleasesOpenChange,
onPromotionCompleted,
@@ -18,6 +24,8 @@ export function CanvasHostDialogs({
canvas: Canvas | null;
promotionOpen: boolean;
onPromotionOpenChange: (open: boolean) => void;
workspaceDataOpen: boolean;
onWorkspaceDataOpenChange: (open: boolean) => void;
releasesOpen: boolean;
onReleasesOpenChange: (open: boolean) => void;
onPromotionCompleted: () => void;
@@ -31,6 +39,12 @@ export function CanvasHostDialogs({
onOpenChange={onPromotionOpenChange}
onCompleted={onPromotionCompleted}
/>
<CanvasWorkspaceDataDialog
canvas={canvas?.scope_kind === "task" ? canvas : null}
open={workspaceDataOpen}
onOpenChange={onWorkspaceDataOpenChange}
onCompleted={onChanged}
/>
<CanvasReleaseDialog
canvas={canvas}
open={releasesOpen}
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DropdownMenu, DropdownMenuContent } from "@kandev/ui/dropdown-menu";
@@ -8,6 +8,12 @@ const COPY: Record<string, string> = {
"canvases:editCanvas": "Edit canvas",
"canvases:releasesAndPermissions": "Releases and permissions",
"canvases:promoteCanvas": "Promote canvas",
"canvases:enableWorkspaceData": "Enable workspace data",
"canvases:enableWorkspaceDataHelp": "Enable declared workspace data access.",
"canvases:taskDataScope": "Task data",
"canvases:workspaceDataScope": "Workspace data",
"canvases:taskPlacementScope": "Task",
"canvases:workspacePlacementScope": "Workspace",
"canvases:canvasActions": "Canvas actions",
"canvases:canvases": "Canvases",
"canvases:openInNewTab": "Open in new tab",
@@ -45,6 +51,7 @@ vi.mock("@/components/plugins/canvas-page", () => ({ CanvasPage: () => null }));
vi.mock("./canvas-lifecycle-dialogs", () => ({
CanvasPromotionDialog: () => null,
CanvasReleaseDialog: () => null,
CanvasWorkspaceDataDialog: () => null,
}));
import {
@@ -160,12 +167,56 @@ describe("canvas host action guidance", () => {
});
});
describe("legacy workspace data action", () => {
it("offers the legacy workspace data review on desktop and mobile actions", () => {
const legacyCanvas = {
...canvas,
status: "active" as const,
active_release_status: "valid" as const,
};
const onEnableWorkspaceData = vi.fn();
render(
<>
<CanvasDesktopActions
canvas={legacyCanvas}
editing={false}
onEdit={vi.fn()}
onPromote={vi.fn()}
onReleases={vi.fn()}
onShare={vi.fn()}
onEnableWorkspaceData={onEnableWorkspaceData}
/>
<MobileCanvasActions
canvas={legacyCanvas}
canvases={[legacyCanvas]}
open
onOpenChange={vi.fn()}
onEdit={vi.fn()}
onPromote={vi.fn()}
onReleases={vi.fn()}
onShare={vi.fn()}
onRename={vi.fn()}
onEnableWorkspaceData={onEnableWorkspaceData}
onSelectCanvas={vi.fn()}
editing={false}
/>
</>,
);
const buttons = screen.getAllByRole("button", { name: "Enable workspace data" });
expect(buttons).toHaveLength(2);
buttons.forEach((button) => fireEvent.click(button));
expect(onEnableWorkspaceData).toHaveBeenCalledTimes(2);
});
});
describe("canvas host chrome", () => {
it("keeps state content in the body and leaves the shared header for title/actions", () => {
render(
<>
<CanvasHostHeader
title="Task canvas"
dataScopeLabel="Workspace data"
isMobile={false}
menuOpen={false}
onOpenActions={vi.fn()}
@@ -177,6 +228,7 @@ describe("canvas host chrome", () => {
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Task canvas");
expect(screen.getByTestId("canvas-host-header").textContent).toContain("Release actions");
expect(screen.getByTestId("canvas-data-scope").textContent).toBe("Workspace data");
expect(screen.getByTestId("canvas-host-state").textContent).toContain("Canvas unavailable");
expect(
screen
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import {
IconDatabase,
IconEdit,
IconExternalLink,
IconLayoutGrid,
@@ -15,7 +16,11 @@ import { Button } from "@kandev/ui/button";
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
import { CanvasPage } from "@/components/plugins/canvas-page";
import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api";
import {
canvasCanEnableWorkspaceData,
canvasHref,
type Canvas,
} from "@/lib/api/domains/canvas-api";
import { CanvasMobileActionsButton } from "./canvas-host-actions";
import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions";
export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions";
@@ -137,6 +142,7 @@ export function CanvasHostBody({
export function CanvasHostHeader({
title,
dataScopeLabel,
isMobile,
menuOpen,
onOpenActions,
@@ -145,6 +151,7 @@ export function CanvasHostHeader({
overflowActions,
}: {
title: string;
dataScopeLabel?: string;
isMobile: boolean;
menuOpen: boolean;
onOpenActions: () => void;
@@ -157,7 +164,16 @@ export function CanvasHostHeader({
data-testid="canvas-host-header"
left={
<div className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium">{title}</span>
<span className="block min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
{dataScopeLabel && (
<span
className="max-w-20 shrink-0 truncate text-xs text-muted-foreground"
data-testid="canvas-data-scope"
title={dataScopeLabel}
>
{dataScopeLabel}
</span>
)}
{renameAction}
</div>
}
@@ -327,6 +343,35 @@ function MobileCanvasPromoteAction({
);
}
function MobileCanvasWorkspaceDataAction({
canvas,
onEnable,
t,
}: {
canvas: Canvas | null;
onEnable?: () => void;
t: (key: string) => string;
}) {
if (!canvas || !onEnable || !canvasCanEnableWorkspaceData(canvas)) return null;
return (
<MobileCanvasAction
description={t("canvases:enableWorkspaceDataHelp")}
testId="canvas-action-enable-workspace-data-help"
>
<Button
variant="ghost"
className="min-h-11 w-full justify-start cursor-pointer"
onClick={onEnable}
data-testid="canvas-action-enable-workspace-data"
>
<IconDatabase className="mr-2 h-4 w-4" />
{t("canvases:enableWorkspaceData")}
</Button>
</MobileCanvasAction>
);
}
function MobileCanvasShareAction({
onShare,
t,
@@ -377,6 +422,7 @@ export function MobileCanvasActions({
onReleases,
onShare,
onRename,
onEnableWorkspaceData,
onSelectCanvas,
editing,
}: {
@@ -389,6 +435,7 @@ export function MobileCanvasActions({
onReleases: () => void;
onShare: () => void;
onRename: () => void;
onEnableWorkspaceData?: () => void;
onSelectCanvas: (canvas: Canvas) => void;
editing: boolean;
}) {
@@ -427,6 +474,7 @@ export function MobileCanvasActions({
)}
<MobileCanvasReleasesAction onReleases={onReleases} t={t} />
{canvas && <MobileCanvasShareAction onShare={onShare} t={t} />}
<MobileCanvasWorkspaceDataAction canvas={canvas} onEnable={onEnableWorkspaceData} t={t} />
{canvas?.scope_kind === "task" && (
<MobileCanvasPromoteAction
canvas={canvas}
@@ -1,13 +1,19 @@
"use client";
import type { ReactNode } from "react";
import { IconEdit, IconListDetails, IconShare3, IconSparkles } from "@tabler/icons-react";
import {
IconDatabase,
IconEdit,
IconListDetails,
IconShare3,
IconSparkles,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { DropdownMenuItem } from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { PanelHeaderOverflowMenu } from "@/components/task/panel-primitives";
import { type Canvas } from "@/lib/api/domains/canvas-api";
import { canvasCanEnableWorkspaceData, type Canvas } from "@/lib/api/domains/canvas-api";
export function canvasLockHelp(canvas: Canvas, t: (key: string) => string): string {
return canvas.status === "archived"
@@ -59,6 +65,7 @@ export function CanvasDesktopActions({
onPromote,
onReleases,
onShare,
onEnableWorkspaceData,
}: {
canvas: Canvas;
editing: boolean;
@@ -66,6 +73,7 @@ export function CanvasDesktopActions({
onPromote: () => void;
onReleases: () => void;
onShare: () => void;
onEnableWorkspaceData?: () => void;
}) {
const { t } = useTranslation();
const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled";
@@ -112,6 +120,24 @@ export function CanvasDesktopActions({
{t("canvases:shareCanvas")}
</Button>
</CanvasDesktopActionTooltip>
{onEnableWorkspaceData && canvasCanEnableWorkspaceData(canvas) && (
<CanvasDesktopActionTooltip
description={t("canvases:enableWorkspaceDataHelp")}
disabled={false}
testId="canvas-action-enable-workspace-data-tooltip-trigger"
>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={onEnableWorkspaceData}
data-testid="canvas-action-enable-workspace-data"
>
<IconDatabase className="mr-1.5 h-3.5 w-3.5" />
{t("canvases:enableWorkspaceData")}
</Button>
</CanvasDesktopActionTooltip>
)}
{canvas.scope_kind === "task" && (
<CanvasDesktopActionTooltip
description={promoteDescription}
@@ -140,9 +166,33 @@ type CanvasDesktopOverflowActionsProps = {
onPromote: () => void;
onReleases: () => void;
onShare: () => void;
onEnableWorkspaceData?: () => void;
omitPrimaryAction?: boolean;
};
function CanvasWorkspaceDataMenuAction({
canvas,
onEnableWorkspaceData,
t,
}: {
canvas: Canvas;
onEnableWorkspaceData?: () => void;
t: (key: string) => string;
}) {
if (!onEnableWorkspaceData || !canvasCanEnableWorkspaceData(canvas)) return null;
return (
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={onEnableWorkspaceData}
data-testid="canvas-action-enable-workspace-data"
>
<IconDatabase className="size-4" />
{t("canvases:enableWorkspaceData")}
</DropdownMenuItem>
);
}
export function CanvasDesktopPrimaryAction({
canvas,
editing,
@@ -197,6 +247,7 @@ function CanvasDesktopOverflowMenuItemsContent({
onPromote,
onReleases,
onShare,
onEnableWorkspaceData,
omitPrimaryAction,
t,
}: CanvasDesktopOverflowActionsProps & { t: (key: string) => string }) {
@@ -238,6 +289,11 @@ function CanvasDesktopOverflowMenuItemsContent({
<IconShare3 className="size-4" />
{t("canvases:shareCanvas")}
</DropdownMenuItem>
<CanvasWorkspaceDataMenuAction
canvas={canvas}
onEnableWorkspaceData={onEnableWorkspaceData}
t={t}
/>
{canvas.scope_kind === "task" && !omitPrimaryAction && (
<DropdownMenuItem
className="cursor-pointer gap-2"
@@ -10,6 +10,8 @@ export function CanvasHostFrame({
embedded,
isMobile,
title,
mobileTitleSlot,
dataScopeLabel,
menuOpen,
setMenuOpen,
desktopActions,
@@ -25,6 +27,8 @@ export function CanvasHostFrame({
embedded: boolean;
isMobile: boolean;
title: string;
mobileTitleSlot?: ReactNode;
dataScopeLabel?: string;
menuOpen: boolean;
setMenuOpen: (open: boolean) => void;
desktopActions: ReactNode;
@@ -43,6 +47,7 @@ export function CanvasHostFrame({
<PanelRoot data-testid="canvas-host-panel">
<CanvasHostHeader
title={title}
dataScopeLabel={dataScopeLabel}
isMobile={isMobile}
menuOpen={menuOpen}
onOpenActions={() => setMenuOpen(true)}
@@ -61,13 +66,16 @@ export function CanvasHostFrame({
<PageShell
title={title}
titleSlot={
!isMobile ? (
isMobile ? (
mobileTitleSlot
) : (
<span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium">{title}</span>
{renameAction}
</span>
) : undefined
)
}
subtitle={isMobile ? undefined : dataScopeLabel}
backHref="/"
backLabel={t("sidebar:home")}
scroll="none"
@@ -18,7 +18,7 @@ import {
MobileCanvasActions,
type CanvasHostState,
} from "./canvas-host-components";
import { type Canvas } from "@/lib/api/domains/canvas-api";
import { canvasDataScope, type Canvas } from "@/lib/api/domains/canvas-api";
type CanvasHostRouteViewProps = {
canvasId: string;
@@ -31,17 +31,20 @@ type CanvasHostRouteViewProps = {
error: string | null;
menuOpen: boolean;
promotionOpen: boolean;
workspaceDataOpen: boolean;
releasesOpen: boolean;
shareOpen: boolean;
renameOpen: boolean;
editing: boolean;
setMenuOpen: (open: boolean) => void;
setPromotionOpen: (open: boolean) => void;
setWorkspaceDataOpen: (open: boolean) => void;
setReleasesOpen: (open: boolean) => void;
setShareOpen: (open: boolean) => void;
setRenameOpen: (open: boolean) => void;
onEdit: () => void;
onPromote: () => void;
onEnableWorkspaceData: () => void;
onReleases: () => void;
onShare: () => void;
onRename: () => void;
@@ -57,6 +60,8 @@ function CanvasHostRouteDialogs({
canvas,
promotionOpen,
setPromotionOpen,
workspaceDataOpen,
setWorkspaceDataOpen,
releasesOpen,
setReleasesOpen,
shareOpen,
@@ -70,6 +75,8 @@ function CanvasHostRouteDialogs({
| "canvas"
| "promotionOpen"
| "setPromotionOpen"
| "workspaceDataOpen"
| "setWorkspaceDataOpen"
| "releasesOpen"
| "setReleasesOpen"
| "shareOpen"
@@ -85,6 +92,8 @@ function CanvasHostRouteDialogs({
canvas={canvas}
promotionOpen={promotionOpen}
onPromotionOpenChange={setPromotionOpen}
workspaceDataOpen={workspaceDataOpen}
onWorkspaceDataOpenChange={setWorkspaceDataOpen}
releasesOpen={releasesOpen}
onReleasesOpenChange={setReleasesOpen}
onPromotionCompleted={onPromotionCompleted}
@@ -134,6 +143,7 @@ function CanvasHostRouteMobileActions({
setMenuOpen,
onEdit,
onPromote,
onEnableWorkspaceData,
onReleases,
onShare,
onRename,
@@ -147,6 +157,7 @@ function CanvasHostRouteMobileActions({
| "setMenuOpen"
| "onEdit"
| "onPromote"
| "onEnableWorkspaceData"
| "onReleases"
| "onShare"
| "onRename"
@@ -160,6 +171,7 @@ function CanvasHostRouteMobileActions({
onOpenChange={setMenuOpen}
onEdit={onEdit}
onPromote={onPromote}
onEnableWorkspaceData={onEnableWorkspaceData}
onReleases={onReleases}
onShare={onShare}
onRename={onRename}
@@ -187,6 +199,7 @@ function createCanvasDesktopActions(canvas: Canvas | null, props: CanvasHostRout
onPromote: props.onPromote,
onReleases: props.onReleases,
onShare: props.onShare,
onEnableWorkspaceData: props.onEnableWorkspaceData,
};
return {
actions: <CanvasDesktopActions {...actionProps} />,
@@ -196,10 +209,35 @@ function createCanvasDesktopActions(canvas: Canvas | null, props: CanvasHostRout
};
}
function useCanvasHeaderPresentation(canvas: Canvas | null, isMobile: boolean) {
const { t } = useTranslation();
const title = canvas?.title || t("canvases:canvas");
const dataScopeLabel = canvas
? t(
canvasDataScope(canvas) === "workspace"
? "canvases:workspaceDataScope"
: "canvases:taskDataScope",
)
: undefined;
const mobileTitleSlot =
isMobile && dataScopeLabel ? (
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium">{title}</span>
<span
className="shrink-0 text-xs font-normal text-muted-foreground"
data-testid="canvas-data-scope"
>
{dataScopeLabel}
</span>
</span>
) : undefined;
return { title, dataScopeLabel, mobileTitleSlot };
}
export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
const { canvas, isMobile, menuOpen, setMenuOpen } = props;
const { t } = useTranslation();
const title = canvas?.title || t("canvases:canvas");
const { title, dataScopeLabel, mobileTitleSlot } = useCanvasHeaderPresentation(canvas, isMobile);
const desktopActions = createCanvasDesktopActions(canvas, props);
const renameAction = canvas ? (
<Button
@@ -236,6 +274,7 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
setMenuOpen={setMenuOpen}
onEdit={props.onEdit}
onPromote={props.onPromote}
onEnableWorkspaceData={props.onEnableWorkspaceData}
onReleases={props.onReleases}
onShare={props.onShare}
onRename={props.onRename}
@@ -249,6 +288,8 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
canvas={canvas}
promotionOpen={props.promotionOpen}
setPromotionOpen={props.setPromotionOpen}
workspaceDataOpen={props.workspaceDataOpen}
setWorkspaceDataOpen={props.setWorkspaceDataOpen}
releasesOpen={props.releasesOpen}
setReleasesOpen={props.setReleasesOpen}
shareOpen={props.shareOpen}
@@ -265,6 +306,8 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
embedded={props.embedded}
isMobile={isMobile}
title={title}
mobileTitleSlot={mobileTitleSlot}
dataScopeLabel={dataScopeLabel}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
desktopActions={desktopActions.actions}
@@ -28,6 +28,12 @@ const RUNTIME_URL_ATTRIBUTE = "data-runtime-url";
vi.mock("@/lib/api/domains/canvas-api", () => ({
canvasHref: (canvasId: string) => `/canvases/${canvasId}`,
canvasDataScope: (value: Canvas) => value.data_scope_kind ?? value.scope_kind,
canvasCanEnableWorkspaceData: (value: Canvas) =>
value.scope_kind === "task" &&
(value.data_scope_kind ?? value.scope_kind) === "task" &&
value.status === "active" &&
value.active_release_status === "valid",
getCanvas: mockGetCanvas,
getCanvasRuntime: mockGetCanvasRuntime,
listTaskCanvases: mockListTaskCanvases,
@@ -40,15 +46,23 @@ vi.mock("@/components/page-shell", () => ({
actions,
children,
overflowMenuItems,
subtitle,
titleSlot,
topbarTestId,
}: {
actions?: ReactNode;
children: ReactNode;
overflowMenuItems?: ReactNode;
subtitle?: string;
titleSlot?: ReactNode;
topbarTestId?: string;
}) => (
<div>
<div data-testid={topbarTestId}>{mockIsMobile.value ? actions : null}</div>
<div data-testid={topbarTestId}>
{titleSlot}
{subtitle}
{mockIsMobile.value ? actions : null}
</div>
{overflowMenuItems && mockRenderPageShellOverflow.value ? (
<DropdownMenu open>
<DropdownMenuContent data-testid="page-shell-overflow">
@@ -77,6 +91,7 @@ vi.mock("@/components/plugins/canvas-page", () => ({
vi.mock("@/components/settings/canvas-lifecycle-dialogs", () => ({
CanvasPromotionDialog: () => null,
CanvasReleaseDialog: () => null,
CanvasWorkspaceDataDialog: () => null,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
@@ -240,7 +255,9 @@ describe("CanvasHostRoute runtime recovery", () => {
);
});
});
});
describe("CanvasHostRoute mobile data scope", () => {
it("lets a mobile focused host switch to another applicable canvas", async () => {
mockIsMobile.value = true;
const otherCanvas = {
@@ -261,4 +278,13 @@ describe("CanvasHostRoute runtime recovery", () => {
expect(mockPush).toHaveBeenCalledWith("/canvases/canvas-2");
});
it("shows the data scope beside the title on mobile", async () => {
mockIsMobile.value = true;
mockGetCanvas.mockReset().mockResolvedValue({ ...canvas, data_scope_kind: "workspace" });
render(<CanvasHostRoute canvasId="canvas-1" />);
expect((await screen.findByTestId("canvas-data-scope")).textContent).toBe("Workspace data");
});
});
@@ -389,6 +389,21 @@ async function editCanvasFromHost(options: CanvasHostEditOptions): Promise<void>
}
}
function useRecordCanvasPresentation(canvas: Canvas | null, userId: string | null) {
useEffect(() => {
if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !userId) return;
recordCanvasPresentation(
{
userId,
workspaceId: canvas.workspace_id,
taskId: canvas.task_id,
canvasId: canvas.id,
},
"manual",
);
}, [canvas, userId]);
}
export function CanvasHostRoute({
canvasId,
embedded = false,
@@ -414,23 +429,13 @@ export function CanvasHostRoute({
const hostCanvases = useCanvasHostCanvases(canvas);
const [menuOpen, setMenuOpen] = useState(false);
const [promotionOpen, setPromotionOpen] = useState(false);
const [workspaceDataOpen, setWorkspaceDataOpen] = useState(false);
const [releasesOpen, setReleasesOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !presentationUserId) return;
recordCanvasPresentation(
{
userId: presentationUserId,
workspaceId: canvas.workspace_id,
taskId: canvas.task_id,
canvasId: canvas.id,
},
"manual",
);
}, [canvas, presentationUserId]);
useRecordCanvasPresentation(canvas, presentationUserId);
const edit = () =>
editCanvasFromHost({
@@ -461,17 +466,23 @@ export function CanvasHostRoute({
error={error}
menuOpen={menuOpen}
promotionOpen={promotionOpen}
workspaceDataOpen={workspaceDataOpen}
releasesOpen={releasesOpen}
shareOpen={shareOpen}
renameOpen={renameOpen}
editing={editing}
setMenuOpen={setMenuOpen}
setPromotionOpen={setPromotionOpen}
setWorkspaceDataOpen={setWorkspaceDataOpen}
setReleasesOpen={setReleasesOpen}
setShareOpen={setShareOpen}
setRenameOpen={setRenameOpen}
onEdit={() => void edit()}
onPromote={() => setPromotionOpen(true)}
onEnableWorkspaceData={() => {
setMenuOpen(false);
setWorkspaceDataOpen(true);
}}
onReleases={() => setReleasesOpen(true)}
onShare={() => setShareOpen(true)}
onRename={() => {
@@ -4,9 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/api/client";
import type { Canvas, CanvasRelease } from "@/lib/api/domains/canvas-api";
const ENABLE_WORKSPACE_DATA_TEXT = vi.hoisted(() => "Enable workspace data");
const READ_TASK_DATA_TEXT = vi.hoisted(() => "Read task data");
const {
mockRequestCanvasPromotion,
mockConfirmCanvasPromotion,
mockRequestCanvasWorkspaceData,
mockEnableCanvasWorkspaceData,
mockListCanvasReleases,
mockApproveCanvasRelease,
mockRejectCanvasRelease,
@@ -14,6 +19,8 @@ const {
} = vi.hoisted(() => ({
mockRequestCanvasPromotion: vi.fn(),
mockConfirmCanvasPromotion: vi.fn(),
mockRequestCanvasWorkspaceData: vi.fn(),
mockEnableCanvasWorkspaceData: vi.fn(),
mockListCanvasReleases: vi.fn(),
mockApproveCanvasRelease: vi.fn(),
mockRejectCanvasRelease: vi.fn(),
@@ -28,6 +35,24 @@ const translate = vi.hoisted(() => {
"canvases:promoteCanvasDescription": "Review permissions before promotion.",
"canvases:loadingPermissions": "Loading requested permissions",
"canvases:promotionScopeChange": "Promotion changes the canvas scope.",
"canvases:promotionDataAccessPreserved":
"Promotion changes placement only. Data access stays the same.",
"canvases:promotionDataAccessChanges": "Promotion expands data access.",
"canvases:promotionSourceScope": "Placement before promotion",
"canvases:promotionTargetScope": "Placement after promotion",
"canvases:promotionCurrentDataScope": "Data access before promotion",
"canvases:promotionTargetDataScope": "Data access after promotion",
"canvases:taskDataScope": "Task data",
"canvases:workspaceDataScope": "Workspace data",
"canvases:taskPlacementScope": "Task",
"canvases:workspacePlacementScope": "Workspace",
"canvases:enableWorkspaceData": ENABLE_WORKSPACE_DATA_TEXT,
"canvases:enableWorkspaceDataHelp": "Enable declared permissions for this workspace.",
"canvases:workspaceDataReviewDescription": "Review permissions for {{title}}.",
"canvases:workspaceDataReviewScopeChange":
"Only data access changes. The canvas stays in this task.",
"canvases:enablingWorkspaceData": "Enabling workspace data",
"canvases:activeRelease": "Active release",
"canvases:noAdditionalPermissions": "No additional permissions were requested.",
"canvases:permissionDeclaration": "Declared permissions",
"canvases:missingPermissions": "Permissions still needed",
@@ -50,13 +75,15 @@ const translate = vi.hoisted(() => {
"canvases:permissionExternalOrigins": "External origins",
"canvases:permissionExternalOrigin": "External HTTPS origin",
"canvases:permissionSharedState": "Read and write shared canvas state",
"canvases:permissionReadTasks": "Read task data",
"canvases:permissionReadTasks": READ_TASK_DATA_TEXT,
"canvases:permissionReadWorkflows": "Read workflow data",
"canvases:permissionWriteTasks": "Write task data",
"canvases:permissionWriteMessages": "Write task messages",
"canvases:permissionEventTaskUpdated": "Receive task update events",
"canvases:permissionEventWorkflowUpdated": "Receive workflow update events",
"canvases:unsupportedPermission": "Unsupported permission: {{value}}",
"canvases:workspaceDataReviewStale": "The review changed. Open it again.",
"canvases:workspaceOwnerRequired": "Only the workspace owner can enable workspace data.",
"canvases:selectRelease": "Select a release to review",
"canvases:releaseOption": "{{date}} ({{status}})",
"canvases:releaseDate": "Created",
@@ -71,11 +98,9 @@ const translate = vi.hoisted(() => {
"canvases:sourceActorSystem": "Kandev",
"canvases:sourceActorUnknown": "Unknown author",
"canvases:sharedState": "Shared state",
"canvases:promotionSourceScope": "Source scope",
"canvases:promotionSourceActor": "Source actor",
"canvases:promotionSourceTask": "Source task",
"canvases:promotionSourceSession": "Source session",
"canvases:promotionTargetScope": "Target scope",
"canvases:promotionPlacement": "Workspace placement",
"common:cancel": "Cancel",
"common:close": "Close",
@@ -114,13 +139,19 @@ vi.mock("@kandev/ui/dialog", () => ({
vi.mock("@/lib/api/domains/canvas-api", () => ({
approveCanvasRelease: mockApproveCanvasRelease,
confirmCanvasPromotion: mockConfirmCanvasPromotion,
enableCanvasWorkspaceData: mockEnableCanvasWorkspaceData,
listCanvasReleases: mockListCanvasReleases,
rejectCanvasRelease: mockRejectCanvasRelease,
requestCanvasWorkspaceData: mockRequestCanvasWorkspaceData,
requestCanvasPromotion: mockRequestCanvasPromotion,
rollbackCanvas: mockRollbackCanvas,
}));
import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs";
import {
CanvasPromotionDialog,
CanvasReleaseDialog,
CanvasWorkspaceDataDialog,
} from "./canvas-lifecycle-dialogs";
const canvas: Canvas = {
id: "canvas-1",
@@ -143,6 +174,8 @@ const COPY = {
rollbackRelease: "Roll back release",
promotingCanvas: "Promoting canvas",
confirmPromotion: "Confirm promotion",
enableWorkspaceData: ENABLE_WORKSPACE_DATA_TEXT,
enablingWorkspaceData: "Enabling workspace data",
cancel: "Cancel",
} as const;
@@ -180,6 +213,8 @@ beforeEach(() => {
source_session_name: "Source session name",
current_scope: "task",
target_scope: "workspace",
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
placement: "workspace_sidebar",
permissions: {
reads: [TASKS_READ_PERMISSION],
@@ -190,6 +225,16 @@ beforeEach(() => {
},
});
mockConfirmCanvasPromotion.mockReset();
mockRequestCanvasWorkspaceData.mockReset().mockResolvedValue({
canvas,
active_release_id: "release-1",
permission_digest: "digest-1",
grant_generation: 7,
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
permissions: { reads: ["tasks"] },
});
mockEnableCanvasWorkspaceData.mockReset();
mockListCanvasReleases.mockReset().mockResolvedValue({ releases: [] });
mockApproveCanvasRelease.mockReset();
mockRejectCanvasRelease.mockReset();
@@ -206,7 +251,7 @@ describe("CanvasPromotionDialog", () => {
render(<CanvasPromotionDialog canvas={canvas} open onOpenChange={vi.fn()} />);
await waitFor(() =>
expect(screen.getByTestId("canvas-promotion-source-scope").textContent).toContain("task"),
expect(screen.getByTestId("canvas-promotion-source-scope").textContent).toContain("Task"),
);
expect(screen.getByTestId("canvas-promotion-source-task").textContent).toContain(
@@ -219,7 +264,7 @@ describe("CanvasPromotionDialog", () => {
"workspace_sidebar",
);
expect(screen.getByText("API reads")).toBeTruthy();
expect(screen.getByText("Read task data")).toBeTruthy();
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
expect(screen.getByText("API writes")).toBeTruthy();
expect(screen.getByText("Write task data")).toBeTruthy();
expect(screen.getByText("Events")).toBeTruthy();
@@ -227,6 +272,11 @@ describe("CanvasPromotionDialog", () => {
expect(screen.getByText("Shared state")).toBeTruthy();
expect(screen.getByText("External origins")).toBeTruthy();
expect(screen.getByText("https://example.test")).toBeTruthy();
expect(screen.getByTestId("canvas-promotion-current-data-scope").textContent).toBe("Task data");
expect(screen.getByTestId("canvas-promotion-target-data-scope").textContent).toBe(
"Workspace data",
);
expect(screen.getByText("Promotion expands data access.")).toBeTruthy();
});
it("maps stable API error codes to localized copy", async () => {
@@ -309,6 +359,72 @@ describe("CanvasPromotionDialog", () => {
});
});
describe("CanvasWorkspaceDataDialog", () => {
it("shows the active release, current and target data scopes, and declared permissions", async () => {
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={vi.fn()} />);
expect((await screen.findByTestId("canvas-workspace-data-release")).textContent).toBe(
"release-1",
);
expect(screen.getByTestId("canvas-workspace-data-current-scope").textContent).toBe("Task data");
expect(screen.getByTestId("canvas-workspace-data-target-scope").textContent).toBe(
"Workspace data",
);
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
});
it("enables exactly the reviewed release and grant generation", async () => {
const updated = { ...canvas, data_scope_kind: "workspace" };
const onCompleted = vi.fn();
const onOpenChange = vi.fn();
mockEnableCanvasWorkspaceData.mockResolvedValue(updated);
render(
<CanvasWorkspaceDataDialog
canvas={canvas}
open
onOpenChange={onOpenChange}
onCompleted={onCompleted}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: COPY.enableWorkspaceData }));
await waitFor(() =>
expect(mockEnableCanvasWorkspaceData).toHaveBeenCalledWith(canvas.id, {
expected_release_id: "release-1",
expected_permission_digest: "digest-1",
expected_grant_generation: 7,
}),
);
await waitFor(() => expect(onCompleted).toHaveBeenCalledWith(updated));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("cancels without changing workspace data access", async () => {
const onOpenChange = vi.fn();
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={onOpenChange} />);
fireEvent.click(await screen.findByRole("button", { name: COPY.cancel }));
expect(mockEnableCanvasWorkspaceData).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("uses a full-height phone review with one scroll region and safe-area actions", async () => {
responsive.isMobile = true;
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={vi.fn()} />);
await screen.findByTestId("canvas-workspace-data-release");
expect(screen.getByTestId("canvas-workspace-data-dialog").className).toContain("h-dvh");
expect(screen.getByTestId("canvas-workspace-data-review-scroll").className).toContain(
"overflow-y-auto",
);
expect(screen.getByRole("button", { name: COPY.enableWorkspaceData }).className).toContain(
"min-h-12",
);
});
});
describe("CanvasReleaseDialog validation", () => {
it("localizes stable release validation codes", async () => {
mockListCanvasReleases.mockResolvedValue({
@@ -353,7 +469,7 @@ describe("CanvasReleaseDialog permission review", () => {
expect(screen.getByTestId("canvas-release-permissions-release-pending")).toBeTruthy(),
);
expect(screen.getByText("Declared permissions")).toBeTruthy();
expect(screen.getByText("Read task data")).toBeTruthy();
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
expect(screen.getByText("Write task messages")).toBeTruthy();
expect(screen.getByText(EXTERNAL_ORIGIN)).toBeTruthy();
expect(screen.getByText("New")).toBeTruthy();
@@ -11,11 +11,15 @@ import {
DialogTitle,
} from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import {
enableCanvasWorkspaceData,
confirmCanvasPromotion,
requestCanvasWorkspaceData,
requestCanvasPromotion,
type Canvas,
type CanvasPromotionPreview,
type CanvasWorkspaceDataPreview,
} from "@/lib/api/domains/canvas-api";
import { canvasErrorMessage } from "@/lib/api/domains/canvas-error-copy";
import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle";
@@ -31,6 +35,12 @@ export { CanvasReleaseDialog } from "./canvas-release-review";
const CANVAS_ACTION_FAILED_KEY = "canvases:actionFailed";
const canvasActionClassName = controlSizingClassName("standard", "cursor-pointer");
const promotionScopeKeys = {
workspaceData: "canvases:workspaceDataScope",
taskData: "canvases:taskDataScope",
workspacePlacement: "canvases:workspacePlacementScope",
taskPlacement: "canvases:taskPlacementScope",
} as const;
function useCanvasPromotion(
canvas: Canvas | null,
@@ -104,40 +114,137 @@ type PromotionMetadataRow = {
testId: string;
};
function promotionScopeLabel(
scope: string | undefined,
workspaceKey: string,
taskKey: string,
t: (key: string) => string,
) {
if (scope === "workspace") return t(workspaceKey);
if (scope === "task") return t(taskKey);
return scope;
}
function buildPromotionMetadataRows(
canvas: Canvas | null,
preview: CanvasPromotionPreview,
t: (key: string) => string,
): PromotionMetadataRow[] {
const sourceTaskID =
preview.source_task_id ?? preview.origin_task_id ?? canvas?.origin_task_id ?? canvas?.task_id;
const sourceTask = sourceTaskID ? canvasSourceLabel(preview.source_task_title, t) : undefined;
const sourceSessionID = preview.source_session_id ?? canvas?.created_by_session_id;
const sourceSession = sourceSessionID
? canvasSourceLabel(preview.source_session_name, t)
: undefined;
const row = (
value: string | undefined,
labelKey: string,
testId: string,
): PromotionMetadataRow | null => (value ? { label: t(labelKey), value, testId } : null);
const placementRow = optionalPromotionRow(
preview.placement,
"canvases:promotionPlacement",
"canvas-promotion-placement",
t,
);
return [
row(
preview.current_scope ?? canvas?.scope_kind,
"canvases:promotionSourceScope",
"canvas-promotion-source-scope",
),
row(
preview.source_actor_kind ? canvasSourceActorLabel(preview.source_actor_kind, t) : undefined,
...buildPromotionPlacementRows(canvas, preview, t),
...buildPromotionSourceRows(canvas, preview, t),
...buildPromotionDataScopeRows(canvas, preview, t),
...(placementRow ? [placementRow] : []),
];
}
function buildPromotionPlacementRows(
canvas: Canvas | null,
preview: CanvasPromotionPreview,
t: (key: string) => string,
): PromotionMetadataRow[] {
return [
{
label: t("canvases:promotionSourceScope"),
value:
promotionScopeLabel(
preview.current_scope ?? canvas?.scope_kind,
promotionScopeKeys.workspacePlacement,
promotionScopeKeys.taskPlacement,
t,
) ?? "",
testId: "canvas-promotion-source-scope",
},
{
label: t("canvases:promotionTargetScope"),
value:
promotionScopeLabel(
preview.target_scope,
promotionScopeKeys.workspacePlacement,
promotionScopeKeys.taskPlacement,
t,
) ?? "",
testId: "canvas-promotion-target-scope",
},
].filter((row) => row.value !== "");
}
function buildPromotionSourceRows(
canvas: Canvas | null,
preview: CanvasPromotionPreview,
t: (key: string) => string,
): PromotionMetadataRow[] {
const taskID =
preview.source_task_id ?? preview.origin_task_id ?? canvas?.origin_task_id ?? canvas?.task_id;
const sessionID = preview.source_session_id ?? canvas?.created_by_session_id;
const actor = preview.source_actor_kind
? canvasSourceActorLabel(preview.source_actor_kind, t)
: undefined;
const task = taskID ? canvasSourceLabel(preview.source_task_title, t) : undefined;
const session = sessionID ? canvasSourceLabel(preview.source_session_name, t) : undefined;
return [
optionalPromotionRow(
actor,
"canvases:promotionSourceActor",
"canvas-promotion-source-actor",
t,
),
row(sourceTask, "canvases:promotionSourceTask", "canvas-promotion-source-task"),
row(sourceSession, "canvases:promotionSourceSession", "canvas-promotion-source-session"),
row(preview.target_scope, "canvases:promotionTargetScope", "canvas-promotion-target-scope"),
row(preview.placement, "canvases:promotionPlacement", "canvas-promotion-placement"),
].filter((item): item is PromotionMetadataRow => item !== null);
optionalPromotionRow(task, "canvases:promotionSourceTask", "canvas-promotion-source-task", t),
optionalPromotionRow(
session,
"canvases:promotionSourceSession",
"canvas-promotion-source-session",
t,
),
].filter((row): row is PromotionMetadataRow => row !== null);
}
function buildPromotionDataScopeRows(
canvas: Canvas | null,
preview: CanvasPromotionPreview,
t: (key: string) => string,
): PromotionMetadataRow[] {
const currentScope =
preview.current_data_scope_kind ?? canvas?.data_scope_kind ?? canvas?.scope_kind;
return [
{
label: t("canvases:promotionCurrentDataScope"),
value:
promotionScopeLabel(
currentScope,
promotionScopeKeys.workspaceData,
promotionScopeKeys.taskData,
t,
) ?? "",
testId: "canvas-promotion-current-data-scope",
},
{
label: t("canvases:promotionTargetDataScope"),
value:
promotionScopeLabel(
preview.target_data_scope_kind,
promotionScopeKeys.workspaceData,
promotionScopeKeys.taskData,
t,
) ?? "",
testId: "canvas-promotion-target-data-scope",
},
].filter((row) => row.value !== "");
}
function optionalPromotionRow(
value: string | undefined,
labelKey: string,
testId: string,
t: (key: string) => string,
): PromotionMetadataRow | null {
return value ? { label: t(labelKey), value, testId } : null;
}
function PromotionMetadata({
@@ -198,6 +305,11 @@ export function CanvasPromotionDialog({
{preview && (
<div className="space-y-3 text-sm">
<p>{t("canvases:promotionScopeChange")}</p>
<p>
{preview.current_data_scope_kind === preview.target_data_scope_kind
? t("canvases:promotionDataAccessPreserved")
: t("canvases:promotionDataAccessChanges")}
</p>
<PromotionMetadata canvas={canvas} preview={preview} />
{permissionGroups.length > 0 ? (
<div className="max-h-48 space-y-3 overflow-y-auto rounded-md border p-3">
@@ -235,3 +347,221 @@ export function CanvasPromotionDialog({
</Dialog>
);
}
function useCanvasWorkspaceDataReview(
canvas: Canvas | null,
open: boolean,
onOpenChange: (open: boolean) => void,
onCompleted?: (canvas: Canvas) => void,
) {
const { t } = useTranslation();
const lifecycleRevision = useCanvasLifecycleRevision();
const [preview, setPreview] = useState<CanvasWorkspaceDataPreview | null>(null);
const [loading, setLoading] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasId = canvas?.id;
useEffect(() => {
if (!open || !canvasId) return;
let cancelled = false;
setLoading(true);
setPreview(null);
setError(null);
requestCanvasWorkspaceData(canvasId)
.then((value) => {
if (!cancelled) setPreview(value);
})
.catch((reason: unknown) => {
if (!cancelled) setError(canvasErrorMessage(reason, t, CANVAS_ACTION_FAILED_KEY));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [canvasId, lifecycleRevision, open, t]);
const permissionGroups = buildCanvasPermissionGroups(preview?.permissions, undefined, t);
const unsupportedPermissions = hasUnsupportedPermissions(permissionGroups);
const confirm = useCallback(async () => {
if (
!canvas ||
!preview?.active_release_id ||
!preview.permission_digest ||
preview.grant_generation === undefined ||
unsupportedPermissions
) {
return;
}
setConfirming(true);
setError(null);
try {
const updated = await enableCanvasWorkspaceData(canvas.id, {
expected_release_id: preview.active_release_id,
expected_permission_digest: preview.permission_digest,
expected_grant_generation: preview.grant_generation,
});
onCompleted?.(updated);
onOpenChange(false);
} catch (reason: unknown) {
setError(canvasErrorMessage(reason, t, CANVAS_ACTION_FAILED_KEY));
} finally {
setConfirming(false);
}
}, [canvas, onCompleted, onOpenChange, preview, t, unsupportedPermissions]);
return { preview, loading, confirming, error, confirm, unsupportedPermissions };
}
function WorkspaceDataReviewDetails({
preview,
loading,
error,
t,
}: {
preview: CanvasWorkspaceDataPreview | null;
loading: boolean;
error: string | null;
t: (key: string) => string;
}) {
return (
<div
className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-4 sm:p-6"
data-testid="canvas-workspace-data-review-scroll"
>
{loading && <p role="status">{t("canvases:loadingPermissions")}</p>}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
{preview && (
<>
<p className="text-sm">{t("canvases:workspaceDataReviewScopeChange")}</p>
<dl className="grid gap-2 rounded-md bg-muted/40 p-3 text-sm">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{t("canvases:activeRelease")}</dt>
<dd className="min-w-0 break-all" data-testid="canvas-workspace-data-release">
{preview.active_release_id}
</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{t("canvases:promotionCurrentDataScope")}</dt>
<dd data-testid="canvas-workspace-data-current-scope">
{t(
preview.current_data_scope_kind === "workspace"
? "canvases:workspaceDataScope"
: "canvases:taskDataScope",
)}
</dd>
</div>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{t("canvases:promotionTargetDataScope")}</dt>
<dd data-testid="canvas-workspace-data-target-scope">
{t(
preview.target_data_scope_kind === "task"
? "canvases:taskDataScope"
: "canvases:workspaceDataScope",
)}
</dd>
</div>
</dl>
<CanvasPermissionSummary permissions={preview.permissions} />
</>
)}
</div>
);
}
function WorkspaceDataReviewFooter({
isMobile,
disabled,
confirming,
onCancel,
onConfirm,
t,
}: {
isMobile: boolean;
disabled: boolean;
confirming: boolean;
onCancel: () => void;
onConfirm: () => void;
t: (key: string) => string;
}) {
return (
<DialogFooter
className={
isMobile
? "shrink-0 grid grid-cols-2 border-t border-border/70 p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "shrink-0 flex-row border-t border-border/70 p-4 sm:p-6"
}
>
<Button variant="outline" className={canvasActionClassName} onClick={onCancel}>
{t("common:cancel")}
</Button>
<Button
className={`${canvasActionClassName}${isMobile ? " min-h-12" : ""}`}
disabled={disabled}
onClick={onConfirm}
>
{confirming ? t("canvases:enablingWorkspaceData") : t("canvases:enableWorkspaceData")}
</Button>
</DialogFooter>
);
}
export function CanvasWorkspaceDataDialog({
canvas,
open,
onOpenChange,
onCompleted,
}: {
canvas: Canvas | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCompleted?: (canvas: Canvas) => void;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const { preview, loading, confirming, error, confirm, unsupportedPermissions } =
useCanvasWorkspaceDataReview(canvas, open, onOpenChange, onCompleted);
const surfaceClassName = isMobile
? "!left-0 !top-0 !h-dvh !max-h-dvh !w-screen !max-w-none !translate-x-0 !translate-y-0 flex flex-col gap-0 overflow-hidden rounded-none p-0 [padding-top:max(1rem,env(safe-area-inset-top))]"
: "flex max-h-[min(90dvh,48rem)] w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-lg";
// The footer uses a labeled Cancel action instead of an icon-only close button.
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-testid="canvas-workspace-data-dialog"
className={surfaceClassName}
showCloseButton={false}
>
<DialogHeader className="shrink-0 border-b border-border/70 p-4 text-left sm:p-6">
<DialogTitle>{t("canvases:enableWorkspaceData")}</DialogTitle>
<DialogDescription>
{t("canvases:workspaceDataReviewDescription", { title: canvas?.title ?? "" })}
</DialogDescription>
</DialogHeader>
<WorkspaceDataReviewDetails preview={preview} loading={loading} error={error} t={t} />
<WorkspaceDataReviewFooter
isMobile={isMobile}
disabled={
!preview ||
!preview.active_release_id ||
!preview.permission_digest ||
preview.grant_generation === undefined ||
unsupportedPermissions ||
confirming
}
confirming={confirming}
onCancel={() => onOpenChange(false)}
onConfirm={() => void confirm()}
t={t}
/>
</DialogContent>
</Dialog>
);
}
@@ -76,7 +76,7 @@ test.describe("authenticated same-origin canvas runtime", () => {
true,
);
await frame.getByTestId("canvas-fixture-continue").click();
await frame.getByTestId("canvas-fixture-continue").dispatchEvent("click");
await expect(frame.getByTestId("canvas-fixture-message-status")).toHaveText("accepted");
await expect
.poll(async () =>
+106 -26
View File
@@ -20,6 +20,8 @@ export type CanvasRecord = {
workspace_id: string;
task_id?: string;
scope_kind?: string;
data_scope_kind?: string;
grant_generation?: number;
status?: string;
active_release_id?: string;
active_release_status?: string;
@@ -47,6 +49,31 @@ export type SeededCanvas = {
session: SessionPage;
};
export async function seedCanvasWorkspacePreview(apiClient: ApiClient, seedData: SeedData) {
const previewTask = await apiClient.seedTask(
seedData.workspaceId,
"E2E Canvas Workspace Preview Task",
{ workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId },
);
const foreignWorkspaceName = "E2E Canvas Foreign Workspace";
try {
const foreignWorkspace = await apiClient.createWorkspace(foreignWorkspaceName);
return {
workspaceTaskId: previewTask.task_id,
foreignWorkspaceId: foreignWorkspace.id,
cleanup: async () => {
await apiClient.deleteTask(previewTask.task_id).catch(() => undefined);
await apiClient
.deleteWorkspace(foreignWorkspace.id, foreignWorkspaceName)
.catch(() => undefined);
},
};
} catch (error) {
await apiClient.deleteTask(previewTask.task_id).catch(() => undefined);
throw error;
}
}
export async function expectCanvasFrameFillsHost(page: Page): Promise<void> {
await expect
.poll(
@@ -182,6 +209,7 @@ export async function waitForSessionWorkspace(
export type CanvasSourceOptions = {
noPermissions?: boolean;
minimalPermissions?: boolean;
foreignWorkspaceId?: string;
};
function canvasCapabilities(options?: CanvasSourceOptions): string[] {
@@ -230,10 +258,12 @@ function canvasManifest(canvas: CanvasRecord, options?: CanvasSourceOptions): st
].join("\n");
}
function canvasFixtureScript(canvas: CanvasRecord): string {
function canvasFixtureScript(canvas: CanvasRecord, options?: CanvasSourceOptions): string {
const taskID = JSON.stringify(canvas.task_id ?? "");
const foreignWorkspaceID = JSON.stringify(options?.foreignWorkspaceId ?? "");
return String.raw`(() => {
const taskId = ${taskID};
const foreignWorkspaceId = ${foreignWorkspaceID};
const text = (testId, value) => {
const element = document.querySelector('[data-testid="' + testId + '"]');
if (element) element.textContent = String(value);
@@ -260,6 +290,23 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
let streamReader;
let streamCompletion = Promise.resolve();
const renderTasks = (tasks) => {
const list = document.querySelector('[data-testid="canvas-fixture-task-list"]');
if (!list) return;
list.replaceChildren();
tasks.forEach((candidate) => {
const item = document.createElement("article");
item.className = "task-item";
const title = document.createElement("h2");
title.textContent = candidate.title || "Workspace task";
const status = document.createElement("span");
status.className = "task-status";
status.textContent = candidate.status || "Open";
item.append(title, status);
list.append(item);
});
};
const renderAppearance = () => {
const root = document.documentElement;
const styles = getComputedStyle(root);
@@ -275,12 +322,22 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
});
const loadProjection = async () => {
const [context, taskPage, workflowPage] = await Promise.all([
const [context, tasks, workflowPage] = await Promise.all([
api("./_kandev/v1/context"),
api("./_kandev/v1/data/tasks?limit=10"),
(async () => {
const items = [];
let cursor = "";
do {
const query = new URLSearchParams({ limit: "1" });
if (cursor) query.set("cursor", cursor);
const page = await api("./_kandev/v1/data/tasks?" + query.toString());
items.push(...(page.items || []));
cursor = page.page_info?.next_cursor || "";
} while (cursor);
return items;
})(),
api("./_kandev/v1/data/workflows?limit=10"),
]);
const tasks = taskPage.items || [];
task = tasks.find((candidate) => candidate.id === taskId) || tasks[0];
const workflow = (workflowPage.items || []).find(
(candidate) => candidate.id === task?.workflow_id,
@@ -293,8 +350,19 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
}
text("canvas-fixture-context", context.task_id || "workspace");
text("canvas-fixture-task-count", tasks.length);
text("canvas-fixture-task-ids", tasks.map((candidate) => candidate.id).join(","));
renderTasks(tasks);
text("canvas-fixture-workflow-count", workflowPage.items?.length || 0);
text("canvas-fixture-step-id", task?.workflow_step_id || "");
text("canvas-fixture-refresh-status", "refreshed");
if (foreignWorkspaceId) {
try {
await api("./_kandev/v1/data/tasks?workspace_id=" + encodeURIComponent(foreignWorkspaceId));
text("canvas-fixture-foreign-workspace-status", "unexpected-allowed");
} catch (error) {
text("canvas-fixture-foreign-workspace-status", "denied:" + error.status);
}
}
};
const parseEvent = (block) => {
@@ -425,6 +493,12 @@ function canvasFixtureScript(canvas: CanvasRecord): string {
document.querySelector('[data-testid="canvas-fixture-resync"]')?.addEventListener("click", () => {
void connectEvents("old-generation:1");
});
document.querySelector('[data-testid="canvas-fixture-refresh"]')?.addEventListener("click", () => {
text("canvas-fixture-refresh-status", "refreshing");
void loadProjection().catch((error) => {
text("canvas-fixture-refresh-status", "error:" + error.status);
});
});
text("canvas-fixture-script", "inline-ready");
renderAppearance();
@@ -445,33 +519,39 @@ export function writeCanvasSource(
[
"<!doctype html>",
'<html lang="en">',
' <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>E2E Plugin Canvas</title><link rel="stylesheet" href="./styles.css"><script src="./appearance.js"></script></head>',
' <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>E2E Plugin Canvas</title><script src="./appearance.js"></script>',
" <style>",
" :root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #172033; background: #f7f8fc; font-synthesis: none; }",
" * { box-sizing: border-box; } body { margin: 0; padding: 32px; } .canvas-shell { max-width: 920px; margin: 0 auto; }",
" .canvas-header { margin-bottom: 24px; } .eyebrow { margin: 0 0 8px; color: #6257df; font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }",
" h1 { margin: 0; font-size: clamp(24px, 4vw, 34px); line-height: 1.2; letter-spacing: -.03em; } .summary { margin: 8px 0 0; color: #687187; font-size: 14px; }",
" .canvas-card { padding: 20px; border: 1px solid #e2e6ef; border-radius: 16px; background: white; box-shadow: 0 10px 32px #27345b0a; }",
" .list-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; } .list-heading h2 { margin: 0; font-size: 15px; }",
" .task-count { color: #687187; font-size: 12px; } .task-list { display: grid; gap: 10px; }",
" .task-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 58px; padding: 12px 14px; border: 1px solid #e8ebf2; border-radius: 10px; background: #fff; }",
" .task-item h2 { margin: 0; font-size: 14px; font-weight: 600; } .task-status { flex: none; padding: 4px 8px; border-radius: 999px; background: #f0efff; color: #5046c7; font-size: 11px; font-weight: 600; text-transform: capitalize; }",
' .canvas-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } button { min-height: 34px; padding: 7px 11px; border: 1px solid #dfe3ec; border-radius: 8px; background: white; color: #384158; font: inherit; font-size: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; } button:hover { background: #f6f7fb; } button[data-testid="canvas-fixture-refresh"] { border-color: #5548e8; background: #5548e8; color: white; }',
" .diagnostics { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }",
' @media (max-width: 600px) { body { padding: 20px 16px; } .canvas-card { padding: 16px; } .task-item { align-items: flex-start; flex-direction: column; gap: 8px; } .canvas-actions button:not([data-testid="canvas-fixture-refresh"]) { display: none; } }',
" </style></head>",
" <body>",
' <main class="canvas-shell" data-testid="canvas-fixture-content">',
' <header class="canvas-header"><p class="eyebrow" data-testid="canvas-fixture-eyebrow">E2E Plugin Canvas</p><h1>E2E Plugin Canvas</h1></header>',
' <header class="canvas-header"><p class="eyebrow" data-testid="canvas-fixture-eyebrow">Workspace preview</p><h1>Your workspace tasks</h1><p class="summary">A live view of work across this workspace.</p></header>',
' <section class="canvas-card">',
' <p data-testid="canvas-fixture-script">loading</p>',
' <p data-testid="canvas-fixture-context">loading</p>',
' <p data-testid="canvas-fixture-task-count">0</p>',
' <p data-testid="canvas-fixture-workflow-count">0</p>',
' <p data-testid="canvas-fixture-step-id">loading</p>',
' <p data-testid="canvas-fixture-message-status">idle</p>',
' <p data-testid="canvas-fixture-move-status">idle</p>',
' <p data-testid="canvas-fixture-state-status">idle</p>',
' <p data-testid="canvas-fixture-sse-status">loading</p>',
' <p data-testid="canvas-fixture-sse-events">0</p>',
' <p data-testid="canvas-fixture-sse-resync">idle</p>',
' <p data-testid="canvas-fixture-appearance-mode">loading</p>',
' <p data-testid="canvas-fixture-appearance-background">loading</p>',
' <p data-testid="canvas-fixture-appearance-color-scheme">loading</p>',
' <button type="button" data-testid="canvas-fixture-continue">Continue</button>',
' <button type="button" data-testid="canvas-fixture-move">Move workflow step</button>',
' <button type="button" data-testid="canvas-fixture-state">Recover state</button>',
' <button type="button" data-testid="canvas-fixture-reconnect">Reconnect events</button>',
' <button type="button" data-testid="canvas-fixture-resync">Force resync</button>',
' <div class="list-heading"><h2>All workspace tasks</h2><span class="task-count"><span data-testid="canvas-fixture-task-count">0</span> tasks</span></div>',
' <div class="task-list" data-testid="canvas-fixture-task-list" aria-label="Workspace tasks"></div>',
' <div class="canvas-actions"><button type="button" data-testid="canvas-fixture-refresh">Refresh list</button></div>',
' <div class="diagnostics" aria-hidden="true">',
' <p data-testid="canvas-fixture-script">loading</p><p data-testid="canvas-fixture-context">loading</p><p data-testid="canvas-fixture-task-ids"></p>',
' <p data-testid="canvas-fixture-refresh-status">idle</p><p data-testid="canvas-fixture-foreign-workspace-status">not-tested</p><p data-testid="canvas-fixture-workflow-count">0</p><p data-testid="canvas-fixture-step-id">loading</p>',
' <p data-testid="canvas-fixture-message-status">idle</p><p data-testid="canvas-fixture-move-status">idle</p><p data-testid="canvas-fixture-state-status">idle</p><p data-testid="canvas-fixture-sse-status">loading</p>',
' <p data-testid="canvas-fixture-sse-events">0</p><p data-testid="canvas-fixture-sse-resync">idle</p><p data-testid="canvas-fixture-appearance-mode">loading</p><p data-testid="canvas-fixture-appearance-background">loading</p><p data-testid="canvas-fixture-appearance-color-scheme">loading</p>',
' <button type="button" data-testid="canvas-fixture-continue">Continue</button><button type="button" data-testid="canvas-fixture-move">Move workflow step</button><button type="button" data-testid="canvas-fixture-state">Recover state</button>',
' <button type="button" data-testid="canvas-fixture-reconnect">Reconnect events</button><button type="button" data-testid="canvas-fixture-resync">Force resync</button>',
" </div>",
" </section>",
" </main>",
` <script>${canvasFixtureScript(canvas)}</script>`,
` <script>${canvasFixtureScript(canvas, options)}</script>`,
' <script src="./script.js"></script>',
" </body>",
"</html>",
@@ -13,6 +13,7 @@ import {
publishTaskCanvas,
removeCanvas,
promoteCanvas,
seedCanvasWorkspacePreview,
seedTaskCanvas,
waitForTaskCanvas,
} from "./canvas-fixture";
@@ -348,6 +349,117 @@ test.describe("Plugin-backed canvases on mobile", () => {
}
});
test("reviews workspace data for a legacy task canvas before enabling it", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(150_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
const canvasIds: string[] = [];
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
canvasIds.push(seeded.canvas.id);
const activeCanvas = await approvePendingCanvasThroughHost(
testPage,
apiClient,
seeded.canvas,
);
const legacyCanvas = { ...activeCanvas, data_scope_kind: "task" };
const permissionDigest = "legacy-task-canvas-review";
const grantGeneration = activeCanvas.grant_generation ?? 0;
let workspaceDataEnabled = false;
let submittedReview: Record<string, unknown> | undefined;
const canvasURL = new RegExp(`/api/v1/canvases/${activeCanvas.id}(?:\\?.*)?$`);
await testPage.route(canvasURL, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...activeCanvas,
data_scope_kind: workspaceDataEnabled ? "workspace" : "task",
}),
});
});
await testPage.route(
new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data-preview(?:\\?.*)?$`),
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
canvas: legacyCanvas,
active_release_id: activeCanvas.active_release_id,
permission_digest: permissionDigest,
grant_generation: grantGeneration,
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
permissions: {
reads: ["tasks"],
writes: ["messages"],
events: ["task.updated"],
shared_state: false,
external_origins: [],
},
}),
});
},
);
await testPage.route(
new RegExp(`/api/v1/canvases/${activeCanvas.id}/workspace-data(?:\\?.*)?$`),
async (route) => {
submittedReview = route.request().postDataJSON() as Record<string, unknown>;
workspaceDataEnabled = true;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ...legacyCanvas, data_scope_kind: "workspace" }),
});
},
);
await testPage.goto(canvasHref(activeCanvas.id));
await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Task data");
await testPage.getByTestId("canvas-mobile-actions").tap();
const actions = testPage.getByTestId("canvas-mobile-actions-sheet");
await actions.getByTestId("canvas-action-enable-workspace-data").tap();
const review = testPage.getByTestId("canvas-workspace-data-dialog");
await expect(review).toBeVisible();
await expect(review.getByTestId("canvas-workspace-data-release")).toHaveText(
activeCanvas.active_release_id ?? "",
);
await expect(review.getByTestId("canvas-workspace-data-current-scope")).toHaveText(
"Task data",
);
await expect(review.getByTestId("canvas-workspace-data-target-scope")).toHaveText(
"Workspace data",
);
await expect(review.getByText("Read task data")).toBeVisible();
await review.getByRole("button", { name: "Enable workspace data", exact: true }).tap();
await expect(review).toHaveCount(0);
await expect(testPage.getByTestId("canvas-data-scope")).toHaveText("Workspace data");
expect(submittedReview).toEqual({
expected_release_id: activeCanvas.active_release_id,
expected_permission_digest: permissionDigest,
expected_grant_generation: grantGeneration,
});
await testPage.getByTestId("canvas-mobile-actions").tap();
await expect(
testPage
.getByTestId("canvas-mobile-actions-sheet")
.getByTestId("canvas-action-enable-workspace-data"),
).toHaveCount(0);
} finally {
await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId)));
await releaseFeature();
}
});
test("uses a focused route, workspace navigation, and an inset action drawer", async ({
testPage,
apiClient,
@@ -358,8 +470,12 @@ test.describe("Plugin-backed canvases on mobile", () => {
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
const canvasIds: string[] = [];
let workspacePreview: Awaited<ReturnType<typeof seedCanvasWorkspacePreview>> | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData);
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true, {
foreignWorkspaceId: workspacePreview.foreignWorkspaceId,
});
canvasIds.push(seeded.canvas.id);
const activeCanvas = await approvePendingCanvasThroughHost(
testPage,
@@ -390,6 +506,21 @@ test.describe("Plugin-backed canvases on mobile", () => {
);
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected");
const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length;
expect(expectedTaskCount).toBeGreaterThanOrEqual(2);
await fixture.getByTestId("canvas-fixture-refresh").tap();
await expect(fixture.getByTestId("canvas-fixture-refresh-status")).toHaveText("refreshed");
await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText(
String(expectedTaskCount),
);
await expect(fixture.locator(".task-item")).toHaveCount(expectedTaskCount);
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(seeded.taskId);
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(
workspacePreview.workspaceTaskId,
);
await expect(fixture.getByTestId("canvas-fixture-foreign-workspace-status")).toHaveText(
"denied:403",
);
const actionsButton = testPage.getByTestId("canvas-mobile-actions");
await expect(actionsButton).toBeVisible();
@@ -409,7 +540,7 @@ test.describe("Plugin-backed canvases on mobile", () => {
const promotionDialog = testPage.getByTestId("canvas-promotion-dialog");
await expect(promotionDialog).toBeVisible();
await expect(promotionDialog.getByTestId("canvas-promotion-target-scope")).toHaveText(
"workspace",
"Workspace",
);
await promotionDialog.getByRole("button", { name: "Confirm promotion", exact: true }).tap();
@@ -480,6 +611,7 @@ test.describe("Plugin-backed canvases on mobile", () => {
).toBeVisible();
} finally {
await Promise.all(canvasIds.map((canvasId) => removeCanvas(apiClient, canvasId)));
await workspacePreview?.cleanup();
await releaseFeature();
}
});
@@ -11,6 +11,7 @@ import {
expectCanvasFrameFillsHost,
listCanvasReleases,
removeCanvas,
seedCanvasWorkspacePreview,
seedTaskCanvas,
waitForSessionWorkspace,
writeCanvasSource,
@@ -150,8 +151,12 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
let workspacePreview: Awaited<ReturnType<typeof seedCanvasWorkspacePreview>> | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
workspacePreview = await seedCanvasWorkspacePreview(apiClient, seedData);
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, false, {
foreignWorkspaceId: workspacePreview.foreignWorkspaceId,
});
canvasId = seeded.canvas.id;
await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible();
@@ -281,7 +286,21 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
.getByTestId("canvas-fixture-appearance-background")
.textContent();
await expect(fixture.getByTestId("canvas-fixture-context")).toHaveText(seeded.taskId);
await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText("1");
const expectedTaskCount = (await apiClient.listTasks(seedData.workspaceId)).tasks.length;
expect(expectedTaskCount).toBeGreaterThanOrEqual(2);
await fixture.getByTestId("canvas-fixture-refresh").click();
await expect(fixture.getByTestId("canvas-fixture-refresh-status")).toHaveText("refreshed");
await expect(fixture.getByTestId("canvas-fixture-task-count")).toHaveText(
String(expectedTaskCount),
);
await expect(fixture.locator(".task-item")).toHaveCount(expectedTaskCount);
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(seeded.taskId);
await expect(fixture.getByTestId("canvas-fixture-task-ids")).toContainText(
workspacePreview.workspaceTaskId,
);
await expect(fixture.getByTestId("canvas-fixture-foreign-workspace-status")).toHaveText(
"denied:403",
);
await expect(fixture.getByTestId("canvas-fixture-workflow-count")).toHaveText("1");
await expect(fixture.getByTestId("canvas-fixture-step-id")).not.toHaveText("loading");
await expect(fixture.getByTestId("canvas-fixture-sse-status")).toHaveText("connected");
@@ -357,6 +376,7 @@ test.describe("Plugin-backed canvases in the desktop task workbench", () => {
.not.toBe(lightBackground);
} finally {
if (canvasId) await removeCanvas(apiClient, canvasId);
await workspacePreview?.cleanup();
await releaseFeature();
}
});
@@ -8,7 +8,7 @@ import {
seedTaskCanvas,
} from "./canvas-fixture";
test("task and workspace transition reads stay inside canvas scope", async ({
test("workspace transition groups remain available before and after task canvas promotion", async ({
testPage,
apiClient,
backend,
@@ -45,13 +45,16 @@ test("task and workspace transition reads stay inside canvas scope", async ({
item.to_workflow_step_id === seedData.startStepId,
),
).toBe(true);
const taskScopeGroups = await body.evaluate(
async (_, flowId) =>
(await fetch(`./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups`))
.status,
seedData.workflowId,
const prePromotionGroups = await body.evaluate(async (_, flowId) => {
const response = await fetch(
`./_kandev/v1/data/workflows/${encodeURIComponent(flowId)}/transition-groups?limit=200`,
);
return { status: response.status, body: await response.json() };
}, seedData.workflowId);
expect(prePromotionGroups.status).toBe(200);
expect(prePromotionGroups.body.items.some((item: { count: number }) => item.count > 0)).toBe(
true,
);
expect(taskScopeGroups).toBe(403);
const workspace = await promoteCanvas(apiClient, approved);
await testPage.goto(canvasHref(workspace.id));
const groups = await testPage
@@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
archiveCanvas,
confirmCanvasPromotion,
enableCanvasWorkspaceData,
getCanvasRuntime,
listTaskCanvases,
listWorkspaceCanvases,
requestCanvasWorkspaceData,
requestCanvasPromotion,
type CanvasPromotionPreview,
type CanvasRuntimeResponse,
@@ -153,7 +155,64 @@ describe("canvas host and lifecycle endpoints", () => {
}),
});
});
});
describe("workspace data scope endpoint", () => {
it("reviews and confirms a task canvas workspace data upgrade", async () => {
const preview = {
canvas: {
id: "canvas-1",
plugin_instance_id: "instance-1",
plugin_id: "plugin-1",
workspace_id: "workspace-1",
task_id: "task-1",
scope_kind: "task",
data_scope_kind: "task",
title: "Canvas",
status: "active",
},
active_release_id: "release-3",
permission_digest: "digest-3",
grant_generation: 9,
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
permissions: { reads: ["tasks"] },
};
fetchSpy
.mockResolvedValueOnce(jsonResponse(preview))
.mockResolvedValueOnce(jsonResponse({ ...preview.canvas, data_scope_kind: "workspace" }));
await expect(
requestCanvasWorkspaceData("canvas-1", { baseUrl: API_BASE_URL }),
).resolves.toEqual(preview);
await enableCanvasWorkspaceData(
"canvas-1",
{
expected_release_id: "release-3",
expected_permission_digest: "digest-3",
expected_grant_generation: 9,
},
{ baseUrl: API_BASE_URL },
);
expect(fetchSpy.mock.calls[0][0]).toBe(
`${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data-preview`,
);
expect(fetchSpy.mock.calls[1][0]).toBe(
`${API_BASE_URL}/api/v1/canvases/canvas-1/workspace-data`,
);
expect(fetchSpy.mock.calls[1][1]).toMatchObject({
method: "POST",
body: JSON.stringify({
expected_release_id: "release-3",
expected_permission_digest: "digest-3",
expected_grant_generation: 9,
}),
});
});
});
describe("canvas archive endpoint", () => {
it("archives with a mutation request and preserves the API response", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "canvas-1", status: "archived" }));
+49
View File
@@ -40,6 +40,7 @@ export type Canvas = {
origin_task_id?: string;
created_by_session_id?: string;
scope_kind: CanvasScopeKind | string;
data_scope_kind?: CanvasScopeKind | string;
title: string;
status: CanvasStatus | string;
active_release_id?: string;
@@ -53,6 +54,19 @@ export type Canvas = {
updated_at?: string;
};
export function canvasDataScope(canvas: Pick<Canvas, "scope_kind" | "data_scope_kind">): string {
return canvas.data_scope_kind || canvas.scope_kind;
}
export function canvasCanEnableWorkspaceData(canvas: Canvas): boolean {
return (
canvas.scope_kind === "task" &&
canvasDataScope(canvas) === "task" &&
canvas.status === "active" &&
canvas.active_release_status === "valid"
);
}
export type CanvasListResponse = {
canvases: Canvas[];
total?: number;
@@ -68,6 +82,7 @@ export type CanvasRuntimeResponse = {
canvas?: Canvas;
binding?: {
scope_kind?: CanvasScopeKind | string;
data_scope_kind?: CanvasScopeKind | string;
grant_generation?: number;
};
};
@@ -104,9 +119,21 @@ export type CanvasPromotionPreview = {
grant_generation?: number;
current_scope?: CanvasScopeKind | string;
target_scope?: CanvasScopeKind | string;
current_data_scope_kind?: CanvasScopeKind | string;
target_data_scope_kind?: CanvasScopeKind | string;
placement?: string;
};
export type CanvasWorkspaceDataPreview = {
canvas: Canvas;
active_release_id: string;
permission_digest: string;
grant_generation: number;
current_data_scope_kind: CanvasScopeKind | string;
target_data_scope_kind: CanvasScopeKind | string;
permissions?: CanvasPermissionReview;
};
export type CanvasReleaseListResponse = {
releases: CanvasRelease[];
};
@@ -198,6 +225,28 @@ export function confirmCanvasPromotion(
return mutate<Canvas>(canvasPath(canvasId, "/promotion"), "POST", review, options);
}
export function requestCanvasWorkspaceData(
canvasId: string,
options?: ApiRequestOptions,
): Promise<CanvasWorkspaceDataPreview> {
return fetchJson<CanvasWorkspaceDataPreview>(
canvasPath(canvasId, "/workspace-data-preview"),
options,
);
}
export function enableCanvasWorkspaceData(
canvasId: string,
review: {
expected_release_id: string;
expected_permission_digest: string;
expected_grant_generation: number;
},
options?: ApiRequestOptions,
): Promise<Canvas> {
return mutate<Canvas>(canvasPath(canvasId, "/workspace-data"), "POST", review, options);
}
export function listCanvasReleases(
canvasId: string,
options?: ApiRequestOptions,
@@ -18,6 +18,8 @@ const CANVAS_API_ERROR_TRANSLATIONS: Record<string, string> = {
artifact_unavailable: CANVAS_UNAVAILABLE_KEY,
active_release_missing: CANVAS_UNAVAILABLE_KEY,
promotion_review_stale: "canvases:actionFailed",
workspace_data_review_stale: "canvases:workspaceDataReviewStale",
workspace_owner_required: "canvases:workspaceOwnerRequired",
canvas_edit_stale: "canvases:actionFailed",
};
+16
View File
@@ -32,11 +32,25 @@
"promoteCanvas": "Promote canvas",
"promoteCanvasHelp": "Make this task canvas available in workspace navigation.",
"promoteCanvasUnavailable": "Promotion is available after a valid release.",
"taskDataScope": "Task data",
"workspaceDataScope": "Workspace data",
"taskPlacementScope": "Task",
"workspacePlacementScope": "Workspace",
"enableWorkspaceData": "Enable workspace data",
"enableWorkspaceDataHelp": "Allow this canvas to use its declared permissions across the current workspace. This does not move it into workspace navigation.",
"workspaceDataReviewDescription": "Review the active release and its declared permissions for {{title}}.",
"workspaceDataReviewScopeChange": "This changes data access only. The canvas stays in its current task.",
"workspaceDataReviewStale": "Canvas or permission details changed. Close this review and open it again.",
"workspaceOwnerRequired": "Only the current workspace owner can enable workspace data.",
"enablingWorkspaceData": "Enabling workspace data",
"activeRelease": "Active release",
"openInNewTab": "Open in new tab",
"openInNewTabHelp": "Open this canvas in a focused browser tab.",
"promoteCanvasDescription": "Review permissions before moving {{title}} to workspace scope.",
"loadingPermissions": "Loading requested permissions",
"promotionScopeChange": "Promotion makes this canvas available in workspace navigation.",
"promotionDataAccessPreserved": "Promotion changes placement only. Workspace data access already applies and will stay the same.",
"promotionDataAccessChanges": "Promotion changes placement and expands declared data permissions to the workspace.",
"noAdditionalPermissions": "No additional permissions were requested.",
"sharedState": "Shared state",
"promotingCanvas": "Promoting canvas",
@@ -76,6 +90,8 @@
"promotionSourceTask": "Source task",
"promotionSourceSession": "Source session",
"promotionTargetScope": "Target scope",
"promotionCurrentDataScope": "Data access before promotion",
"promotionTargetDataScope": "Data access after promotion",
"promotionPlacement": "Workspace placement",
"permissionReads": "API reads",
"permissionWrites": "API writes",
+16
View File
@@ -32,11 +32,25 @@
"promoteCanvas": "キャンバスを昇格",
"promoteCanvasHelp": "このタスクキャンバスをワークスペースのナビゲーションで利用できるようにします。",
"promoteCanvasUnavailable": "有効なリリース後に昇格できます。",
"taskDataScope": "タスクデータ",
"workspaceDataScope": "ワークスペースデータ",
"taskPlacementScope": "タスク",
"workspacePlacementScope": "ワークスペース",
"enableWorkspaceData": "ワークスペースデータを有効にする",
"enableWorkspaceDataHelp": "このキャンバスが宣言済みの権限を現在のワークスペース全体で使用できるようにします。キャンバスの配置は変わりません。",
"workspaceDataReviewDescription": "{{title}} のアクティブなリリースと宣言済みの権限を確認します。",
"workspaceDataReviewScopeChange": "データアクセスのみを変更します。キャンバスは現在のタスクに残ります。",
"workspaceDataReviewStale": "キャンバスまたは権限の情報が変更されました。この画面を閉じて、もう一度開いてください。",
"workspaceOwnerRequired": "ワークスペースデータを有効にできるのは現在のワークスペース所有者のみです。",
"enablingWorkspaceData": "ワークスペースデータを有効にしています",
"activeRelease": "アクティブなリリース",
"openInNewTab": "新しいタブで開く",
"openInNewTabHelp": "このキャンバスを専用のブラウザータブで開きます。",
"promoteCanvasDescription": "{{title}} をワークスペーススコープに移動する前に権限を確認してください。",
"loadingPermissions": "要求された権限を読み込み中",
"promotionScopeChange": "昇格すると、このキャンバスがワークスペースのナビゲーションで利用できるようになります。",
"promotionDataAccessPreserved": "昇格では配置のみが変わります。ワークスペースデータへのアクセスはそのまま維持されます。",
"promotionDataAccessChanges": "昇格では配置が変わり、宣言済みのデータ権限がワークスペース全体に広がります。",
"noAdditionalPermissions": "追加の権限は要求されていません。",
"sharedState": "共有状態",
"promotingCanvas": "キャンバスを昇格中",
@@ -76,6 +90,8 @@
"promotionSourceTask": "ソースタスク",
"promotionSourceSession": "ソースセッション",
"promotionTargetScope": "ターゲットスコープ",
"promotionCurrentDataScope": "昇格前のデータアクセス",
"promotionTargetDataScope": "昇格後のデータアクセス",
"promotionPlacement": "ワークスペース内の配置",
"permissionReads": "API 読み取り",
"permissionWrites": "API 書き込み",
+16
View File
@@ -32,11 +32,25 @@
"promoteCanvas": "Ƥŕōḿōţē ćàńvàś",
"promoteCanvasHelp": "Ḿàķē ţĥĩś ţàśķ ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
"promoteCanvasUnavailable": "Ƥŕōḿōţĩōń ĩś àvàĩĺàƀĺē àƒţēŕ à vàĺĩď ŕēĺēàśē.",
"taskDataScope": "Ţàśķ ďàţà",
"workspaceDataScope": "Ŵōŕķśƥàćē ďàţà",
"taskPlacementScope": "Ţàśķ",
"workspacePlacementScope": "Ŵōŕķśƥàćē",
"enableWorkspaceData": "Ēńàƀĺē ŵōŕķśƥàćē ďàţà",
"enableWorkspaceDataHelp": "Àĺĺōŵ ţĥĩś ćàńvàś ţō ũśē ĩţś ďēćĺàŕēď ƥēŕḿĩśśĩōńś àćŕōśś ţĥē ćũŕŕēńţ ŵōŕķśƥàćē. Ţĥĩś ďōēś ńōţ ḿōvē ĩţ ĩńţō ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
"workspaceDataReviewDescription": "Ŕēvĩēŵ ţĥē àćţĩvē ŕēĺēàśē àńď ĩţś ďēćĺàŕēď ƥēŕḿĩśśĩōńś ƒōŕ {{title}}.",
"workspaceDataReviewScopeChange": "Ţĥĩś ćĥàńĝēś ďàţà àććēśś ōńĺŷ. Ţĥē ćàńvàś śţàŷś ĩń ĩţś ćũŕŕēńţ ţàśķ.",
"workspaceDataReviewStale": "Ţĥē ćàńvàś ōŕ ƥēŕḿĩśśĩōń ďēţàĩĺś ĥàvē ćĥàńĝēď. Ćĺōśē ţĥĩś ŕēvĩēŵ àńď ōƥēń ĩţ àĝàĩń.",
"workspaceOwnerRequired": "Ōńĺŷ ţĥē ćũŕŕēńţ ŵōŕķśƥàćē ōŵńēŕ ćàń ēńàƀĺē ŵōŕķśƥàćē ďàţà.",
"enablingWorkspaceData": "Ēńàƀĺĩńĝ ŵōŕķśƥàćē ďàţà",
"activeRelease": "Àćţĩvē ŕēĺēàśē",
"openInNewTab": "Ōƥēń ĩń ńēŵ ţàƀ",
"openInNewTabHelp": "Ōƥēń ţĥĩś ćàńvàś ĩń à ƒōćũśēď ƀŕōŵśēŕ ţàƀ.",
"promoteCanvasDescription": "Ŕēvĩēŵ ƥēŕḿĩśśĩōńś ƀēƒōŕē ḿōvĩńĝ {{title}} ţō ŵōŕķśƥàćē śćōƥē.",
"loadingPermissions": "Ĺōàďĩńĝ ŕēqũēśţēď ƥēŕḿĩśśĩōńś",
"promotionScopeChange": "Ƥŕōḿōţĩōń ḿàķēś ţĥĩś ćàńvàś àvàĩĺàƀĺē ĩń ŵōŕķśƥàćē ńàvĩĝàţĩōń.",
"promotionDataAccessPreserved": "Ƥŕōḿōţĩōń ćĥàńĝēś ƥĺàćēḿēńţ ōńĺŷ. Ŵōŕķśƥàćē ďàţà àććēśś àĺŕēàďŷ àƥƥĺĩēś àńď ŵĩĺĺ śţàŷ ţĥē śàḿē.",
"promotionDataAccessChanges": "Ƥŕōḿōţĩōń ćĥàńĝēś ƥĺàćēḿēńţ àńď ēxƥàńďś ďēćĺàŕēď ďàţà ƥēŕḿĩśśĩōńś ţō ţĥē ŵōŕķśƥàćē.",
"noAdditionalPermissions": "Ńō àďďĩţĩōńàĺ ƥēŕḿĩśśĩōńś ŵēŕē ŕēqũēśţēď.",
"sharedState": "Śĥàŕēď śţàţē",
"promotingCanvas": "Ƥŕōḿōţĩńĝ ćàńvàś",
@@ -76,6 +90,8 @@
"promotionSourceTask": "Śōũŕćē ţàśķ",
"promotionSourceSession": "Śōũŕćē śēśśĩōń",
"promotionTargetScope": "Ţàŕĝēţ śćōƥē",
"promotionCurrentDataScope": "Ďàţà àććēśś ƀēƒōŕē ƥŕōḿōţĩōń",
"promotionTargetDataScope": "Ďàţà àććēśś àƒţēŕ ƥŕōḿōţĩōń",
"promotionPlacement": "Ŵōŕķśƥàćē ƥĺàćēḿēńţ",
"permissionReads": "ÀƤĨ ŕēàďś",
"permissionWrites": "ÀƤĨ ŵŕĩţēś",
+16
View File
@@ -32,11 +32,25 @@
"promoteCanvas": "Promover canvas",
"promoteCanvasHelp": "Disponibiliza este canvas de tarefa na navegação da área de trabalho.",
"promoteCanvasUnavailable": "A promoção fica disponível depois de uma versão válida.",
"taskDataScope": "Dados da tarefa",
"workspaceDataScope": "Dados do workspace",
"taskPlacementScope": "Tarefa",
"workspacePlacementScope": "Área de trabalho",
"enableWorkspaceData": "Ativar dados do workspace",
"enableWorkspaceDataHelp": "Permite que este canvas use as permissões declaradas em todo o workspace atual. Não adiciona o canvas à navegação do workspace.",
"workspaceDataReviewDescription": "Reveja a versão ativa e as permissões declaradas de {{title}}.",
"workspaceDataReviewScopeChange": "Isto altera apenas o acesso aos dados. O canvas mantém-se na tarefa atual.",
"workspaceDataReviewStale": "Os dados do canvas ou das permissões foram alterados. Feche esta revisão e volte a abri-la.",
"workspaceOwnerRequired": "Apenas o proprietário atual do workspace pode ativar os dados do workspace.",
"enablingWorkspaceData": "A ativar os dados do workspace",
"activeRelease": "Versão ativa",
"openInNewTab": "Abrir num novo separador",
"openInNewTabHelp": "Abre este canvas num separador focado do navegador.",
"promoteCanvasDescription": "Reveja as permissões antes de mover {{title}} para o âmbito do workspace.",
"loadingPermissions": "A carregar as permissões pedidas",
"promotionScopeChange": "A promoção disponibiliza este canvas na navegação do workspace.",
"promotionDataAccessPreserved": "A promoção altera apenas a localização. O acesso aos dados do workspace mantém-se.",
"promotionDataAccessChanges": "A promoção altera a localização e alarga ao workspace as permissões de dados declaradas.",
"noAdditionalPermissions": "Não foram pedidas permissões adicionais.",
"sharedState": "Estado partilhado",
"promotingCanvas": "A promover o canvas",
@@ -76,6 +90,8 @@
"promotionSourceTask": "Tarefa de origem",
"promotionSourceSession": "Sessão de origem",
"promotionTargetScope": "Âmbito de destino",
"promotionCurrentDataScope": "Acesso aos dados antes da promoção",
"promotionTargetDataScope": "Acesso aos dados depois da promoção",
"promotionPlacement": "Localização no workspace",
"permissionReads": "Leituras da API",
"permissionWrites": "Escritas da API",
+16
View File
@@ -32,11 +32,25 @@
"promoteCanvas": "提升画布",
"promoteCanvasHelp": "让此任务画布显示在工作区导航中。",
"promoteCanvasUnavailable": "有效版本获批准后才能提升。",
"taskDataScope": "任务数据",
"workspaceDataScope": "工作区数据",
"taskPlacementScope": "任务",
"workspacePlacementScope": "工作区",
"enableWorkspaceData": "启用工作区数据",
"enableWorkspaceDataHelp": "允许此画布在当前工作区内使用其已声明的权限。此操作不会将画布移至工作区导航。",
"workspaceDataReviewDescription": "查看 {{title}} 的活动版本和已声明权限。",
"workspaceDataReviewScopeChange": "此操作只会更改数据访问权限。画布仍位于当前任务中。",
"workspaceDataReviewStale": "画布或权限信息已更改。请关闭此审核并重新打开。",
"workspaceOwnerRequired": "只有当前工作区所有者可以启用工作区数据。",
"enablingWorkspaceData": "正在启用工作区数据",
"activeRelease": "活动版本",
"openInNewTab": "在新标签页中打开",
"openInNewTabHelp": "在聚焦的浏览器标签页中打开此画布。",
"promoteCanvasDescription": "将 {{title}} 移至工作区范围前,请先查看权限。",
"loadingPermissions": "正在加载请求的权限",
"promotionScopeChange": "提升后,此画布会出现在工作区导航中。",
"promotionDataAccessPreserved": "提升只会更改位置。此画布已经可以访问工作区数据,提升后仍保持不变。",
"promotionDataAccessChanges": "提升会更改位置,并将已声明的数据权限扩展到工作区。",
"noAdditionalPermissions": "未请求其他权限。",
"sharedState": "共享状态",
"promotingCanvas": "正在提升画布",
@@ -76,6 +90,8 @@
"promotionSourceTask": "来源任务",
"promotionSourceSession": "来源会话",
"promotionTargetScope": "目标范围",
"promotionCurrentDataScope": "提升前的数据访问范围",
"promotionTargetDataScope": "提升后的数据访问范围",
"promotionPlacement": "工作区位置",
"permissionReads": "API 读取",
"permissionWrites": "API 写入",
+16
View File
@@ -32,11 +32,25 @@
"promoteCanvas": "提升畫布",
"promoteCanvasHelp": "讓此任務畫布顯示在工作區導航中。",
"promoteCanvasUnavailable": "有效版本獲批准後才能提升。",
"taskDataScope": "任務數據",
"workspaceDataScope": "工作區數據",
"taskPlacementScope": "任務",
"workspacePlacementScope": "工作區",
"enableWorkspaceData": "啓用工作區數據",
"enableWorkspaceDataHelp": "允許此畫布在目前工作區內使用其已聲明的權限。此操作不會將畫布移至工作區導航。",
"workspaceDataReviewDescription": "查看 {{title}} 的活動版本和已聲明權限。",
"workspaceDataReviewScopeChange": "此操作只會更改數據存取權限。畫布仍位於目前任務中。",
"workspaceDataReviewStale": "畫布或權限資訊已更改。請關閉此審核並重新開啟。",
"workspaceOwnerRequired": "只有目前工作區所有者可以啓用工作區數據。",
"enablingWorkspaceData": "正在啓用工作區數據",
"activeRelease": "活動版本",
"openInNewTab": "在新標籤頁中開啟",
"openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。",
"promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先查看權限。",
"loadingPermissions": "正在載入請求的權限",
"promotionScopeChange": "提升後,此畫布會出現在工作區導航中。",
"promotionDataAccessPreserved": "提升只會更改位置。此畫布已經可以存取工作區數據,提升後仍保持不變。",
"promotionDataAccessChanges": "提升會更改位置,並將已聲明的數據權限擴展到工作區。",
"noAdditionalPermissions": "未請求其他權限。",
"sharedState": "共享狀態",
"promotingCanvas": "正在提升畫布",
@@ -76,6 +90,8 @@
"promotionSourceTask": "來源任務",
"promotionSourceSession": "來源工作階段",
"promotionTargetScope": "目標範圍",
"promotionCurrentDataScope": "提升前的數據存取範圍",
"promotionTargetDataScope": "提升後的數據存取範圍",
"promotionPlacement": "工作區位置",
"permissionReads": "API 讀取",
"permissionWrites": "API 寫入",
+16
View File
@@ -32,11 +32,25 @@
"promoteCanvas": "提升畫布",
"promoteCanvasHelp": "讓此任務畫布顯示在工作區導覽中。",
"promoteCanvasUnavailable": "有效版本獲批准後才能提升。",
"taskDataScope": "任務資料",
"workspaceDataScope": "工作區資料",
"taskPlacementScope": "任務",
"workspacePlacementScope": "工作區",
"enableWorkspaceData": "啟用工作區資料",
"enableWorkspaceDataHelp": "允許此畫布在目前工作區內使用其已宣告的權限。此操作不會將畫布移至工作區導覽。",
"workspaceDataReviewDescription": "檢視 {{title}} 的活動版本和已宣告權限。",
"workspaceDataReviewScopeChange": "此操作只會更改資料存取權限。畫布仍位於目前任務中。",
"workspaceDataReviewStale": "畫布或權限資訊已更改。請關閉此稽核並重新開啟。",
"workspaceOwnerRequired": "只有目前工作區所有者可以啟用工作區資料。",
"enablingWorkspaceData": "正在啟用工作區資料",
"activeRelease": "活動版本",
"openInNewTab": "在新標籤頁中開啟",
"openInNewTabHelp": "在聚焦的瀏覽器標籤頁中開啟此畫布。",
"promoteCanvasDescription": "將 {{title}} 移至工作區範圍前,請先檢視權限。",
"loadingPermissions": "正在載入請求的權限",
"promotionScopeChange": "提升後,此畫布會出現在工作區導覽中。",
"promotionDataAccessPreserved": "提升只會更改位置。此畫布已經可以存取工作區資料,提升後仍保持不變。",
"promotionDataAccessChanges": "提升會更改位置,並將已宣告的資料權限擴充套件到工作區。",
"noAdditionalPermissions": "未請求其他權限。",
"sharedState": "共享狀態",
"promotingCanvas": "正在提升畫布",
@@ -76,6 +90,8 @@
"promotionSourceTask": "來源任務",
"promotionSourceSession": "來源工作階段",
"promotionTargetScope": "目標範圍",
"promotionCurrentDataScope": "提升前的資料存取範圍",
"promotionTargetDataScope": "提升後的資料存取範圍",
"promotionPlacement": "工作區位置",
"permissionReads": "API 讀取",
"permissionWrites": "API 寫入",
@@ -7,6 +7,8 @@
The initial-grant rule is amended by
[owner-authorized canvas creation](2026-09-10-canvas-creation-authority.md).
Task placement and data scope are separated for owner-authorized canvases by
[task canvas workspace data](2026-09-23-task-canvas-workspace-data.md).
The bounded startup acknowledgement is specified in the
[runtime design](../specs/plugins/system-design/isolated-web-app-contributions.md#runtime-startup-protocol);
it adds no privileged host API.
@@ -4,6 +4,10 @@
**Date:** 2026-09-10
**Area:** backend, protocol, security
The task-data initial grant ceiling below is amended by
[task canvas workspace data](2026-09-23-task-canvas-workspace-data.md).
It remains the historical rule for version-1 authority.
## Context
A user asks their task agent to create a canvas, then must approve its first
@@ -0,0 +1,72 @@
# ADR-2026-09-23-task-canvas-workspace-data: Separate canvas placement from data scope
**Status:** accepted
**Date:** 2026-09-23
**Area:** backend, frontend, protocol, security
## Context
Users create canvases from tasks and review their behavior before promotion.
The original canvas contract binds task placement and data scope together. A
task coordinator that calls the supported canvas task-list endpoint therefore
receives only its creating task, even when the package declares task reads and
the workspace has many tasks. Granting every capability would not remove that
scope filter.
The [owner-authorized creation decision](2026-09-10-canvas-creation-authority.md)
already treats the user's request as authority for the first release's exact
declared grants. The [same-origin decision](2026-09-19-trusted-same-origin-canvases.md)
also establishes that locally run canvas code has the viewer's ordinary browser
authority. The relative canvas API should provide a coherent supported path
without relying on direct first-party API calls.
## Decision
A task canvas has separate placement and data scopes. For a newly
owner-authorized local canvas, the first valid release receives only its
declared supported capabilities, with data scope limited to its current
workspace. It remains placed in the creating task until the user promotes it.
The task-list API can show every task in that workspace before promotion;
current user authorization still applies. Promotion governs placement and
sharing, and still requires human confirmation.
The data scope is server-owned, persisted, included in runtime bindings, and
checked alongside grants on every canvas-protocol request. Agent or manifest
input cannot choose a different workspace. Existing task-only releases retain
their scope until a workspace owner reviews their exact declared permissions
and explicitly enables workspace data access. Later permission increases and
imported packages continue through their existing review paths.
This amends the task-scoped first-grant ceiling in the 2026-09-10 decision and
the coupling of task canvas and task data scope in the 2026-08-26 decision. It
does not change the same-origin trust decision or grant undeclared capabilities.
## Consequences
- An owner-created canvas can be evaluated with representative live workspace
data and supported actions before it appears in workspace navigation.
- Permission review remains tied to the exact capabilities a release uses;
there is no blanket read, write, network, secret, or backend permission.
- The runtime, persistence, review projection, and authoring guidance must
carry placement and data scopes independently.
- Existing task canvases require an explicit access upgrade or promotion to
show workspace data. No retained release is silently broadened on upgrade.
- The upgrade path must check current ownership and a versioned review
snapshot atomically, then invalidate older runtime tokens.
## Alternatives Considered
- **Grant all possible permissions by default:** Does not remove the task-list
scope filter and gives unused writes and network access to generated code.
- **Require promotion before representative preview:** Leaves the user unable
to judge the app before placing it in workspace navigation.
- **Call ordinary Kandev APIs from canvas code:** Works only with browser user
authority and bypasses the supported capability contract; capability-only
hosts would still show one task.
- **Silently widen all existing task canvases:** Changes retained release
authority without a review of the code and exact declared capabilities.
## Related contracts
- [Workspace preview requirements](../specs/canvases/requirements/task-canvas-workspace-preview.md)
- [Workspace preview design](../specs/canvases/system-design/task-canvas-workspace-preview.md)
@@ -0,0 +1,152 @@
---
created: 2026-09-23
status: complete
requirements:
- REQ-CANVASES-WORKSPACE-PREVIEW-001
- REQ-CANVASES-LOCAL-CREATION-001
- REQ-CANVASES-AGENT-WEB-APPS-003
system_design:
- ../../specs/canvases/system-design/task-canvas-workspace-preview.md
- ../../specs/canvases/system-design/local-creation-authority.md
- ../../specs/canvases/system-design/agent-authored-web-apps.md
- ../../specs/plugins/system-design/canvas-workspace-data-scope.md
legacy_specs: []
---
# Implementation Plan: Task canvas workspace data preview
## Overview
Let an owner-created canvas use its declared capabilities across the current
workspace while it remains in the creating task. First add the server-owned
data-scope and review transaction, then expose the scope and legacy upgrade
in the host, authoring guidance, and browser tests. Promotion continues to
control workspace placement.
## Scope
### In scope
- Workspace task lists and all currently supported declared canvas data,
write, event, state, and network capabilities before promotion.
- A separate server-owned data scope on plugin instances and runtime bindings.
- An explicit, reviewed workspace-data upgrade for existing task canvases.
- Desktop and phone host scope presentation, localized copy, authoring and
public documentation, and focused browser coverage.
### Out of scope
- Cross-workspace and installation-wide data access.
- Blanket grants for undeclared capabilities, new capability kinds, or
managed agent-generated backend code.
- Silent widening of retained releases.
## Technical approach
1. Add nullable `data_scope_kind` to plugin instance persistence with null
fallback to the existing placement `scope_kind`. Create version-2 local
canvas authority with workspace data scope and exact workspace-ceiling
declared grants. Keep other instances and historical authority unchanged.
2. Carry data scope through `webapp.CapabilityBinding`, grant validation, and
the relative browser protocol. Use it for tasks, workflows, writes,
messages, dependency projection, and events. Bind every operation to the
trusted workspace and current user authorization. Follow the
[plugin runtime design](../../specs/plugins/system-design/canvas-workspace-data-scope.md).
3. Add an owner-reviewed workspace-data upgrade for retained task canvases
using the active release ID, permission digest, and grant generation as
conditional transaction inputs. Keep placement and package unchanged.
4. Project placement and data scope separately in canvas context and host
review. Update authoring instructions and public canvas/API docs. Use the
existing desktop action menu and phone action drawer for legacy upgrade.
Likely backend files include `internal/plugins/instances/store.go`,
`internal/plugins/webapp/tokens.go`, `internal/plugins/webapp_protocol*.go`,
`internal/canvas/authoring.go`, `internal/canvas/service.go`, and the canvas
HTTP handler/controller. Existing E2E fixture expectations for one task must
change to a multi-task workspace fixture.
## ASCII UI preview
`UI-01: Task canvas host, new release (desktop)`. Entry: task canvas panel.
The host header stays fixed; the application owns its existing scroll region.
The badge reports data scope, while Promote controls placement. Labels are
illustrative; the distinction is required by
`AC-CANVASES-WORKSPACE-PREVIEW-001.3-.4`.
```text
Task coordinator [Workspace data] [Releases and permissions] [Promote canvas]
--------------------------------------------------------------------------
Existing tasks [Refresh]
Available 3 Active 2 Completed 1
Task A Task B Task C
```
`UI-02: Existing task-only canvas (desktop)`. Entry: the same task panel.
The upgrade dialog uses the current permission-review layout and keeps
Cancel and Enable actions visible. It does not promote the canvas.
```text
Task coordinator [Task data] [Enable workspace data] [Promote canvas]
|
Enable workspace data for this canvas?
Release: Task coordinator 1.0.0
Reads: tasks in this workspace
Writes/events/network: exact declared permissions
[Cancel] [Enable workspace data]
```
`UI-03: Existing task-only canvas (phone)`. Entry: focused task canvas route.
The existing Actions drawer exposes the upgrade; the review opens as a
full-height view with one scrolling permission region and safe-area actions.
```text
< Task Task coordinator [Actions]
Task data
| Actions drawer: Enable workspace data
v
Enable workspace data
Release and declared permissions [scrolls]
-------------------------------------------
[Cancel] [Enable workspace data] [fixed, safe area]
```
## Tests
| Criteria | Evidence |
| --- | --- |
| `.1`, `.2`, `.8` | Plugin instance and web-app protocol tests for exact grants, all workspace pages, foreign denial, writes, workflows, messages, events, and dependencies. |
| `.3`, `.4` | Canvas service promotion and projection tests. |
| `.5`, `.6` | Conditional legacy-upgrade transaction and stale/owner/revocation tests. |
| `.7` | Canvas host component tests and desktop/mobile E2E. |
## E2E tests
- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts` (`chromium`): owner-created
task canvas sees two or more workspace tasks and refreshes before promotion;
promotion leaves the list intact. Covers `.1`, `.3`, `.4`.
- `apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts`
(`mobile-chrome`): same data before promotion plus task-only legacy upgrade
from the phone action drawer. Covers `.5`, `.7`.
## Work orders
- [x] [Task 01: Workspace data scope and legacy upgrade](task-01-workspace-data-scope.md)
- [x] [Task 02: Host scope review and browser proof](task-02-host-scope-review.md)
Task 02 depends on Task 01. Both run in the primary session unless the user
later explicitly authorizes another arrangement.
## Verification results
Passed: backend canvas/plugin tests, focused frontend Vitest coverage, web
typecheck, i18n validation, desktop and mobile canvas Playwright suites, spec
index validation, specification lint, and public documentation validation.
## Risks
- Grant checks currently equate instance scope with data scope. Missing a
validation or event path could leak data or leave preview inconsistent.
- Existing task releases must retain their one-task behavior until reviewed;
a broad migration would silently change authority.
- Same-origin canvas code can call ordinary user APIs already; the supported
relative protocol must remain internally coherent in capability-only hosts.
@@ -0,0 +1,112 @@
---
id: "01-workspace-data-scope"
title: "Workspace data scope and legacy upgrade"
status: complete
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-CANVASES-WORKSPACE-PREVIEW-001
- REQ-CANVASES-LOCAL-CREATION-001
- REQ-CANVASES-AGENT-WEB-APPS-003
acceptance_criteria:
- AC-CANVASES-WORKSPACE-PREVIEW-001.1
- AC-CANVASES-WORKSPACE-PREVIEW-001.2
- AC-CANVASES-WORKSPACE-PREVIEW-001.3
- AC-CANVASES-WORKSPACE-PREVIEW-001.5
- AC-CANVASES-WORKSPACE-PREVIEW-001.6
- AC-CANVASES-WORKSPACE-PREVIEW-001.8
- AC-CANVASES-LOCAL-CREATION-001.2
- AC-CANVASES-AGENT-WEB-APPS-003.2
system_design:
- ../../specs/canvases/system-design/task-canvas-workspace-preview.md
- ../../specs/canvases/system-design/local-creation-authority.md
- ../../specs/canvases/system-design/agent-authored-web-apps.md
- ../../specs/plugins/system-design/canvas-workspace-data-scope.md
---
# Task 01: Workspace data scope and legacy upgrade
## Summary
Persist a separate data scope for canvas instances and honor it throughout the
relative web-app protocol. New owner-authorized task canvases get exact
declared workspace grants on first publication; existing task-only canvases
can obtain them only through a conditional owner-reviewed upgrade.
## In scope
- SQLite/PostgreSQL instance mapping and null fallback; version-2 creation
authority and first-publication transaction.
- Runtime binding, grant checks, task/workflow/write/message/event/dependency
enforcement, and additive context projection.
- Bounded expvar counters for first publication, reviewed upgrades, and
promotion data-scope results, without per-canvas metric labels.
- Workspace-data preview and confirmation endpoints for retained task canvases.
- Promotion behavior with an already workspace-data task canvas and tests for
foreign workspaces, stale reviews, owner drift, and revoked grants.
## Out of scope
- Host action, dialog, translations, browser tests, and public docs.
## Acceptance
1. A new task-placed local canvas can list and operate on all authorized tasks
in its own workspace through declared capabilities before promotion; no
foreign workspace or undeclared capability is admitted.
2. Existing task-only instances keep their current behavior until an owner
confirms an exact, current release review; confirmation changes grants and
data scope atomically without changing placement or release.
3. Promotion preserves workspace data access when it already exists, while
stale or invalid runtime bindings and reviews fail closed.
## Verification
Run from the repository root:
```bash
(cd apps/backend && go test ./internal/canvas ./internal/plugins/instances ./internal/plugins ./internal/plugins/webapp ./internal/backendapp)
python3 scripts/list-docs.py validate
```
## Files likely touched
- `apps/backend/internal/plugins/instances/store.go`
- `apps/backend/internal/plugins/webapp/tokens.go`
- `apps/backend/internal/plugins/webapp_protocol.go`
- `apps/backend/internal/plugins/webapp_protocol_data.go`
- `apps/backend/internal/plugins/webapp_events.go`
- `apps/backend/internal/plugins/webapp_protocol_json.go`
- `apps/backend/internal/canvas/authoring.go`
- `apps/backend/internal/canvas/service.go`
- `apps/backend/internal/canvas/types.go`
- `apps/backend/internal/backendapp/` canvas HTTP handlers and tests
- Adjacent `*_test.go` files
## Dependencies
None.
## Risks
Every route that uses placement scope today must be classified by whether it
filters data, determines discovery, or checks grant validity. Missing a route
could make the preview inconsistent or broaden an unintended path.
## Parallelism
`sequential`
## Inputs
- [Workspace preview requirements](../../specs/canvases/requirements/task-canvas-workspace-preview.md)
- [Workspace preview design](../../specs/canvases/system-design/task-canvas-workspace-preview.md)
- [Scope decision](../../decisions/2026-09-23-task-canvas-workspace-data.md)
## Results
Passed: `go test ./internal/canvas ./internal/plugins/instances
./internal/plugins ./internal/plugins/webapp ./internal/backendapp` and
`python3 scripts/list-docs.py validate`. The canvas tests verify fixed-label
counter increments for initial publication, reviewed upgrade, and promotion.
@@ -0,0 +1,141 @@
---
id: "02-host-scope-review"
title: "Host scope review and browser proof"
status: complete
wave: 2
depends_on:
- "01-workspace-data-scope"
plan: "plan.md"
requirements:
- REQ-CANVASES-WORKSPACE-PREVIEW-001
- REQ-CANVASES-AGENT-WEB-APPS-003
acceptance_criteria:
- AC-CANVASES-WORKSPACE-PREVIEW-001.1
- AC-CANVASES-WORKSPACE-PREVIEW-001.4
- AC-CANVASES-WORKSPACE-PREVIEW-001.5
- AC-CANVASES-WORKSPACE-PREVIEW-001.7
- AC-CANVASES-AGENT-WEB-APPS-003.1
system_design:
- ../../specs/canvases/system-design/task-canvas-workspace-preview.md
- ../../specs/canvases/system-design/agent-authored-web-apps.md
---
# Task 02: Host scope review and browser proof
## Summary
Show task placement and workspace data access separately in the host and
promotion review. Give older task-only canvases an owner-reviewed access
action on desktop and phone, update author guidance, and prove live workspace
task refresh before promotion in browser tests.
## In scope
- Canvas API client/projections, existing host actions and review component,
localized copy in six catalogs, and phone action drawer.
- Versioned bundled authoring reference, public canvas and plugin API docs,
and concise scoped `AGENTS.md` updates if implementation changes their
current statements.
- Component and desktop/mobile Playwright tests using multiple tasks in one
workspace and a foreign-workspace negative case.
## Out of scope
- New canvas-authored UI template, broad UI redesign, installation-wide task
listing, and silent legacy grant migration.
## Acceptance
1. Desktop and phone hosts label the data scope accurately and offer the
upgrade review only for eligible task-only canvases; the action does not
promote or republish.
2. Promotion review distinguishes placement from data access and keeps exact
declared permission details visible on both viewports.
3. Browser tests show a new task canvas refreshing at least two workspace
tasks before promotion and an existing one expanding access after review.
## ASCII UI preview
`UI-01: Task canvas host, new release (desktop)` and
`UI-02: Existing task-only canvas (desktop)` use the same header shown in
[the full plan](plan.md#ascii-ui-preview):
```text
New: Task coordinator [Workspace data] [Releases] [Promote canvas]
Existing: Task coordinator [Task data] [Enable workspace data] [Promote canvas]
```
`UI-03: Existing task-only canvas (phone)` uses the existing focused route
and Actions drawer. Its review is full-height with one scroll region and
fixed safe-area actions:
```text
< Task Task coordinator [Actions]
Task data
Actions > Enable workspace data
Review declared permissions [scrolls]
[Cancel] [Enable workspace data] [fixed, safe area]
```
The scope label and action availability are required by
`AC-CANVASES-WORKSPACE-PREVIEW-001.4-.5` and `.7`; text spacing is
illustrative. Reuse the shipped canvas host and phone drawer.
## Verification
Run from the repository root:
```bash
(cd apps/web && pnpm exec vitest run components/settings/canvas-lifecycle-dialogs.test.tsx components/settings/canvas-host-components.test.tsx lib/api/domains/canvas-api.test.ts)
(cd apps/web && pnpm run typecheck)
(cd apps/web && pnpm run i18n:check)
(cd apps/web && pnpm e2e:run --host --project chromium tests/canvas/plugin-canvas.spec.ts)
(cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/canvas/mobile-plugin-canvas.spec.ts)
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
node scripts/validate-public-docs.mjs
```
## Files likely touched
- `apps/web/lib/api/domains/canvas-api.ts`
- `apps/web/components/settings/canvas-host-actions.tsx`
- `apps/web/components/settings/canvas-lifecycle-dialogs.tsx`
- `apps/web/components/settings/canvas-host-route*.tsx`
- `apps/web/src/locales/*/canvases.json`
- `apps/web/e2e/tests/canvas/plugin-canvas.spec.ts`
- `apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts`
- `apps/backend/internal/mcp/canvasskill/files/references/browser-api.md`
- `apps/backend/config/prompts/create-canvas.md`
- `docs/public/canvases.md`
- `docs/public/plugins-authoring.md`
- Adjacent component tests and `AGENTS.md` files when needed
## Dependencies
Task 01 supplies the data-scope projection and conditional access endpoint.
## Risks
The current canvas fixture expects one task. The browser proof must create a
second task in the same workspace and retain a foreign-workspace negative case
without changing unrelated fixture behavior.
## Parallelism
`sequential`
## Inputs
- [Workspace preview requirements](../../specs/canvases/requirements/task-canvas-workspace-preview.md)
- [Workspace preview design](../../specs/canvases/system-design/task-canvas-workspace-preview.md)
- [Full UI preview](plan.md#ascii-ui-preview)
## Results
Passed: focused Vitest suite (41 tests), web typecheck, i18n checks, desktop
canvas Playwright suite, mobile canvas Playwright suite (5 tests), spec index
validation, specification lint, and public documentation validation. The phone
browser test verifies both scope labels and the legacy review flow; it uses a
test-only legacy API response, while backend service/store tests verify the
transactional grant upgrade.
+20 -5
View File
@@ -53,7 +53,18 @@ Kandev validates the package before it stores or runs the release. An invalid dr
## Use a canvas
A task canvas belongs to one task. A workspace canvas appears in workspace navigation and uses workspace scope. Promotion changes the same canvas from task scope to workspace scope. It does not copy the canvas.
A task canvas stays in its creating task until promotion. After the first valid
release, a new owner-authorized task canvas can use its declared supported
permissions across the current workspace. This lets the app preview the full
workspace task list before it appears in workspace navigation. Imported and
older task canvases keep their existing data scope until an authorized review
widens it.
A workspace canvas appears in workspace navigation. Promotion changes the
canvas placement and keeps its identity, active release, state, and release
history. For a new canvas that already has workspace data access, promotion
changes placement only. The host review identifies whether it changes data
access for a legacy canvas.
The app uses relative requests such as `./_kandev/v1/data/tasks` and `./_kandev/v1/state`. Kandev remains the source of truth for task, workflow, and message data. Canvas state stores app-specific shared state. It does not replace Kandev domain data.
@@ -115,7 +126,7 @@ and **Releases and permissions** outside the failed frame. Retry creates a new
runtime binding and startup attempt. This check confirms document and context
startup; it does not certify application business health.
Kandev calculates effective access from the package declaration, instance grant, trusted task or workspace scope, and current caller authorization. A release receives only the intersection of those permissions. See [Security and trust](security.md#isolated-web-applications) for the security boundary.
Kandev calculates effective access from the package declaration, instance grant, trusted data scope, and current caller authorization. A release receives only the intersection of those permissions. See [Security and trust](security.md#isolated-web-applications) for the security boundary.
### Return to a task after publication
@@ -136,13 +147,17 @@ Only a user can promote a task canvas. An agent cannot promote, demote, grant pe
1. Open the task canvas and choose its promotion action.
2. Read the review dialog before you continue.
3. Review every Kandev data read and write, event subscription, shared-state permission, and exact external HTTPS origin.
4. Confirm the task-to-workspace scope change and workspace placement.
4. Confirm the placement change to workspace navigation.
The confirmation includes the active release ID, permission declaration
digest, and grant generation that you reviewed. If any of these changes before
confirmation, Kandev rejects the request as stale and requires a new review.
The promotion keeps the canvas identity, active release, state, and release history. A workspace canvas then appears in workspace navigation. Canceling the review leaves the task canvas unchanged.
The promotion keeps the canvas identity, active release, state, and release
history. A workspace canvas then appears in workspace navigation. A new
owner-authorized task canvas already has workspace data access, so promotion
changes placement only. A legacy task canvas uses the permission review shown
by its host. Canceling the review leaves the task canvas unchanged.
If promotion adds a permission, Kandev keeps the current active release until a user approves the new grant. The new release stays pending permission until that review finishes.
@@ -193,7 +208,7 @@ Use these recovery actions:
- Roll back to the retained prior release after a failed publish.
- Restore the database and matching artifact directory after a storage loss. See [Canvas artifacts and recovery](operations.md#canvas-artifacts-and-recovery).
- Archive a canvas to hide it from normal discovery. Restore it from the canvas controls.
- Remove a task canvas to remove its task-scoped data. Removing a task does not remove a canvas already promoted to a workspace.
- Remove a task canvas to remove the canvas and its data. Removing a task does not remove a canvas already promoted to a workspace.
- Remove a workspace canvas to remove its grants, state, tokens, releases, and artifacts after cleanup completes.
Kandev records artifact cleanup before it removes release ownership. A worker completes cleanup after the database transaction and retries it after a restart.
+12 -8
View File
@@ -121,11 +121,12 @@ does not need a Go backend or an injected Kandev JavaScript API.
the app needs.
5. Package the manifest and static files as a gzip-compressed tar archive.
For a new owner-created task canvas, the first valid release can receive the
declared supported task-scoped permissions through its initial permission
policy. Imported packages and later permission increases need human approval.
Keep `network_origins` as exact HTTPS origins. Do not use wildcards, paths,
credentials, query strings, or fragments.
For a new owner-authorized task canvas, the first valid release can receive
only its declared supported permissions through the initial permission policy.
Kandev data access is limited to the current workspace while the canvas stays
placed in its creating task. Imported packages and later permission increases
need human approval. Keep `network_origins` as exact HTTPS origins. Do not use
wildcards, paths, credentials, query strings, or fragments.
For example, a page can read task data with the browser Fetch API:
@@ -137,9 +138,12 @@ if (!response.ok) {
const tasks = await response.json();
```
Use `./_kandev/v1/events` for the event stream. Keep all protocol paths
relative so the same package works in task and workspace scope. Do not copy a
capability URL from the host into the app.
Use `./_kandev/v1/events` for the event stream. Read both `scope_kind` and
`data_scope_kind` from `./_kandev/v1/context`: the first describes placement,
and the second describes the Kandev data boundary. Keep all protocol paths
relative so the same package works in task and workspace placement. For task
lists, follow `page_info.next_cursor` to load every page. Do not set
`workspace_id` to another workspace or copy a capability URL from the host.
For recorded workflow movement, the browser can GET
`./_kandev/v1/data/tasks/{task_id}/step-transitions` with
+5 -2
View File
@@ -10,8 +10,8 @@ last_updated: 2026-09-10
# Canvases
Canvases own agent-authored task applications and their promotion to workspace
applications. The system owns canvas scope, source lineage, release selection,
editing sessions, discovery, and portable canvas distribution.
applications. The system owns canvas placement and data scope, source lineage,
release selection, editing sessions, discovery, and portable canvas distribution.
The Plugins system owns the web-application runtime, permissions,
Kandev data access, state, events, and package validation. The task system
@@ -20,18 +20,21 @@ remains authoritative for task data and permissions.
## Requirements
- [Agent-authored web-app canvases](requirements/agent-authored-web-apps.md)
- [Task canvas workspace data preview](requirements/task-canvas-workspace-preview.md)
- [Canvas marketplace and sharing](requirements/marketplace-sharing.md)
- [Deprecated collaborative canvases](requirements/collaborative-canvases.md)
## System design
- [Agent-authored web-app canvases](system-design/agent-authored-web-apps.md)
- [Task canvas workspace data preview](system-design/task-canvas-workspace-preview.md)
- [Canvas marketplace and sharing](system-design/marketplace-sharing.md)
- [Superseded collaborative canvases](system-design/collaborative-canvases.md)
## Related context
- [Runtime and permission fix plan](../../plans/canvas-runtime-permission-fixes/plan.md)
- [Task canvas workspace data preview plan](../../plans/task-canvas-workspace-preview/plan.md)
- [Agent MCP discovery guidance](../agents/system-design/mcp-tool-discovery-guidance.md)
- [MCP discovery and canvas prompt plan](../../plans/mcp-discovery-canvas-prompts/plan.md)
- [GitHub Copilot App Canvas reference](../../copilot-canvas-reference.md)
@@ -6,7 +6,7 @@ system: canvases
owners:
- canvases
created: 2026-08-26
last_updated: 2026-09-21
last_updated: 2026-09-23
---
# Agent-authored web-app canvases Requirements
@@ -15,7 +15,8 @@ last_updated: 2026-09-21
A canvas is a custom web application that an agent creates for one task. A
user can promote a useful task canvas to its workspace. A workspace canvas
appears in workspace navigation and can use workspace-scoped data.
appears in workspace navigation. An owner-authorized task canvas can already
use its workspace's data while the user reviews it in the originating task.
The Canvases system owns the canvas scope, source lineage, release selection,
promotion, editing flow, and discovery. The Plugins system owns the web-application
@@ -24,13 +25,13 @@ runtime and its data contract.
## Terminology
- **Task canvas:** A canvas that belongs to one task and appears only in that
task.
task. Its placement does not determine its approved data scope.
- **Workspace canvas:** A canvas that belongs to one workspace and appears in
workspace navigation. Agent-authored canvases reach this scope by promotion;
[distribution](marketplace-sharing.md) also defines reviewed package installs.
- **Draft:** Editable canvas source in an authorized agent workspace.
- **Release:** An immutable package that passed validation.
- **Promotion:** A user action that changes a task canvas to workspace scope.
- **Promotion:** A user action that changes a task canvas to workspace placement.
## Requirements
@@ -120,8 +121,8 @@ browser session end.
### REQ-CANVASES-AGENT-WEB-APPS-003: User-controlled promotion
**Intent:** A useful task canvas becomes a workspace application only after a
user reviews its scope and permissions.
**Intent:** A useful task canvas becomes available in workspace navigation only
after a user reviews its placement and permissions.
**User story:** As a user, I want to promote a useful task canvas, so that I
can open it from the workspace sidebar.
@@ -132,7 +133,8 @@ can open it from the workspace sidebar.
system shall show the requested data, write, event, state, and network
permissions before confirmation.
- **AC-CANVASES-AGENT-WEB-APPS-003.2:** When the user confirms promotion, the
same canvas identity and active release shall change to workspace scope.
same canvas identity and active release shall change to workspace placement.
A release that already has workspace data access shall keep that access.
- **AC-CANVASES-AGENT-WEB-APPS-003.3:** When promotion completes, the canvas
shall appear in navigation for that workspace only.
- **AC-CANVASES-AGENT-WEB-APPS-003.4:** An agent shall not promote, demote, or
@@ -342,6 +344,7 @@ including missing references, lookup failures, and passthrough exclusions.
- [Canvas runtime and task-entry recovery](../../../plans/canvas-runtime-entry-recovery/plan.md)
- [Direct canvas creation and saved prompt](../../../plans/canvas-direct-creation/plan.md)
- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md)
## Out of scope
@@ -6,7 +6,7 @@ system: canvases
owners:
- canvases
created: 2026-09-10
last_updated: 2026-09-10
last_updated: 2026-09-23
---
# Owner-authorized canvas creation
@@ -39,10 +39,11 @@ imported code and later changes cannot claim that creation authority.
- **AC-CANVASES-LOCAL-CREATION-001.1:** The first valid release of an
owner-authorized task canvas shall activate without a separate permission
approval. Its grants shall contain only its declared, supported permissions.
- **AC-CANVASES-LOCAL-CREATION-001.2:** Initial grants shall remain within the
task scope and current owner access. They can cover supported data reads,
writes, events, instance state, and exact HTTPS external origins. They shall
not enable remote scripts, managed backend execution, or workspace access.
- **AC-CANVASES-LOCAL-CREATION-001.2:** Initial grants shall contain only the
declared supported data reads, writes, events, instance state, and exact HTTPS
external origins. For a new owner-authorized canvas, those grants shall cover
the current workspace while the canvas remains in its task. They shall not
enable another workspace, remote scripts, or managed backend execution.
- **AC-CANVASES-LOCAL-CREATION-001.3:** Agent fields, package metadata, display
titles, and ownership of an imported package shall not establish local
creation authority. Missing or mismatched trusted identity shall deny it.
@@ -71,3 +72,4 @@ a marketplace, import UI, collaborator role, or ongoing blanket approval.
## Implementation plans
- [Canvas runtime and permission fixes](../../../plans/canvas-runtime-permission-fixes/plan.md)
- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md)
@@ -0,0 +1,88 @@
---
id: canvases-task-canvas-workspace-preview
title: Task canvas workspace data preview
status: draft
system: canvases
owners:
- canvases
created: 2026-09-23
last_updated: 2026-09-23
---
# Task canvas workspace data preview Requirements
## Overview
A user creates a canvas from a task and reviews the live application there
before deciding whether to add it to workspace navigation. Its task placement
must not reduce the data available to an owner-authorized first release.
Canvases owns the creation and promotion lifecycle; the Plugins system owns the
runtime data contract and grant enforcement.
## Terminology
- **Placement scope:** Where Kandev presents a canvas and which task owns its
unpromoted lifecycle.
- **Data scope:** The largest Kandev data boundary admitted by a release's
declared and granted capabilities. Data scope never overrides the viewing
user's current authorization.
## Requirements
### REQ-CANVASES-WORKSPACE-PREVIEW-001: Representative task canvas preview
**Intent:** A locally requested canvas works with the current workspace's live
data before the user chooses to promote it.
**User story:** As a user creating a canvas from a task, I want to see all tasks
in my current workspace, so that I can judge the canvas before promotion.
#### Acceptance criteria
- **AC-CANVASES-WORKSPACE-PREVIEW-001.1:** After the first valid release of an
owner-authorized task canvas activates, its declared task read capability
shall list and read all currently authorized tasks in the canvas's workspace,
including tasks added after publication. Refresh and pagination shall reach
the full result set. A task in another workspace shall remain unavailable.
- **AC-CANVASES-WORKSPACE-PREVIEW-001.2:** Before promotion, every supported
capability declared by that first release shall operate across the same
workspace where the capability has a workspace data meaning. Undeclared or
unsupported capabilities shall remain unavailable, and every operation shall
obey current user authorization.
- **AC-CANVASES-WORKSPACE-PREVIEW-001.3:** The canvas shall remain discoverable
only in its originating task until a user promotes it. Task removal shall
retain its existing cleanup behavior. Promotion shall still require user
confirmation, but it shall not be required to preview workspace data.
- **AC-CANVASES-WORKSPACE-PREVIEW-001.4:** The canvas context and host review
shall distinguish task placement from workspace data access. Promotion review
shall identify whether it changes data access or only placement.
- **AC-CANVASES-WORKSPACE-PREVIEW-001.5:** An existing task canvas with
task-only data access shall keep that access after upgrade until a current
workspace owner explicitly enables workspace data access. The review shall
name the active release and its declared permissions. Confirmation shall
widen only those declared grants without promoting or republishing the
canvas; cancellation shall change nothing.
- **AC-CANVASES-WORKSPACE-PREVIEW-001.6:** A stale review, changed owner,
revoked grant, missing task or workspace, or failed permission check shall
deny the access change without widening data exposure. A new release that
adds a capability after first publication shall still await permission
review.
- **AC-CANVASES-WORKSPACE-PREVIEW-001.7:** Desktop and phone users shall be
able to inspect the data scope and complete the existing-canvas access review
from the task canvas host. The phone flow shall remain usable with one
vertical scroll region, safe-area clearance, and touch controls.
- **AC-CANVASES-WORKSPACE-PREVIEW-001.8:** A canvas built under this contract
shall receive only workspace data authorized for its own workspace. Its
runtime token, manifest, or agent input shall not select a different
workspace or expand its capabilities.
## Out of scope
- Data from other workspaces or installation-wide grants.
- Blanket grants for capabilities a release does not declare.
- New canvas plugin capability types or arbitrary backend execution.
- Automatic widening of existing published releases.
## Implementation plans
- [Task canvas workspace data preview](../../../plans/task-canvas-workspace-preview/plan.md)
@@ -6,7 +6,7 @@ system: canvases
owners:
- canvases
created: 2026-08-26
last_updated: 2026-09-21
last_updated: 2026-09-23
requirements:
- REQ-CANVASES-AGENT-WEB-APPS-001
- REQ-CANVASES-AGENT-WEB-APPS-002
@@ -110,9 +110,11 @@ Use a small lifecycle record instead of declarative canvas blocks.
- nullable `archived_at`
- `created_at` and `updated_at`
The plugin instance owns `scope_kind`, active release, status, and grants. The
canvas service is the only service that changes the task or workspace scope of
a canvas instance.
The plugin instance owns placement `scope_kind`, effective data scope, active
release, status, and grants. The canvas service is the only service that
changes task or workspace placement or authorizes a canvas data-scope change.
The [workspace preview design](task-canvas-workspace-preview.md) separates
these scopes for locally requested task canvases.
The canvas and plugin instance have a one-to-one relationship. Canvas removal
removes the plugin instance, runtime tokens, grants, state, pending releases,
@@ -366,13 +368,14 @@ The promotion dialog shows:
- each event subject
- shared state access
- each exact external network origin
- the change from task scope to workspace scope
- the change from task placement to workspace placement, with any data-scope
difference identified separately
- the new workspace navigation placement
The user can cancel without a state change. Confirmation creates the approved
workspace grants and changes the plugin instance scope in one transaction. The
canvas keeps its ID, plugin instance, active release, state, and release
history.
The user can cancel without a state change. Confirmation creates any needed
workspace grants and changes the plugin instance placement scope in one
transaction. The canvas keeps its ID, plugin instance, active release, state,
and release history.
Promotion publishes one lifecycle event after commit. The task and workspace
navigation projections then refresh.
@@ -641,8 +644,10 @@ connection. An edit Quick Chat session also verifies its trusted target canvas
metadata.
Task canvases cannot appear in another task. Workspace canvases can serve task
surfaces only inside their workspace. Promotion does not grant access to tasks,
repositories, sessions, or external services by itself.
surfaces only inside their workspace. A task canvas can use approved workspace
data before promotion under the [workspace preview contract](task-canvas-workspace-preview.md).
Promotion does not grant undeclared access to tasks, repositories, sessions,
or external services.
The plugin runtime repeats resource authorization for each request. Canvas
authorization does not replace task or workspace authorization.
@@ -152,7 +152,8 @@ The definition in `config/prompts/create-canvas.md` contains this guidance:
> Call `publish_canvas_kandev` and address any validation errors.
> Report the canvas identity and whether its release is active, awaits permission review, or was unsuccessful.
> If publication is unsuccessful, report the failure and do not claim that the canvas is published.
> If workspace access requires promotion, explain the user action that is still required.
> A new owner-authorized task canvas can use its declared supported permissions across the current workspace before promotion.
> Promotion adds workspace navigation. Do not claim that promotion is required to preview workspace task data.
> A local build alone does not publish a canvas inside Kandev.
> If the tools remain unavailable, report the limitation instead of claiming that workspace files are a Kandev canvas.
@@ -6,7 +6,7 @@ system: canvases
owners:
- canvases
created: 2026-09-10
last_updated: 2026-09-10
last_updated: 2026-09-23
requirements:
- REQ-CANVASES-LOCAL-CREATION-001
---
@@ -20,7 +20,8 @@ owns grant storage, release activation, and runtime enforcement. This design
implements `REQ-CANVASES-LOCAL-CREATION-001`; the authority, transaction, and
compatibility sections map to criteria .1-.3, .4-.5, and .6-.7 respectively.
See [the creation authority decision](../../../decisions/2026-09-10-canvas-creation-authority.md)
See [the creation authority decision](../../../decisions/2026-09-10-canvas-creation-authority.md),
[its workspace-data amendment](../../../decisions/2026-09-23-task-canvas-workspace-data.md),
and [plugin grants](../../plugins/system-design/isolated-web-app-contributions.md#instance-grants).
## Trusted creation authority
@@ -40,7 +41,7 @@ Add a canvas-owned `canvas_creation_authority` table through
| `canvas_id` | Primary key, references the canvas lifecycle identity |
| `owner_user_id` | Trusted workspace owner at creation |
| `creating_session_id` | Trusted source session |
| `policy_version` | Integer 1 for the initial-publication policy |
| `policy_version` | Integer 1 for historical task-data grants; version 2 for new workspace-data grants |
| `consumed_at` | Empty until the first release transaction consumes it |
Create the row in the canvas/instance creation transaction. No agent-facing
@@ -52,7 +53,9 @@ Existing databases acquire the empty table; do not backfill authority.
At publication require every predicate: a nonzero captured `PublishAuthority`,
local-canvas instance source, task scope, matching task and workspace, matching
creating session, unchanged current owner, pending instance, no active or
persisted release, no previous grant changes, and unconsumed policy version 1.
persisted release, no previous grant changes, and an unconsumed supported
policy version. New authority uses version 2; historical version-1 authority
keeps its task data ceiling and is never upgraded by publication alone.
Check the full instance snapshot again inside the transaction. Re-read the
current task/workspace ownership through the same transaction and serialize
against ownership changes; the adapter's earlier lookup alone is insufficient.
@@ -64,10 +67,12 @@ The trusted adapter supplies the freshly resolved owner; caller-controlled
## Grant derivation and transaction
Derive grants from `ManifestPermissions` and the existing static web-app
capability vocabulary. Restrict Kandev resource access to the recorded task,
using normal runtime authorization. Normalize each declared external origin
through `NormalizeNetworkOrigins`; initial external grants count as delegated
owner approval, with that owner as `ApprovedBy`. This is deliberate: a locally
capability vocabulary. Version-2 authority binds the approved data scope to
the recorded workspace while the canvas remains task-placed. Historical
version-1 authority retains task data scope. See the
[workspace preview design](task-canvas-workspace-preview.md). Normalize each
declared external origin through `NormalizeNetworkOrigins`; initial external
grants count as delegated owner approval, with that owner as `ApprovedBy`. A locally
requested app can contact its declared HTTPS service on its first run.
Unsupported capabilities and backend/native contributions cannot gain access
through this path. Do not add a generic manifest-controlled auto-approve flag.
@@ -96,8 +101,9 @@ schema replay and backup enumeration must include this table.
## API and compatibility
Return an additive `initial_permission_policy` field in the create response:
version 1, eligibility, scope `task`, supported permission kinds, and the exact
HTTPS-origin constraint. This is descriptive output, never a publish input.
the policy version, eligibility, placement `task`, data scope, supported
permission kinds, and exact HTTPS-origin constraint. This is descriptive
output, never a publish input.
It must not claim a permission ceiling that the backend does not enforce.
Keep the existing `activated` and `permission_required` publish result fields.
@@ -0,0 +1,168 @@
---
id: canvases-task-canvas-workspace-preview-design
title: Task canvas workspace data preview design
status: draft
system: canvases
owners:
- canvases
created: 2026-09-23
last_updated: 2026-09-23
requirements:
- REQ-CANVASES-WORKSPACE-PREVIEW-001
---
# Task canvas workspace data preview System Design
## Boundary and mapping
Canvases owns when a locally requested task canvas gains workspace data scope,
how an older canvas can opt in, and when promotion changes placement. Plugins
persists the instance's effective data scope and enforces it through the shared
web-app runtime. Task services remain authoritative for data and current user
access. This design implements `REQ-CANVASES-WORKSPACE-PREVIEW-001`.
| Criteria | Design sections |
| --- | --- |
| `AC-CANVASES-WORKSPACE-PREVIEW-001.1`, `.2`, `.8` | Data scope and runtime enforcement |
| `AC-CANVASES-WORKSPACE-PREVIEW-001.3`, `.4` | Lifecycle and presentation |
| `AC-CANVASES-WORKSPACE-PREVIEW-001.5`, `.6`, `.7` | Existing canvas upgrade and host flow |
See [the workspace preview decision](../../../decisions/2026-09-23-task-canvas-workspace-data.md)
and [the existing creation authority](local-creation-authority.md).
## Data scope and persistence
Keep `plugin_instances.scope_kind` as the placement/lifecycle scope. Add a
nullable `data_scope_kind` to `plugin_instances`; null means the existing
`scope_kind` so older rows and non-canvas plugins retain their behavior. A
non-null value is valid only when it equals the placement scope, or when a
`local_canvas` task instance uses `workspace` data scope with its existing
trusted `workspace_id` and `task_id`. Reject `instance` and foreign workspace
data scope for canvases. Do not accept the field from agent, manifest, or
browser input. Store it through SQLite and PostgreSQL schema paths, backup,
snapshot, and instance row mapping.
At trusted canvas creation, record workspace data scope only for a new task
canvas with owner-authorized creation provenance. The task remains the canvas
owner and the plugin instance's placement scope. The first valid release
derives grants from exactly its declared supported capabilities with a
`workspace` ceiling, then consumes creation authority and activates in the
existing transaction. Raise `CreationAuthorityPolicyVersion` for newly issued
authority; consumed version-1 rows remain historical evidence and are never
reissued. If provenance or current owner checks fail, publication follows the
normal permission-review path and cannot infer a workspace grant.
All grant-fit, release activation, and runtime-binding validation for a web
app use its effective data scope. A grant ceiling cannot be interpreted as
covering a wider data scope merely because it covers the placement scope. A
later declaration increase still creates a pending release. Revocation bumps
the existing grant generation and invalidates runtime tokens.
## Runtime enforcement
`webapp.CapabilityBinding` carries both placement `ScopeKind` and effective
`DataScopeKind`. Issue both from the persisted instance, and compare both with
the current instance on every token validation. Existing bindings with an
empty data scope resolve to placement scope. The relative
`./_kandev/v1/context` response retains `scope_kind` for placement and adds
`data_scope_kind` for authors to label the available data accurately.
Use the effective data scope for the web-app task list, single-task read,
task update, message dispatch, workflow list and steps, task dependency edge
projection, and subscribed task events. A workspace data scope binds all
queries and mutations to the instance's trusted `workspace_id`, including
explicit `workspace_id` filters. It does not derive workspace IDs from the
request or allow `ScopeInstance`. The task-list route uses its existing
pagination path instead of the bound-task shortcut. Each operation still
checks the declared capability, effective grant, and current user/domain
authorization. Other plugin instances continue to use their existing scope
rules.
Never use the ordinary authenticated Kandev HTTP API as an implicit escape
from the canvas protocol. The [same-origin trust decision](../../../decisions/2026-09-19-trusted-same-origin-canvases.md)
acknowledges that browser code can call those APIs, but the documented canvas
contract and capability-only runtime must remain coherent.
## Lifecycle and presentation
`canvas_lifecycle_metadata.task_id` and the instance's placement scope retain
task discovery and deletion semantics. Workspace data scope does not add the
canvas to workspace navigation. Promotion remains a reviewed human action:
it changes placement to workspace, clears task placement, preserves the active
release, and leaves workspace data scope intact. The review states that data
access is already workspace-wide when that is true. A task-only legacy canvas
still has its existing promotion behavior, including grant expansion.
Project `data_scope_kind` alongside `scope_kind` in the canvas and runtime
responses. The task host exposes a compact data-scope label in its existing
header/action surface. Releases and permissions and promotion review explain
placement separately from data access. The bundled authoring guidance and
public canvas documentation tell authors to read both context fields and to
declare only capabilities they use. Revise the saved `create-canvas` prompt's
promotion guidance so it describes workspace navigation rather than saying
that workspace data always requires promotion. No generated package rewrite
is needed.
## Existing canvas upgrade
Do not silently widen a retained release. An active task canvas with task-only
data scope offers **Enable workspace data** in the existing host action menu.
The backend returns a review snapshot of the active release, permission
digest, grant generation, current/target data scope, and exact declared
permissions. Only a currently authorized workspace owner may confirm it.
Confirmation rechecks the canvas's task and workspace identity, owner, active
release, permission digest, and grant generation inside one transaction. It
upserts workspace-ceiling grants only for that release's declared permissions,
sets `data_scope_kind=workspace`, increments grant generation, and publishes a
single lifecycle refresh after commit. It neither promotes the canvas nor
republishes the artifact. On stale review or any failed check it changes
nothing. A canceled review has no write. Imported or legacy packages receive
no automatic exemption.
Use the existing canvas HTTP controller's preview/confirm pattern with
`GET /api/v1/canvases/{id}/workspace-data-preview` and
`POST /api/v1/canvases/{id}/workspace-data`. The request carries the same
release ID, permission digest, and grant generation preconditions as promotion.
Return an updated canvas projection after success. Keep the action unavailable
for pending, archived, removed, already workspace-data, or foreign canvases.
## Desktop and phone host flow
Desktop keeps the current canvas panel and action toolbar. The data-scope
label sits with existing host metadata, and a legacy canvas exposes the upgrade
action from its current actions. Phone keeps the focused canvas route and
secondary-action drawer; the same upgrade opens the existing full-height
review surface. The header and actions remain fixed while one body region
scrolls, with safe-area clearance and touch targets. Both presentations share
one review state and backend snapshot; neither relies on canvas-authored UI.
## Failure and observability
Denied or stale access review returns the existing safe error style and leaves
the active release and old scope usable. A missing task or workspace makes
its runtime binding stale. Scope or grant changes invalidate old tokens; the
host reloads the runtime through its existing recovery path. Record bounded
scope-transition result counters and IDs already allowed for canvas lifecycle
logging. Do not log package source, permission bodies, tokens, or task data.
## Verification strategy
- Instance-store tests cover null fallback, invalid combinations, transaction
rollback, snapshot/backup mapping, and grant-generation invalidation.
- Canvas service tests cover version-2 owner authority, exact declarations,
legacy review, stale snapshots, owner drift, and promotion without data
expansion.
- Web-app protocol tests cover list pagination, task read/write, workflow,
message, event, dependency projection, foreign-workspace denial, and old
task-scoped behavior.
- Desktop and mobile Playwright exercise an owner-created task canvas with at
least two tasks, refresh before promotion, and an older canvas's explicit
access upgrade. The mobile route and review remain touch-usable.
## Related designs
- [Canvas lifecycle and promotion](agent-authored-web-apps.md)
- [Owner-authorized first publication](local-creation-authority.md)
- [Plugin web-app runtime](../../plugins/system-design/isolated-web-app-contributions.md)
- [Plugin canvas data-scope runtime](../../plugins/system-design/canvas-workspace-data-scope.md)
@@ -0,0 +1,72 @@
---
id: plugins-canvas-workspace-data-scope-design
title: Canvas placement and data scope in the web-app runtime
status: draft
system: plugins
owners:
- kandev
created: 2026-09-23
last_updated: 2026-09-23
requirements: []
---
# Canvas placement and data scope in the web-app runtime
## Boundary
This is the Plugins runtime implementation contract for the
[Canvas workspace preview requirement](../../canvases/requirements/task-canvas-workspace-preview.md).
Canvases decides when an owner-authorized local app may use workspace data.
Plugins persists and enforces that decision; it does not infer authority from
the manifest or browser request. The existing
[isolated web-app design](isolated-web-app-contributions.md) remains the general
runtime contract.
## Instance model and grants
Add nullable `plugin_instances.data_scope_kind`. A null value resolves to
`scope_kind` for retained rows and non-canvas instances. A non-null workspace
data scope is valid only for a `local_canvas` instance placed in a task or
workspace with a trusted `workspace_id`. Reject any attempt to use an
installation-wide data scope for a canvas. Creation, reviewed upgrade, and
promotion update the field and grants through transactions owned by the
canvas lifecycle service.
Grant fit, release activation, and runtime validation compare each declared
permission with the effective data scope. An exact task grant cannot cover
workspace data. A workspace grant authorizes only the bound workspace and is
still intersected with current user and resource access. Revocation and scope
changes increment the existing grant generation.
## Runtime binding and protocol
Add effective `DataScopeKind` to `webapp.CapabilityBinding`, sourced from the
persisted instance and checked against it on every request. Keep `ScopeKind`
for placement and task lifecycle. The relative context response exposes both
`scope_kind` and `data_scope_kind`; empty historical data scope resolves to
placement scope.
Task lists, single-task reads, task writes, messages, workflow reads, task
dependency projections, and events use data scope. Workspace-data task
canvases use the paginated workspace path, bounded by the trusted workspace
ID, rather than the task-only shortcut. Explicit foreign `workspace_id`
filters fail. Non-canvas instances retain current behavior.
## Compatibility and failure
The schema migration does not widen old rows. Historical runtime bindings
and grants remain task-scoped until a reviewed update changes them. A stale
binding, missing workspace/task, revoked grant, or mismatched data scope fails
closed through existing safe errors. Current plugin manifests and immutable
canvas packages need no rewrite.
## Verification
Test SQLite and PostgreSQL mapping, legacy null fallback, invalid scope
combinations, runtime token invalidation, exact grants, all workspace task
pages, task read/write and message paths, workflow and event scope, dependency
redaction, and foreign-workspace denial.
## Related decision
- [Task canvas workspace data](../../../decisions/2026-09-23-task-canvas-workspace-data.md)