feat: preview task canvases with workspace data (#3887)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t, 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, ¤t, 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, ¤t, 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, ¤t, 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, ¤t, 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) {
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 書き込み",
|
||||
|
||||
@@ -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": "ÀƤĨ ŵŕĩţēś",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 写入",
|
||||
|
||||
@@ -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 寫入",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user