feat(canvas): show live workflow history and simplify sharing (#3877)

This commit is contained in:
Carlos Florêncio
2026-09-23 10:57:06 +01:00
committed by GitHub
parent a2d4ee8c18
commit 41eeaf0c70
75 changed files with 4910 additions and 590 deletions
@@ -18,6 +18,7 @@ func registerCanvasDistributionRoutes(router *gin.Engine, handler *canvasHTTPHan
return
}
router.POST("/api/v1/canvases/:canvasID/exports", handler.prepareCanvasExport)
router.GET("/api/v1/canvases/:canvasID/export-defaults", handler.getCanvasExportDefaults)
router.GET("/api/v1/canvases/exports/:preparationID", handler.getCanvasExport)
router.GET("/api/v1/canvases/exports/:preparationID/bundle", handler.downloadCanvasBundle)
router.GET("/api/v1/canvases/exports/:preparationID/source", handler.downloadCanvasSource)
@@ -28,6 +29,23 @@ func registerCanvasDistributionRoutes(router *gin.Engine, handler *canvasHTTPHan
router.DELETE("/api/v1/canvases/install-preparations/:preparationID", handler.cancelCanvasInstall)
}
func (h *canvasHTTPHandler) getCanvasExportDefaults(c *gin.Context) {
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
if err != nil {
writeCanvasDistributionError(c, err)
return
}
if !h.authorizeWorkspace(c, item.WorkspaceID) {
return
}
defaults, err := h.distribution.GetExportDefaults(c.Request.Context(), canvasRuntimeUser(c), item.WorkspaceID, item.ID)
if err != nil {
writeCanvasDistributionError(c, err)
return
}
writeCanvasJSON(c, http.StatusOK, defaults)
}
type canvasExportRequest struct {
WorkspaceID string `json:"workspace_id"`
ExpectedReleaseID string `json:"expected_release_id"`
@@ -1,13 +1,48 @@
package backendapp
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
canvasservice "github.com/kandev/kandev/internal/canvas"
)
func TestCanvasRenameRequiresWorkspaceManageScope(t *testing.T) {
taskSvc, _, _ := newRunSubscriptionCheckHarness(t)
workspaceID := seedOwnedWorkspace(t, taskSvc, "canvas-owner")
owner := authn.WithIdentity(context.Background(), authn.Identity{UserID: "canvas-owner", Role: authn.RoleMember})
if _, err := taskSvc.UpsertWorkspaceMember(owner, workspaceID, "canvas-viewer", "viewer"); err != nil {
t.Fatal(err)
}
handler := &canvasHTTPHandler{tasks: taskSvc}
for _, test := range []struct {
name string
user string
allowed bool
status int
}{
{name: "owner", user: "canvas-owner", allowed: true, status: http.StatusOK},
{name: "viewer", user: "canvas-viewer", allowed: false, status: http.StatusForbidden},
} {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPatch, "/", nil).WithContext(authn.WithIdentity(context.Background(), authn.Identity{UserID: test.user, Role: authn.RoleMember}))
if allowed := handler.authorizeWorkspaceManage(ctx, workspaceID); allowed != test.allowed {
t.Fatalf("allowed = %v, want %v", allowed, test.allowed)
}
if !test.allowed && recorder.Code != test.status {
t.Fatalf("status = %d, want %d", recorder.Code, test.status)
}
})
}
}
func TestCanvasDistributionErrorStatusUsesInternalErrorForUnexpectedFailures(t *testing.T) {
status, code := canvasDistributionErrorStatus(errors.New("database unavailable"))
if status != http.StatusInternalServerError || code != "internal_error" {
@@ -21,3 +56,14 @@ func TestCanvasDistributionErrorStatusMapsIncompatibleInstall(t *testing.T) {
t.Fatalf("status = %d %q, want 409 incompatible_install", status, code)
}
}
func TestCanvasReleaseResponseIncludesSafeDistributionFields(t *testing.T) {
response := releaseResponseFromMetadata(&canvasservice.ReleaseMetadata{
ID: "release-1", PackageID: "canvas-example", Version: "1.1.0", DisplayName: "Example",
Description: "Example canvas", Author: "Author", License: "MIT", SourceMode: "static",
MinKandevVersion: "0.95.0", RepoURL: "https://example.test/canvas",
})
if response.PackageID != "canvas-example" || response.Version != "1.1.0" || response.DisplayName != "Example" || response.Description != "Example canvas" || response.Author != "Author" || response.License != "MIT" || response.SourceMode != "static" || response.MinKandevVersion != "0.95.0" || response.RepoURL != "https://example.test/canvas" {
t.Fatalf("distribution projection = %+v", response)
}
}
@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/authz"
canvasservice "github.com/kandev/kandev/internal/canvas"
"github.com/kandev/kandev/internal/common/constants"
"github.com/kandev/kandev/internal/orchestrator"
@@ -26,7 +27,7 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// canvasHTTPHandler is the owner-authorized browser API for canvas metadata
// canvasHTTPHandler is the workspace-authorized browser API for canvas metadata
// and host operations. Source transfer and publishing stay on the agent MCP
// path; browsers receive only metadata and short-lived runtime capabilities.
type canvasHTTPHandler struct {
@@ -365,6 +366,7 @@ func registerCanvasRoutes(p routeParams) {
p.router.GET("/api/v1/canvases/workspaces/:workspaceID", h.listWorkspace)
registerCanvasDistributionRoutes(p.router, h)
p.router.GET("/api/v1/canvases/:canvasID", h.get)
p.router.PATCH("/api/v1/canvases/:canvasID", h.rename)
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)
@@ -406,6 +408,15 @@ type canvasHTTPResponse struct {
type canvasReleaseResponse struct {
ID string `json:"id"`
PackageID string `json:"package_id,omitempty"`
Version string `json:"version,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
License string `json:"license,omitempty"`
SourceMode string `json:"source_mode,omitempty"`
MinKandevVersion string `json:"min_kandev_version,omitempty"`
RepoURL string `json:"repo_url,omitempty"`
PackageDigest string `json:"package_digest,omitempty"`
ValidationStatus string `json:"validation_status"`
ValidationError string `json:"validation_error,omitempty"`
@@ -518,6 +529,30 @@ func (h *canvasHTTPHandler) get(c *gin.Context) {
writeCanvasJSON(c, http.StatusOK, response)
}
func (h *canvasHTTPHandler) rename(c *gin.Context) {
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
if err != nil {
h.writeError(c, err)
return
}
if !h.authorizeWorkspaceManage(c, item.WorkspaceID) {
return
}
var request struct {
Title string `json:"title"`
}
if err := c.ShouldBindJSON(&request); err != nil {
writeCanvasError(c, http.StatusBadRequest, "invalid_request", nil)
return
}
updated, err := h.canvases.Rename(c.Request.Context(), item.ID, request.Title)
if err != nil {
h.writeError(c, err)
return
}
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
}
func (h *canvasHTTPHandler) releases(c *gin.Context) {
canvas, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
if err != nil {
@@ -553,16 +588,7 @@ func (h *canvasHTTPHandler) releases(c *gin.Context) {
}
func releaseResponse(release instances.Release, scope string, grants []instances.Grant) canvasReleaseResponse {
permissions := canvasservice.ReleasePermissionSummary(release)
return canvasReleaseResponse{
ID: release.ID, PackageDigest: release.PackageDigest,
ValidationStatus: release.ValidationStatus, ValidationError: release.ValidationError,
Permissions: &permissions, MissingPermissions: canvasservice.MissingPermissionKeys(permissions, scope, grants),
PermissionDigest: canvasservice.PermissionDigest(release), SourceActorKind: release.SourceActorKind,
SourceUserID: release.SourceUserID, SourceTaskID: release.SourceTaskID,
SourceSessionID: release.SourceSessionID, ProtocolVersion: release.ProtocolVersion,
CreatedAt: release.CreatedAt,
}
return *releaseResponseFromMetadata(canvasservice.ReleaseMetadataForHTTP(release, scope, grants))
}
func releaseResponseFromMetadata(value *canvasservice.ReleaseMetadata) *canvasReleaseResponse {
@@ -571,6 +597,9 @@ func releaseResponseFromMetadata(value *canvasservice.ReleaseMetadata) *canvasRe
}
return &canvasReleaseResponse{
ID: value.ID, PackageDigest: value.PackageDigest, ValidationStatus: value.ValidationStatus,
PackageID: value.PackageID, Version: value.Version, DisplayName: value.DisplayName,
Description: value.Description, Author: value.Author, License: value.License,
SourceMode: value.SourceMode, MinKandevVersion: value.MinKandevVersion, RepoURL: value.RepoURL,
ValidationError: value.ValidationError, Permissions: value.Permissions,
MissingPermissions: value.MissingPermissions, PermissionDigest: value.PermissionDigest,
SourceActorKind: value.SourceActorKind, SourceUserID: value.SourceUserID,
@@ -846,6 +875,17 @@ func (h *canvasHTTPHandler) authorizeWorkspace(c *gin.Context, workspaceID strin
return true
}
func (h *canvasHTTPHandler) authorizeWorkspaceManage(c *gin.Context, workspaceID string) bool {
if !h.authorizeWorkspace(c, workspaceID) {
return false
}
if err := h.tasks.AuthorizeWorkspaceScope(c.Request.Context(), workspaceID, authz.ScopeWorkspaceManage); err != nil {
writeCanvasError(c, http.StatusForbidden, "forbidden", nil)
return false
}
return true
}
func canvasRouteParam(c *gin.Context, names ...string) string {
for _, name := range names {
if value := c.Param(name); value != "" {
@@ -7,6 +7,7 @@ import (
"fmt"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
@@ -83,6 +84,35 @@ func TestGetExposesPendingFirstRelease(t *testing.T) {
}
}
func TestRenameOnlyChangesTitleAndEmitsUpdate(t *testing.T) {
service, _, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Before"})
var event LifecycleEvent
service.SetEventPublisher(func(_ context.Context, value LifecycleEvent) { event = value })
updated, err := service.Rename(context.Background(), created.ID, " After ")
if err != nil {
t.Fatal(err)
}
if updated.Title != "After" || updated.ID != created.ID || updated.PluginInstanceID != created.PluginInstanceID || updated.TaskID != created.TaskID {
t.Fatalf("unexpected rename projection: %+v", updated)
}
if event.Type != EventUpdated || event.Title != "After" || event.WorkspaceID != created.WorkspaceID {
t.Fatalf("unexpected update event: %+v", event)
}
if _, err := service.Rename(context.Background(), created.ID, " "); !errors.Is(err, ErrInvalidCanvas) {
t.Fatalf("blank rename = %v", err)
}
if _, err := service.Rename(context.Background(), created.ID, strings.Repeat("x", MaxTitleLength+1)); !errors.Is(err, ErrInvalidCanvas) {
t.Fatalf("long rename = %v", err)
}
if err := service.Remove(context.Background(), created.ID); err != nil {
t.Fatal(err)
}
if _, err := service.Rename(context.Background(), created.ID, "Restored"); !errors.Is(err, ErrCanvasNotFound) {
t.Fatalf("removed rename = %v", err)
}
}
func TestRemoveDeletesCreationAuthority(t *testing.T) {
service, _, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
@@ -53,6 +53,68 @@ type ExportMetadata struct {
RepoURL string `json:"repo_url,omitempty"`
}
// MinimumCanvasDistributionVersion is the first tagged Kandev release with
// portable canvas distribution support.
const MinimumCanvasDistributionVersion = "0.95.0"
type ExportDefaults struct {
ExpectedReleaseID string `json:"expected_release_id"`
Metadata ExportMetadata `json:"metadata"`
MissingRequired []string `json:"missing_required"`
}
// GetExportDefaults derives a temporary share draft from the active release.
func (s *DistributionService) GetExportDefaults(ctx context.Context, userID, workspaceID, canvasID string) (ExportDefaults, error) {
if err := s.validateReady(userID, workspaceID, canvasID); err != nil {
return ExportDefaults{}, err
}
if err := s.authorizeWorkspace(ctx, workspaceID); err != nil {
return ExportDefaults{}, err
}
item, err := s.canvases.Get(ctx, canvasID)
if err != nil || !validExportCanvas(item, ExportRequest{WorkspaceID: workspaceID}) {
return ExportDefaults{}, ErrCanvasNotFound
}
_, release, files, err := s.loadExportSnapshot(ctx, ExportRequest{WorkspaceID: workspaceID, CanvasID: canvasID, ExpectedReleaseID: item.ActiveReleaseID})
if err != nil {
return ExportDefaults{}, err
}
current := releaseManifest(release)
metadata := fillExportMetadata(ExportMetadata{}, current)
if current.Distribution == nil && !staticExportAvailable(current, files) {
metadata.SourceMode = ""
}
if metadata.DisplayName == "" {
metadata.DisplayName = item.Title
}
if metadata.MinKandevVersion == "" {
metadata.MinKandevVersion = MinimumCanvasDistributionVersion
}
missing := make([]string, 0, 8)
for _, field := range []struct{ key, value string }{
{"package_id", metadata.PackageID}, {"version", metadata.Version}, {"display_name", metadata.DisplayName},
{"description", metadata.Description}, {"author", metadata.Author}, {"license", metadata.License},
{"source_mode", metadata.SourceMode}, {"min_kandev_version", metadata.MinKandevVersion},
} {
if strings.TrimSpace(field.value) == "" {
missing = append(missing, field.key)
}
}
return ExportDefaults{ExpectedReleaseID: release.ID, Metadata: metadata, MissingRequired: missing}, nil
}
func staticExportAvailable(current manifest.Manifest, files map[string][]byte) bool {
if !current.IsStaticWebAppOnly() {
return false
}
for _, app := range current.UI.WebApps {
if _, ok := files[app.Entry]; !ok {
return false
}
}
return true
}
type ExportRequest struct {
UserID string
WorkspaceID string
@@ -164,10 +226,14 @@ func (s *DistributionService) PrepareExport(ctx context.Context, request ExportR
if err != nil {
return ExportReview{}, err
}
metadata, err := normalizeExportMetadata(request.Metadata, releaseManifest(release))
current := releaseManifest(release)
metadata, err := normalizeExportMetadata(request.Metadata, current)
if err != nil {
return ExportReview{}, err
}
if metadata.SourceMode == manifest.SourceModeStatic && !staticExportAvailable(current, files) {
return ExportReview{}, fmt.Errorf("%w: static export is unavailable", ErrExportInvalid)
}
manifestValue, err := exportManifest(release.ManifestJSON, metadata)
if err != nil {
return ExportReview{}, err
@@ -54,6 +54,50 @@ func TestPrepareExportBuildsOneReleaseBoundBundleAndSource(t *testing.T) {
}
}
func TestPrepareExportRejectsStaticModeWhenEntryFileIsMissing(t *testing.T) {
service, _, _ := newDistributionExportTestService(t)
delete(service.artifacts.(*fakeDistributionArtifactReader).files, "ui/index.html")
defaults, err := service.GetExportDefaults(context.Background(), "user-1", "workspace-1", "canvas-1")
if err != nil || defaults.Metadata.SourceMode != "" || !containsString(defaults.MissingRequired, "source_mode") {
t.Fatalf("missing static entry defaults = %+v, error = %v", defaults, err)
}
_, err = service.PrepareExport(context.Background(), ExportRequest{
UserID: "user-1", WorkspaceID: "workspace-1", CanvasID: "canvas-1", ExpectedReleaseID: "release-1",
Metadata: ExportMetadata{PackageID: "canvas-board", Version: "1.2.3", DisplayName: "Board", Description: "A board", Author: "Kandev", License: "MIT", SourceMode: manifest.SourceModeStatic, MinKandevVersion: "1.0.0"},
})
if !errors.Is(err, ErrExportInvalid) || service.preparationsCount() != 0 {
t.Fatalf("missing static entry prepared: error = %v, preparations = %d", err, service.preparationsCount())
}
}
func TestExportDefaultsUseReleaseAndRequireAuthorLicense(t *testing.T) {
service, releases, canvases := newDistributionExportTestService(t)
var value manifest.Manifest
if err := json.Unmarshal(releases.release.ManifestJSON, &value); err != nil {
t.Fatal(err)
}
value.MinKandevVersion = ""
value.Distribution = nil
releases.release.ManifestJSON, _ = json.Marshal(value)
canvases.canvas.Title = "Mutable canvas name"
defaults, err := service.GetExportDefaults(context.Background(), "user-1", "workspace-1", "canvas-1")
if err != nil {
t.Fatal(err)
}
if defaults.ExpectedReleaseID != "release-1" || defaults.Metadata.PackageID != value.ID || defaults.Metadata.Version != value.Version || defaults.Metadata.DisplayName != value.DisplayName || defaults.Metadata.Author != value.Author || defaults.Metadata.MinKandevVersion != MinimumCanvasDistributionVersion {
t.Fatalf("unexpected defaults: %+v", defaults)
}
if len(defaults.MissingRequired) != 1 || defaults.MissingRequired[0] != "license" {
t.Fatalf("missing fields = %v", defaults.MissingRequired)
}
value.MinKandevVersion = "1.5.0"
releases.release.ManifestJSON, _ = json.Marshal(value)
newDefaults, err := service.GetExportDefaults(context.Background(), "user-1", "workspace-1", "canvas-1")
if err != nil || newDefaults.Metadata.MinKandevVersion != "1.5.0" {
t.Fatalf("higher minimum = %+v, %v", newDefaults, err)
}
}
func TestPrepareExportAllowsTaskCanvasWithPortableWorkspacePlacement(t *testing.T) {
service, releases, canvases := newDistributionExportTestService(t)
var sourceManifest manifest.Manifest
@@ -37,6 +37,11 @@ func releaseMetadata(release plugininstances.Release, scope string, grants []plu
}
}
// ReleaseMetadataForHTTP returns safe manifest fields for host projections.
func ReleaseMetadataForHTTP(release plugininstances.Release, scope string, grants []plugininstances.Grant) *ReleaseMetadata {
return releaseMetadata(release, scope, grants)
}
type manifestSeed struct {
PackageID string
Version string
@@ -455,6 +455,22 @@ func (r *Repository) DeleteTx(ctx context.Context, tx *sqlx.Tx, id string) error
return nil
}
// Rename changes only the mutable canvas title. A removed row cannot be recreated.
func (r *Repository) Rename(ctx context.Context, id, title string, updatedAt time.Time) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`UPDATE canvas_lifecycle_metadata SET title = ?, updated_at = ? WHERE id = ?`), title, updatedAt.UTC().Format(time.RFC3339Nano), id)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrCanvasNotFound
}
return nil
}
// Promote records the human-owned provenance and clears the current task
// relationship. The instance scope/grants are changed by the plugin
// instance store immediately before this metadata transaction.
+36
View File
@@ -173,6 +173,40 @@ func (s *Service) GetCanvas(ctx context.Context, id string) (*Canvas, error) {
return s.Get(ctx, id)
}
// Rename updates the instance title without changing its release or runtime.
func (s *Service) Rename(ctx context.Context, id, title string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
title = strings.TrimSpace(title)
if title == "" || len([]rune(title)) > MaxTitleLength {
return nil, ErrInvalidCanvas
}
s.mu.Lock()
_, instance, err := s.load(ctx, id)
if err == nil && instance.Status == StatusRemoved {
err = ErrCanvasNotFound
}
if err == nil {
err = s.repo.Rename(ctx, id, title, s.nowUTC())
}
var updated Canvas
if err == nil {
metadata, current, loadErr := s.load(ctx, id)
err = loadErr
if err == nil {
updated, err = s.buildCanvas(ctx, metadata, current)
}
}
publisher := s.publisher
s.mu.Unlock()
if err != nil {
return nil, err
}
publishEvent(ctx, publisher, lifecycleEvent(EventUpdated, updated))
return &updated, nil
}
// ListTaskCanvases lists task-scoped canvases. Removed canvases are never
// returned; archived canvases are returned only when includeArchived is true.
func (s *Service) ListTaskCanvases(ctx context.Context, taskID string, includeArchived bool) ([]Canvas, error) {
@@ -855,6 +889,8 @@ func isWorkspaceCanvas(metadata CanvasMetadata, instance plugininstances.Instanc
func lifecycleEvent(eventType string, canvas Canvas) LifecycleEvent {
return LifecycleEvent{
Type: eventType,
Title: canvas.Title,
UpdatedAt: canvas.UpdatedAt,
CanvasID: canvas.ID,
PluginInstanceID: canvas.PluginInstanceID,
WorkspaceID: canvas.WorkspaceID,
+12 -9
View File
@@ -207,19 +207,22 @@ type transactionalPluginInstanceStore interface {
// It never carries source files, application state, package bodies, or runtime
// capabilities.
type LifecycleEvent struct {
Type string `json:"type"`
CanvasID string `json:"canvas_id"`
PluginInstanceID string `json:"plugin_instance_id"`
WorkspaceID string `json:"workspace_id"`
TaskID string `json:"task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
Status string `json:"status"`
ActiveReleaseID string `json:"active_release_id,omitempty"`
ActiveReleaseStatus string `json:"active_release_status,omitempty"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
CanvasID string `json:"canvas_id"`
PluginInstanceID string `json:"plugin_instance_id"`
WorkspaceID string `json:"workspace_id"`
TaskID string `json:"task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
Status string `json:"status"`
ActiveReleaseID string `json:"active_release_id,omitempty"`
ActiveReleaseStatus string `json:"active_release_status,omitempty"`
}
const (
EventCreated = "canvas.created"
EventUpdated = "canvas.updated"
EventReleaseActivated = "canvas.release.activated"
EventReleasePermissionRequired = "canvas.release.permission_required"
EventPromoted = "canvas.promoted"
+1
View File
@@ -37,6 +37,7 @@ const (
// source files, application state, or runtime capabilities.
const (
CanvasCreated = "canvas.created"
CanvasUpdated = "canvas.updated"
CanvasReleaseActivated = "canvas.release.activated"
CanvasReleasePermissionRequired = "canvas.release.permission_required"
CanvasPromoted = "canvas.promoted"
@@ -40,6 +40,7 @@ func RegisterCanvasNotifications(ctx context.Context, eventBus bus.EventBus, hub
action string
}{
{events.CanvasCreated, ws.ActionCanvasCreated},
{events.CanvasUpdated, ws.ActionCanvasUpdated},
{events.CanvasReleaseActivated, ws.ActionCanvasReleaseActivated},
{events.CanvasReleasePermissionRequired, ws.ActionCanvasReleasePermissionRequired},
{events.CanvasPromoted, ws.ActionCanvasPromoted},
@@ -59,10 +59,12 @@ workspace.
| --- | --- | --- | --- |
| GET | `./_kandev/v1/data/tasks` | `api_read:tasks` | List tasks |
| GET | `./_kandev/v1/data/tasks/{task_id}` | `api_read:tasks` | Read one task |
| GET | `./_kandev/v1/data/tasks/{task_id}/step-transitions` | `api_read:tasks` | Read recorded task moves |
| PATCH | `./_kandev/v1/data/tasks/{task_id}` | `api_write:tasks` | Update a task |
| POST | `./_kandev/v1/data/tasks/{task_id}/messages` | `api_write:messages` | Send a task message |
| GET | `./_kandev/v1/data/workflows` | `api_read:workflows` | List workflows |
| GET | `./_kandev/v1/data/workflows/{workflow_id}/steps` | `api_read:workflows` | Read workflow steps |
| GET | `./_kandev/v1/data/workflows/{workflow_id}/transition-groups` | `api_read:tasks` and `api_read:workflows`; workspace canvas only | Read recorded workflow route counts |
The task-list query accepts `cursor`, `limit`, `include_archived`,
`workflow_id`, `state`, and `parent_id`. `workflow_id` and `state` can be
@@ -120,6 +122,20 @@ A workflow object contains `id`, `workspace_id`, `name`, `description`,
`is_start_step`, `wip_limit`, `agent_profile_id`, and
`on_enter_action_types`.
The task transition route returns rows newest first, with `id` as a decimal
string, nullable `from_workflow_id`, `from_workflow_step_id`,
`to_workflow_id`, and `to_workflow_step_id`, `trigger`, and RFC3339
`occurred_at`. It omits actor and session identity. The opaque `cursor`
continues by ledger ID; it is bound to the requested task. Empty history
means no retained move was recorded, not that the task never moved.
The workflow transition-group route returns `kind` (`within`, `entry`, or
`exit`), nullable `from_step_id` and `to_step_id`, and `count`. It includes
archived tasks and removed step IDs, but not deleted tasks. Its opaque cursor
orders groups by kind and step IDs. Both routes use the collection envelope
and the 1..200 `limit` rule above. A task canvas cannot request workflow-wide
groups; promotion to workspace scope is a user action, not a fallback.
## Writes and workflow movement
`PATCH ./_kandev/v1/data/tasks/{task_id}` accepts one or more of these JSON
@@ -26,6 +26,10 @@ func int64Ptr(v int64) *int64 { return &v }
// ── fakes for the narrow Host data API interfaces ───────────────────────
type fakeTaskDataSource struct {
transitionRows map[string][]taskmodels.StepTransition
transitionGroups map[string][]taskmodels.TransitionGroup
transitionCalls int
groupCalls int
workspaces []*taskmodels.Workspace
tasksByWorkspace map[string][]*taskmodels.Task
tasksByID map[string]*taskmodels.Task
@@ -66,6 +70,16 @@ type fakeTaskDataSource struct {
dependencyViewsTasks []string
}
func (f *fakeTaskDataSource) ListTaskStepTransitions(_ context.Context, taskID string, _ int, _ string) ([]taskmodels.StepTransition, string, error) {
f.transitionCalls++
return f.transitionRows[taskID], "", nil
}
func (f *fakeTaskDataSource) ListWorkflowTransitionGroups(_ context.Context, workflowID string, _ int, _ string) ([]taskmodels.TransitionGroup, string, error) {
f.groupCalls++
return f.transitionGroups[workflowID], "", nil
}
func (f *fakeTaskDataSource) ListWorkspaces(context.Context) ([]*taskmodels.Workspace, error) {
return f.workspaces, nil
}
@@ -402,6 +416,24 @@ func newTestDataHost(caps manifest.Capabilities) *testDataHost {
return d
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.4 AC-PLUGINS-WORKFLOW-HISTORY-002.1
func TestHostTransitionHistoryUsesExistingReadGrants(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-1"}}
d.tasks.transitionRows = map[string][]taskmodels.StepTransition{"task-1": {{ID: 9, Trigger: "test", OccurredAt: time.Now().UTC()}}}
reader, ok := pluginsdk.TransitionHistory(d.host)
if !ok {
t.Fatal("plugin Host transition-history extension is unavailable")
}
items, _, err := reader.ListTask(context.Background(), "task-1", pluginsdk.Page{})
if err != nil || len(items) != 1 || items[0].ID != "9" {
t.Fatalf("task history = %+v, err=%v", items, err)
}
if _, _, err := reader.ListWorkflowGroups(context.Background(), "wf-1", pluginsdk.Page{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("workflow groups without workflows grant: %v", err)
}
}
// ── capability gating: denied without api_read:<resource> ──────────────
func TestPluginHost_Tasks_DeniedWithoutCapability(t *testing.T) {
@@ -0,0 +1,79 @@
package plugins
import (
"context"
"errors"
"strconv"
"time"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/pkg/pluginsdk"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (h *pluginHost) Transitions() pluginsdk.TransitionHistoryReader {
return hostTransitionHistoryReader{host: h}
}
type hostTransitionHistoryReader struct{ host *pluginHost }
func (r hostTransitionHistoryReader) ListTask(ctx context.Context, taskID string, page pluginsdk.Page) ([]pluginsdk.TaskStepTransition, *pluginsdk.PageInfo, error) {
if !r.host.capabilities.CanRead(resourceTasks) {
return nil, nil, permissionDenied(apiReadCapability(resourceTasks))
}
task, err := r.host.fetchTaskForScopeCheck(ctx, taskID)
if err != nil {
return nil, nil, err
}
if task == nil {
return nil, nil, status.Error(codes.NotFound, "task not found")
}
source, ok := r.host.taskData.(transitionDataSource)
if !ok {
return nil, nil, status.Error(codes.Unimplemented, "transition history unavailable")
}
items, next, err := source.ListTaskStepTransitions(ctx, taskID, normalizePageLimit(page.Limit), page.Cursor)
if err != nil {
return nil, nil, hostTransitionReadError(err)
}
result := make([]pluginsdk.TaskStepTransition, len(items))
for i, item := range items {
result[i] = pluginsdk.TaskStepTransition{
ID: strconv.FormatInt(item.ID, 10), FromWorkflowID: item.FromWorkflowID,
FromWorkflowStepID: item.FromWorkflowStepID, ToWorkflowID: item.ToWorkflowID,
ToWorkflowStepID: item.ToWorkflowStepID, Trigger: item.Trigger,
OccurredAt: item.OccurredAt.UTC().Format(time.RFC3339Nano),
}
}
return result, &pluginsdk.PageInfo{NextCursor: next, HasMore: next != ""}, nil
}
func (r hostTransitionHistoryReader) ListWorkflowGroups(ctx context.Context, workflowID string, page pluginsdk.Page) ([]pluginsdk.WorkflowTransitionGroup, *pluginsdk.PageInfo, error) {
if !r.host.capabilities.CanRead(resourceTasks) {
return nil, nil, permissionDenied(apiReadCapability(resourceTasks))
}
if !r.host.capabilities.CanRead(resourceWorkflows) {
return nil, nil, permissionDenied(apiReadCapability(resourceWorkflows))
}
source, ok := r.host.taskData.(transitionDataSource)
if !ok {
return nil, nil, status.Error(codes.Unimplemented, "transition history unavailable")
}
items, next, err := source.ListWorkflowTransitionGroups(ctx, workflowID, normalizePageLimit(page.Limit), page.Cursor)
if err != nil {
return nil, nil, hostTransitionReadError(err)
}
result := make([]pluginsdk.WorkflowTransitionGroup, len(items))
for i, item := range items {
result[i] = pluginsdk.WorkflowTransitionGroup{Kind: item.Kind, FromStepID: item.FromStepID, ToStepID: item.ToStepID, Count: item.Count}
}
return result, &pluginsdk.PageInfo{NextCursor: next, HasMore: next != ""}, nil
}
func hostTransitionReadError(err error) error {
if errors.Is(err, taskservice.ErrInvalidTransitionCursor) {
return status.Error(codes.InvalidArgument, "invalid transition cursor")
}
return err
}
@@ -219,6 +219,8 @@ func (s *Service) handleWebAppTasks(ctx context.Context, w http.ResponseWriter,
s.listWebAppTasks(ctx, w, r, host, binding)
case len(parts) == 1 && r.Method == http.MethodGet:
s.getWebAppTask(ctx, w, r, host, binding, parts[0])
case len(parts) == 2 && parts[1] == "step-transitions" && r.Method == http.MethodGet:
s.listWebAppTaskTransitions(ctx, w, r, host, binding, parts[0])
case len(parts) == 1 && r.Method == http.MethodPatch:
s.updateWebAppTask(ctx, w, r, host, binding, parts[0])
case len(parts) == 2 && parts[1] == "messages" && r.Method == http.MethodPost:
@@ -234,6 +236,8 @@ func (s *Service) handleWebAppWorkflows(ctx context.Context, w http.ResponseWrit
s.listWebAppWorkflows(ctx, w, r, host, binding)
case len(parts) == 2 && parts[1] == "steps" && r.Method == http.MethodGet:
s.listWebAppWorkflowSteps(ctx, w, r, host, binding, parts[0])
case len(parts) == 2 && parts[1] == "transition-groups" && r.Method == http.MethodGet:
s.listWebAppWorkflowTransitionGroups(ctx, w, r, host, binding, parts[0])
default:
writeWebAppError(w, http.StatusMethodNotAllowed, "method_not_allowed")
}
@@ -0,0 +1,146 @@
package plugins
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/webapp"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
)
type transitionDataSource interface {
ListTaskStepTransitions(context.Context, string, int, string) ([]taskmodels.StepTransition, string, error)
ListWorkflowTransitionGroups(context.Context, string, int, string) ([]taskmodels.TransitionGroup, string, error)
}
type webAppTransition struct {
ID string `json:"id"`
FromWorkflowID *string `json:"from_workflow_id"`
FromWorkflowStepID *string `json:"from_workflow_step_id"`
ToWorkflowID *string `json:"to_workflow_id"`
ToWorkflowStepID *string `json:"to_workflow_step_id"`
Trigger string `json:"trigger"`
OccurredAt string `json:"occurred_at"`
}
type webAppTransitionGroup struct {
Kind string `json:"kind"`
FromStepID *string `json:"from_step_id"`
ToStepID *string `json:"to_step_id"`
Count int64 `json:"count"`
}
func (s *Service) listWebAppTaskTransitions(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, taskID string) {
if !validWebAppKey(taskID) {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
page, err := webAppRequestPage(r)
if err != nil {
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
return
}
task, err := host.fetchTaskForScopeCheck(ctx, taskID)
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if task == nil {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
if !webAppTaskMatches(ctx, host, binding, *task) {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
source, ok := s.taskData.(transitionDataSource)
if !ok {
writeWebAppError(w, http.StatusNotImplemented, "not_implemented")
return
}
items, next, err := source.ListTaskStepTransitions(ctx, taskID, int(page.Limit), page.Cursor)
if err != nil {
writeTransitionReadError(w, err)
return
}
result := make([]webAppTransition, len(items))
for i, item := range items {
result[i] = webAppTransition{
ID: strconv.FormatInt(item.ID, 10), FromWorkflowID: item.FromWorkflowID,
FromWorkflowStepID: item.FromWorkflowStepID, ToWorkflowID: item.ToWorkflowID,
ToWorkflowStepID: item.ToWorkflowStepID, Trigger: item.Trigger,
OccurredAt: item.OccurredAt.UTC().Format(time.RFC3339Nano),
}
}
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppTransition]{Items: result, PageInfo: webAppPageInfo{NextCursor: next, HasMore: next != ""}})
}
func (s *Service) listWebAppWorkflowTransitionGroups(ctx context.Context, w http.ResponseWriter, r *http.Request, host *pluginHost, binding webapp.CapabilityBinding, workflowID string) {
if !validWebAppKey(workflowID) {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
if binding.ScopeKind != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) {
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
return
}
page, err := webAppRequestPage(r)
if err != nil {
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
return
}
if host.workflows == nil {
writeWebAppError(w, http.StatusNotImplemented, "not_implemented")
return
}
allowed, err := webAppWorkflowAllowed(ctx, host, binding.WorkspaceID, workflowID)
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if !allowed {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
source, ok := s.taskData.(transitionDataSource)
if !ok {
writeWebAppError(w, http.StatusNotImplemented, "not_implemented")
return
}
items, next, err := source.ListWorkflowTransitionGroups(ctx, workflowID, int(page.Limit), page.Cursor)
if err != nil {
writeTransitionReadError(w, err)
return
}
result := make([]webAppTransitionGroup, len(items))
for i, item := range items {
result[i] = webAppTransitionGroup{Kind: item.Kind, FromStepID: item.FromStepID, ToStepID: item.ToStepID, Count: item.Count}
}
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppTransitionGroup]{Items: result, PageInfo: webAppPageInfo{NextCursor: next, HasMore: next != ""}})
}
func webAppWorkflowAllowed(ctx context.Context, host *pluginHost, workspaceID, workflowID string) (bool, error) {
workflows, err := host.workflows.ListWorkflows(ctx, workspaceID, false)
if err != nil {
return false, err
}
for _, workflow := range workflows {
if workflow != nil && workflow.ID == workflowID && workflow.WorkspaceID == workspaceID {
return true, nil
}
}
return false, nil
}
func writeTransitionReadError(w http.ResponseWriter, err error) {
if errors.Is(err, taskservice.ErrInvalidTransitionCursor) {
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
return
}
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
}
@@ -0,0 +1,84 @@
package plugins
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/webapp"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/stretchr/testify/require"
)
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.4 AC-PLUGINS-WORKFLOW-HISTORY-001.5
func TestWebAppTransitionTrailChecksTaskScopeBeforeReading(t *testing.T) {
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
source := &fakeTaskDataSource{
tasksByID: map[string]*taskmodels.Task{"task-1": task, "task-2": {ID: "task-2", WorkspaceID: "ws-1"}},
transitionRows: map[string][]taskmodels.StepTransition{"task-1": {{ID: 7, ToWorkflowStepID: stringPointer("review"), Trigger: "task_moved", OccurredAt: time.Now().UTC()}}},
}
svc := &Service{taskData: source}
binding := webapp.CapabilityBinding{ScopeKind: instances.ScopeTask, WorkspaceID: "ws-1", TaskID: "task-1", Permissions: []string{"api_read:tasks"}}
denied := httptest.NewRecorder()
svc.handleWebAppProtocol(denied, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks/task-2/step-transitions")
require.Equal(t, http.StatusNotFound, denied.Code)
require.Zero(t, source.transitionCalls)
allowed := httptest.NewRecorder()
svc.handleWebAppProtocol(allowed, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks/task-1/step-transitions")
require.Equal(t, http.StatusOK, allowed.Code, allowed.Body.String())
require.Equal(t, 1, source.transitionCalls)
require.Contains(t, allowed.Body.String(), `"id":"7"`)
require.NotContains(t, allowed.Body.String(), "actor_id")
require.NotContains(t, allowed.Body.String(), "session_id")
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-002.1
func TestWebAppTransitionGroupsFindWorkflowBeyondFirstListPage(t *testing.T) {
workflows := make([]*taskmodels.Workflow, 201)
for i := range workflows {
workflows[i] = &taskmodels.Workflow{ID: "wf-" + strconv.Itoa(i), WorkspaceID: "ws-1"}
}
source := &fakeTaskDataSource{transitionGroups: map[string][]taskmodels.TransitionGroup{"wf-200": {{Kind: "entry", Count: 1}}}}
svc := &Service{taskData: source, workflows: &fakeWorkflowLister{workflows: map[string][]*taskmodels.Workflow{"ws-1": workflows}}, workflowSteps: &fakeWorkflowStepLister{}}
binding := webapp.CapabilityBinding{ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1", Permissions: []string{"api_read:tasks", "api_read:workflows"}}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/workflows/wf-200/transition-groups")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
}
func stringPointer(value string) *string { return &value }
// @covers AC-PLUGINS-WORKFLOW-HISTORY-002.1 AC-PLUGINS-WORKFLOW-HISTORY-002.3
func TestWebAppTransitionGroupsRequireWorkspaceAndBothGrants(t *testing.T) {
source := &fakeTaskDataSource{transitionGroups: map[string][]taskmodels.TransitionGroup{
"wf-1": {{Kind: "within", FromStepID: stringPointer("draft"), ToStepID: stringPointer("review"), Count: 2}},
}}
svc := &Service{
taskData: source,
workflows: &fakeWorkflowLister{workflows: map[string][]*taskmodels.Workflow{"ws-1": {{ID: "wf-1", WorkspaceID: "ws-1"}}}},
workflowSteps: &fakeWorkflowStepLister{},
}
binding := webapp.CapabilityBinding{ScopeKind: instances.ScopeTask, WorkspaceID: "ws-1", TaskID: "task-1", Permissions: []string{"api_read:tasks", "api_read:workflows"}}
request := httptest.NewRequest(http.MethodGet, "/", nil)
call := func(workflowID string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, request, "", binding, "v1/data/workflows/"+workflowID+"/transition-groups")
return recorder
}
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
binding.ScopeKind = instances.ScopeWorkspace
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)
allowed := call("wf-1")
require.Equal(t, http.StatusOK, allowed.Code, allowed.Body.String())
require.Equal(t, 1, source.groupCalls)
require.Contains(t, allowed.Body.String(), `"count":2`)
}
@@ -0,0 +1,22 @@
package models
import "time"
// StepTransition is the public-safe recorded movement of one task.
type StepTransition struct {
ID int64
FromWorkflowID *string
FromWorkflowStepID *string
ToWorkflowID *string
ToWorkflowStepID *string
Trigger string
OccurredAt time.Time
}
// TransitionGroup is one route through, into, or out of a workflow.
type TransitionGroup struct {
Kind string
FromStepID *string
ToStepID *string
Count int64
}
@@ -1090,8 +1090,14 @@ func (r *Repository) initStepTransitionsSchema() error {
);
CREATE INDEX IF NOT EXISTS idx_task_step_transitions_task
ON task_step_transitions(task_id, occurred_at, id);
CREATE INDEX IF NOT EXISTS idx_task_step_transitions_task_id
ON task_step_transitions(task_id, id);
CREATE INDEX IF NOT EXISTS idx_task_step_transitions_occurred
ON task_step_transitions(occurred_at);
CREATE INDEX IF NOT EXISTS idx_task_step_transitions_from_workflow
ON task_step_transitions(from_workflow_id, task_id);
CREATE INDEX IF NOT EXISTS idx_task_step_transitions_to_workflow
ON task_step_transitions(to_workflow_id, task_id);
`)
if err != nil {
return fmt.Errorf("init step transitions schema: %w", err)
@@ -0,0 +1,102 @@
package sqlite
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.1
func TestTaskTransitionReadReturnsGenesis(t *testing.T) {
repo := newStepTransitionsTestRepo(t)
createStepTransitionsTestTask(t, repo, "history-task", "workflow-1", "draft")
rows, err := repo.ListTaskStepTransitions(context.Background(), "history-task", 0, 10)
if err != nil {
t.Fatalf("list transition history: %v", err)
}
if len(rows) != 1 || rows[0].FromWorkflowID != nil || rows[0].ToWorkflowStepID == nil || *rows[0].ToWorkflowStepID != "draft" {
t.Fatalf("rows = %+v, want genesis row with a null source", rows)
}
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.1 AC-PLUGINS-WORKFLOW-HISTORY-001.2
func TestTaskTransitionReadPagesByIDAndPreservesRemovedEndpoints(t *testing.T) {
repo := newStepTransitionsTestRepo(t)
createStepTransitionsTestTask(t, repo, "history-pages", "workflow-1", "draft")
now := time.Now().UTC()
for _, step := range []string{"review", "removed-step", "done"} {
if _, err := repo.db.Exec(repo.db.Rebind(`INSERT INTO task_step_transitions
(task_id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, contract_version, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), "history-pages", "workflow-1", "draft", "workflow-1", step, "test", "system", 1, now); err != nil {
t.Fatal(err)
}
}
first, err := repo.ListTaskStepTransitions(context.Background(), "history-pages", 0, 2)
if err != nil || len(first) != 2 || first[0].ID <= first[1].ID {
t.Fatalf("first page = %+v, err %v", first, err)
}
second, err := repo.ListTaskStepTransitions(context.Background(), "history-pages", first[1].ID, 2)
if err != nil || len(second) != 2 || second[0].ID >= first[1].ID {
t.Fatalf("second page = %+v, err %v", second, err)
}
if second[0].ToWorkflowStepID == nil || *second[0].ToWorkflowStepID != "review" || second[1].FromWorkflowID != nil {
t.Fatalf("removed/nullable endpoints changed: %+v", second)
}
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-002.1 AC-PLUGINS-WORKFLOW-HISTORY-002.2
func TestWorkflowTransitionGroupsCountRetainedRoutes(t *testing.T) {
repo := newStepTransitionsTestRepo(t)
ctx := context.Background()
createStepTransitionsTestTask(t, repo, "route-task", "workflow-1", "draft")
if err := repo.ArchiveTask(ctx, "route-task"); err != nil {
t.Fatal(err)
}
for _, move := range [][4]any{
{"workflow-1", "draft", "workflow-1", "review"},
{"workflow-1", "review", "workflow-2", "other"},
{"workflow-2", "other", "workflow-1", "removed-step"},
} {
if _, err := repo.db.Exec(repo.db.Rebind(`INSERT INTO task_step_transitions
(task_id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, contract_version, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), "route-task", move[0], move[1], move[2], move[3], "test", "system", 1, time.Now().UTC()); err != nil {
t.Fatal(err)
}
}
if err := repo.CreateTask(ctx, &models.Task{ID: "foreign-route", WorkspaceID: "ws-2", WorkflowID: "workflow-1", WorkflowStepID: "draft", Title: "Foreign", Priority: "medium"}); err != nil {
t.Fatal(err)
}
groups, err := repo.ListWorkflowTransitionGroups(ctx, "ws-1", "workflow-1", "", 10)
if err != nil {
t.Fatal(err)
}
if len(groups) != 4 || groups[0].Kind != "entry" || groups[1].Kind != "entry" || groups[2].Kind != "exit" || groups[3].Kind != "within" {
t.Fatalf("groups = %+v, want two entries, exit, within", groups)
}
if groups[1].ToStepID == nil || *groups[1].ToStepID != "removed-step" {
t.Fatalf("removed step lost: %+v", groups[1])
}
page, err := repo.ListWorkflowTransitionGroups(ctx, "ws-1", "workflow-1", "entry||draft", 2)
if err != nil || len(page) != 2 || page[0].Kind != "entry" || page[1].Kind != "exit" {
t.Fatalf("next route page = %+v, err %v", page, err)
}
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-002.1
func TestWorkflowTransitionReadIndexesExistAfterSchemaReplay(t *testing.T) {
repo := newStepTransitionsTestRepo(t)
if err := repo.initStepTransitionsSchema(); err != nil {
t.Fatal(err)
}
for _, name := range []string{"idx_task_step_transitions_task_id", "idx_task_step_transitions_from_workflow", "idx_task_step_transitions_to_workflow"} {
var count int
if err := repo.db.Get(&count, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, name); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("index %s missing after schema replay", name)
}
}
}
@@ -10,8 +10,87 @@ import (
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/steptelemetry"
"github.com/kandev/kandev/internal/task/models"
)
// ListTaskStepTransitions reads newest-first ledger rows using immutable IDs.
func (r *Repository) ListTaskStepTransitions(ctx context.Context, taskID string, beforeID int64, limit int) ([]models.StepTransition, error) {
query := `SELECT id, from_workflow_id, from_workflow_step_id, to_workflow_id, to_workflow_step_id, trigger, occurred_at
FROM task_step_transitions WHERE task_id = ?`
args := []any{taskID}
if beforeID > 0 {
query += ` AND id < ?`
args = append(args, beforeID)
}
query += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, fmt.Errorf("list task step transitions: %w", err)
}
defer func() { _ = rows.Close() }()
items := make([]models.StepTransition, 0)
for rows.Next() {
var item models.StepTransition
var fromWorkflow, fromStep, toWorkflow, toStep sql.NullString
if err := rows.Scan(&item.ID, &fromWorkflow, &fromStep, &toWorkflow, &toStep, &item.Trigger, &item.OccurredAt); err != nil {
return nil, fmt.Errorf("scan task step transition: %w", err)
}
item.FromWorkflowID = nullableValue(fromWorkflow)
item.FromWorkflowStepID = nullableValue(fromStep)
item.ToWorkflowID = nullableValue(toWorkflow)
item.ToWorkflowStepID = nullableValue(toStep)
items = append(items, item)
}
return items, rows.Err()
}
func nullableValue(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
// ListWorkflowTransitionGroups counts retained routes involving one workflow.
// Rows remain present when a step is removed because the ledger stores IDs.
func (r *Repository) ListWorkflowTransitionGroups(ctx context.Context, workspaceID, workflowID, afterKey string, limit int) ([]models.TransitionGroup, error) {
const query = `WITH scoped AS (
SELECT CASE WHEN h.from_workflow_id = ? AND h.to_workflow_id = ? THEN 'within'
WHEN h.to_workflow_id = ? THEN 'entry' ELSE 'exit' END AS kind,
CASE WHEN h.from_workflow_id = ? THEN h.from_workflow_step_id END AS from_step_id,
CASE WHEN h.to_workflow_id = ? THEN h.to_workflow_step_id END AS to_step_id
FROM task_step_transitions h JOIN tasks t ON t.id = h.task_id
WHERE t.workspace_id = ? AND (h.from_workflow_id = ? OR h.to_workflow_id = ?)
), grouped AS (
SELECT kind, from_step_id, to_step_id, COUNT(*) AS route_count
FROM scoped GROUP BY kind, from_step_id, to_step_id
), ordered AS (
SELECT kind, from_step_id, to_step_id, route_count,
kind || '|' || COALESCE(from_step_id, '') || '|' || COALESCE(to_step_id, '') AS route_key
FROM grouped
)
SELECT kind, from_step_id, to_step_id, route_count FROM ordered
WHERE route_key > ? ORDER BY route_key LIMIT ?`
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), workflowID, workflowID, workflowID,
workflowID, workflowID, workspaceID, workflowID, workflowID, afterKey, limit)
if err != nil {
return nil, fmt.Errorf("list workflow transition groups: %w", err)
}
defer func() { _ = rows.Close() }()
items := make([]models.TransitionGroup, 0)
for rows.Next() {
var item models.TransitionGroup
var fromStep, toStep sql.NullString
if err := rows.Scan(&item.Kind, &fromStep, &toStep, &item.Count); err != nil {
return nil, fmt.Errorf("scan workflow transition group: %w", err)
}
item.FromStepID, item.ToStepID = nullableValue(fromStep), nullableValue(toStep)
items = append(items, item)
}
return items, rows.Err()
}
// stepTransitionTx is satisfied by both *sql.Tx and *sqlx.Tx, the two
// transaction types the mutation paths in this package use.
type stepTransitionTx interface {
@@ -5,6 +5,7 @@ package sqlite
// KANDEV_TEST_POSTGRES_DSN is set; CI runs these in postgres-boot.
import (
"context"
"testing"
"time"
@@ -42,4 +43,19 @@ func TestPostgresStepTransitionsSchemaCreatesTableAndIsReplaySafe(t *testing.T)
if err := repo.initStepTransitionsSchema(); err != nil {
t.Fatalf("replay initStepTransitionsSchema twice: %v", err)
}
for _, name := range []string{"idx_task_step_transitions_task_id", "idx_task_step_transitions_from_workflow", "idx_task_step_transitions_to_workflow"} {
var indexCount int
if err := db.Get(&indexCount, `SELECT COUNT(*) FROM pg_indexes WHERE schemaname = current_schema() AND indexname = $1`, name); err != nil || indexCount != 1 {
t.Fatalf("index %s: count=%d err=%v", name, indexCount, err)
}
}
if _, err := db.Exec(db.Rebind(`INSERT INTO task_step_transitions
(task_id, to_workflow_id, to_workflow_step_id, trigger, actor_kind, contract_version, occurred_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`), "task-pre-ledger-pg", "wf-pg", "draft", "test", "system", 1, now); err != nil {
t.Fatal(err)
}
groups, err := repo.ListWorkflowTransitionGroups(context.Background(), "ws-pre-ledger-pg", "wf-pg", "", 10)
if err != nil || len(groups) != 1 || groups[0].Kind != "entry" {
t.Fatalf("postgres groups = %+v, err=%v", groups, err)
}
}
@@ -0,0 +1,133 @@
package service
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/kandev/kandev/internal/task/models"
)
var ErrInvalidTransitionCursor = errors.New("invalid transition cursor")
type stepTransitionReader interface {
ListTaskStepTransitions(context.Context, string, int64, int) ([]models.StepTransition, error)
ListWorkflowTransitionGroups(context.Context, string, string, string, int) ([]models.TransitionGroup, error)
}
type transitionCursor struct {
Scope string `json:"scope"`
ID int64 `json:"id,omitempty"`
Key string `json:"key,omitempty"`
}
func decodeTransitionCursor(encoded, scope string) (transitionCursor, error) {
if encoded == "" {
return transitionCursor{Scope: scope}, nil
}
if len(encoded) > 1024 {
return transitionCursor{}, ErrInvalidTransitionCursor
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return transitionCursor{}, ErrInvalidTransitionCursor
}
var cursor transitionCursor
if json.Unmarshal(data, &cursor) != nil || cursor.Scope != scope {
return transitionCursor{}, ErrInvalidTransitionCursor
}
return cursor, nil
}
func encodeTransitionCursor(cursor transitionCursor) string {
data, _ := json.Marshal(cursor)
return base64.RawURLEncoding.EncodeToString(data)
}
func transitionReadLimit(limit int) (int, error) {
if limit < 0 {
return 0, fmt.Errorf("invalid transition limit")
}
if limit == 0 {
return 50, nil
}
if limit > 200 {
return 200, nil
}
return limit, nil
}
// ListTaskStepTransitions returns one bounded page of retained task movement.
func (s *Service) ListTaskStepTransitions(ctx context.Context, taskID string, limit int, encodedCursor string) ([]models.StepTransition, string, error) {
if err := s.authorizeTaskID(ctx, taskID); err != nil {
return nil, "", err
}
reader, ok := s.tasks.(stepTransitionReader)
if !ok {
return nil, "", fmt.Errorf("task transition reads unavailable")
}
pageSize, err := transitionReadLimit(limit)
if err != nil {
return nil, "", err
}
cursor, err := decodeTransitionCursor(encodedCursor, "task:"+taskID)
if err != nil || (encodedCursor != "" && (cursor.ID <= 0 || cursor.Key != "")) {
return nil, "", ErrInvalidTransitionCursor
}
items, err := reader.ListTaskStepTransitions(ctx, taskID, cursor.ID, pageSize+1)
if err != nil {
return nil, "", err
}
if len(items) <= pageSize {
return items, "", nil
}
items = items[:pageSize]
return items, encodeTransitionCursor(transitionCursor{Scope: "task:" + taskID, ID: items[len(items)-1].ID}), nil
}
// ListWorkflowTransitionGroups returns bounded route counts for a workflow.
func (s *Service) ListWorkflowTransitionGroups(ctx context.Context, workflowID string, limit int, encodedCursor string) ([]models.TransitionGroup, string, error) {
if err := s.authorizeWorkflowID(ctx, workflowID); err != nil {
return nil, "", err
}
workflow, err := s.workflows.GetWorkflow(ctx, workflowID)
if err != nil {
return nil, "", err
}
if workflow == nil {
return nil, "", fmt.Errorf("workflow not found")
}
reader, ok := s.tasks.(stepTransitionReader)
if !ok {
return nil, "", fmt.Errorf("workflow transition reads unavailable")
}
pageSize, err := transitionReadLimit(limit)
if err != nil {
return nil, "", err
}
cursor, err := decodeTransitionCursor(encodedCursor, "workflow:"+workflowID)
if err != nil || (encodedCursor != "" && (cursor.Key == "" || cursor.ID != 0)) {
return nil, "", ErrInvalidTransitionCursor
}
items, err := reader.ListWorkflowTransitionGroups(ctx, workflow.WorkspaceID, workflowID, cursor.Key, pageSize+1)
if err != nil {
return nil, "", err
}
if len(items) <= pageSize {
return items, "", nil
}
items = items[:pageSize]
last := items[len(items)-1]
key := strings.Join([]string{last.Kind, stepValue(last.FromStepID), stepValue(last.ToStepID)}, "|")
return items, encodeTransitionCursor(transitionCursor{Scope: "workflow:" + workflowID, Key: key}), nil
}
func stepValue(step *string) string {
if step == nil {
return ""
}
return *step
}
@@ -0,0 +1,67 @@
package service
import (
"context"
"testing"
"github.com/kandev/kandev/internal/task/models"
)
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.1 AC-PLUGINS-WORKFLOW-HISTORY-001.3
func TestTransitionReadServicePagesRecordedMoves(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "history-ws", Name: "History"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTask(ctx, &models.Task{ID: "history-task", WorkspaceID: "history-ws", WorkflowID: "history-wf", WorkflowStepID: "draft", Title: "History", Priority: "medium"}); err != nil {
t.Fatal(err)
}
items, next, err := svc.ListTaskStepTransitions(ctx, "history-task", 1, "")
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || next != "" {
t.Fatalf("items = %+v, next = %q, want one genesis row", items, next)
}
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.1 AC-PLUGINS-WORKFLOW-HISTORY-001.3
func TestTransitionReadServiceRejectsForeignAndMalformedCursors(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "cursor-ws", Name: "Cursor"}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"cursor-a", "cursor-b"} {
if err := repo.CreateTask(ctx, &models.Task{ID: id, WorkspaceID: "cursor-ws", WorkflowID: "wf", WorkflowStepID: "draft", Title: id, Priority: "medium"}); err != nil {
t.Fatal(err)
}
}
foreign := encodeTransitionCursor(transitionCursor{Scope: "task:cursor-a", ID: 1})
for _, cursor := range []string{foreign, "not-base64!"} {
items, next, err := svc.ListTaskStepTransitions(ctx, "cursor-b", 10, cursor)
if err != ErrInvalidTransitionCursor || len(items) != 0 || next != "" {
t.Fatalf("cursor %q: items=%+v next=%q err=%v", cursor, items, next, err)
}
}
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-002.1
func TestWorkflowTransitionGroupServiceReadsOwnedWorkflow(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "route-ws", Name: "Routes"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "route-wf", WorkspaceID: "route-ws", Name: "Routes"}); err != nil {
t.Fatal(err)
}
if err := repo.CreateTask(ctx, &models.Task{ID: "route-task", WorkspaceID: "route-ws", WorkflowID: "route-wf", WorkflowStepID: "draft", Title: "Task", Priority: "medium"}); err != nil {
t.Fatal(err)
}
groups, next, err := svc.ListWorkflowTransitionGroups(ctx, "route-wf", 10, "")
if err != nil || len(groups) != 1 || groups[0].Kind != "entry" || next != "" {
t.Fatalf("groups=%+v next=%q err=%v", groups, next, err)
}
}
@@ -0,0 +1,143 @@
package pluginsdk
import (
"context"
pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// TaskStepTransition is one retained task movement. Identity is a decimal
// string to preserve the ledger's int64 value in browser and SDK clients.
type TaskStepTransition struct {
ID string
FromWorkflowID *string
FromWorkflowStepID *string
ToWorkflowID *string
ToWorkflowStepID *string
Trigger string
OccurredAt string
}
// WorkflowTransitionGroup counts one route through or across a workflow.
type WorkflowTransitionGroup struct {
Kind string
FromStepID *string
ToStepID *string
Count int64
}
// TransitionHistoryReader is an optional Host extension so existing Host
// implementations remain source compatible.
type TransitionHistoryReader interface {
ListTask(ctx context.Context, taskID string, page Page) ([]TaskStepTransition, *PageInfo, error)
ListWorkflowGroups(ctx context.Context, workflowID string, page Page) ([]WorkflowTransitionGroup, *PageInfo, error)
}
type TransitionHistoryHost interface {
Transitions() TransitionHistoryReader
}
// TransitionHistory returns a Host's optional transition-history reader.
func TransitionHistory(host Host) (TransitionHistoryReader, bool) {
provider, ok := host.(TransitionHistoryHost)
if !ok {
return nil, false
}
return provider.Transitions(), true
}
func (h *grpcHostClient) Transitions() TransitionHistoryReader {
return grpcTransitionHistoryReader{client: h.client}
}
type grpcTransitionHistoryReader struct{ client pluginv1.HostClient }
func (r grpcTransitionHistoryReader) ListTask(ctx context.Context, taskID string, page Page) ([]TaskStepTransition, *PageInfo, error) {
response, err := r.client.ListTaskStepTransitions(ctx, &pluginv1.ListTaskStepTransitionsRequest{TaskId: taskID, Page: page.toProto()})
if err != nil {
return nil, nil, err
}
items := make([]TaskStepTransition, len(response.GetItems()))
for i, item := range response.GetItems() {
items[i] = taskStepTransitionFromProto(item)
}
return items, pageInfoFromProto(response.GetPageInfo()), nil
}
func (r grpcTransitionHistoryReader) ListWorkflowGroups(ctx context.Context, workflowID string, page Page) ([]WorkflowTransitionGroup, *PageInfo, error) {
response, err := r.client.ListWorkflowTransitionGroups(ctx, &pluginv1.ListWorkflowTransitionGroupsRequest{WorkflowId: workflowID, Page: page.toProto()})
if err != nil {
return nil, nil, err
}
items := make([]WorkflowTransitionGroup, len(response.GetItems()))
for i, item := range response.GetItems() {
items[i] = workflowTransitionGroupFromProto(item)
}
return items, pageInfoFromProto(response.GetPageInfo()), nil
}
func (s *grpcHostServer) ListTaskStepTransitions(ctx context.Context, request *pluginv1.ListTaskStepTransitionsRequest) (*pluginv1.ListTaskStepTransitionsResponse, error) {
reader, ok := TransitionHistory(s.impl)
if !ok {
return nil, status.Error(codes.Unimplemented, "transition history unavailable")
}
items, page, err := reader.ListTask(ctx, request.GetTaskId(), pageFromProto(request.GetPage()))
if err != nil {
return nil, err
}
converted := make([]*pluginv1.TaskStepTransition, len(items))
for i, item := range items {
converted[i] = item.toProto()
}
return &pluginv1.ListTaskStepTransitionsResponse{Items: converted, PageInfo: page.toProto()}, nil
}
func (s *grpcHostServer) ListWorkflowTransitionGroups(ctx context.Context, request *pluginv1.ListWorkflowTransitionGroupsRequest) (*pluginv1.ListWorkflowTransitionGroupsResponse, error) {
reader, ok := TransitionHistory(s.impl)
if !ok {
return nil, status.Error(codes.Unimplemented, "transition history unavailable")
}
items, page, err := reader.ListWorkflowGroups(ctx, request.GetWorkflowId(), pageFromProto(request.GetPage()))
if err != nil {
return nil, err
}
converted := make([]*pluginv1.WorkflowTransitionGroup, len(items))
for i, item := range items {
converted[i] = item.toProto()
}
return &pluginv1.ListWorkflowTransitionGroupsResponse{Items: converted, PageInfo: page.toProto()}, nil
}
func (item TaskStepTransition) toProto() *pluginv1.TaskStepTransition {
return &pluginv1.TaskStepTransition{
Id: item.ID, FromWorkflowId: item.FromWorkflowID,
FromWorkflowStepId: item.FromWorkflowStepID, ToWorkflowId: item.ToWorkflowID,
ToWorkflowStepId: item.ToWorkflowStepID, Trigger: item.Trigger,
OccurredAt: item.OccurredAt,
}
}
func taskStepTransitionFromProto(item *pluginv1.TaskStepTransition) TaskStepTransition {
if item == nil {
return TaskStepTransition{}
}
return TaskStepTransition{
ID: item.GetId(), FromWorkflowID: item.FromWorkflowId,
FromWorkflowStepID: item.FromWorkflowStepId, ToWorkflowID: item.ToWorkflowId,
ToWorkflowStepID: item.ToWorkflowStepId, Trigger: item.GetTrigger(),
OccurredAt: item.GetOccurredAt(),
}
}
func (item WorkflowTransitionGroup) toProto() *pluginv1.WorkflowTransitionGroup {
return &pluginv1.WorkflowTransitionGroup{Kind: item.Kind, FromStepId: item.FromStepID, ToStepId: item.ToStepID, Count: item.Count}
}
func workflowTransitionGroupFromProto(item *pluginv1.WorkflowTransitionGroup) WorkflowTransitionGroup {
if item == nil {
return WorkflowTransitionGroup{}
}
return WorkflowTransitionGroup{Kind: item.GetKind(), FromStepID: item.FromStepId, ToStepID: item.ToStepId, Count: item.GetCount()}
}
@@ -0,0 +1,60 @@
package pluginsdk
import (
"context"
"reflect"
"testing"
"github.com/stretchr/testify/require"
)
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.4 AC-PLUGINS-WORKFLOW-HISTORY-002.1
func TestHostClientExposesOptionalTransitionHistory(t *testing.T) {
if _, ok := reflect.TypeOf(&grpcHostClient{}).MethodByName("Transitions"); !ok {
t.Fatal("Host client has no transition-history extension")
}
}
type recordingTransitionHost struct {
*dataRecordingHost
reader *recordingTransitionReader
}
func (h *recordingTransitionHost) Transitions() TransitionHistoryReader { return h.reader }
type recordingTransitionReader struct {
lastTaskID string
lastPage Page
lastWFID string
}
func (r *recordingTransitionReader) ListTask(_ context.Context, taskID string, page Page) ([]TaskStepTransition, *PageInfo, error) {
r.lastTaskID, r.lastPage = taskID, page
return []TaskStepTransition{{ID: "9223372036854775806", ToWorkflowStepID: strPtr("review"), Trigger: "test", OccurredAt: "2026-09-22T12:00:00Z"}}, &PageInfo{NextCursor: "next", HasMore: true}, nil
}
func (r *recordingTransitionReader) ListWorkflowGroups(_ context.Context, workflowID string, page Page) ([]WorkflowTransitionGroup, *PageInfo, error) {
r.lastWFID, r.lastPage = workflowID, page
return []WorkflowTransitionGroup{{Kind: "entry", ToStepID: strPtr("review"), Count: 3}}, &PageInfo{}, nil
}
// @covers AC-PLUGINS-WORKFLOW-HISTORY-001.1 AC-PLUGINS-WORKFLOW-HISTORY-002.1
func TestTransitionHistoryRoundTripsAcrossHostRPC(t *testing.T) {
impl := &recordingTransitionHost{dataRecordingHost: &dataRecordingHost{}, reader: &recordingTransitionReader{}}
host := dialHostOverBufconn(t, impl)
reader, ok := TransitionHistory(host)
require.True(t, ok)
items, page, err := reader.ListTask(context.Background(), "task-1", Page{Limit: 2, Cursor: "before"})
require.NoError(t, err)
require.Equal(t, "task-1", impl.reader.lastTaskID)
require.Equal(t, Page{Limit: 2, Cursor: "before"}, impl.reader.lastPage)
require.Equal(t, "9223372036854775806", items[0].ID)
require.Nil(t, items[0].FromWorkflowStepID)
require.Equal(t, "review", *items[0].ToWorkflowStepID)
require.Equal(t, &PageInfo{NextCursor: "next", HasMore: true}, page)
groups, _, err := reader.ListWorkflowGroups(context.Background(), "wf-1", Page{Limit: 5})
require.NoError(t, err)
require.Equal(t, "wf-1", impl.reader.lastWFID)
require.Equal(t, int64(3), groups[0].Count)
require.Nil(t, groups[0].FromStepID)
}
+1
View File
@@ -103,6 +103,7 @@ const (
// Plugin-backed canvas lifecycle notifications. These are owner-scoped
// workspace/task events and contain no application content or capabilities.
ActionCanvasCreated = "canvas.created"
ActionCanvasUpdated = "canvas.updated"
ActionCanvasReleaseActivated = "canvas.release.activated"
ActionCanvasReleasePermissionRequired = "canvas.release.permission_required"
ActionCanvasPromoted = "canvas.promoted"
File diff suppressed because it is too large Load Diff
@@ -82,9 +82,11 @@ service Host {
// Reads — capability api_read:<resource>
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
rpc GetTask(GetTaskRequest) returns (GetTaskResponse);
rpc ListTaskStepTransitions(ListTaskStepTransitionsRequest) returns (ListTaskStepTransitionsResponse);
rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse);
rpc ListWorkflows(ListWorkflowsRequest) returns (ListWorkflowsResponse);
rpc ListWorkflowSteps(ListWorkflowStepsRequest) returns (ListWorkflowStepsResponse);
rpc ListWorkflowTransitionGroups(ListWorkflowTransitionGroupsRequest) returns (ListWorkflowTransitionGroupsResponse);
rpc ListAgentProfiles(ListAgentProfilesRequest) returns (ListAgentProfilesResponse);
rpc ListExecutorProfiles(ListExecutorProfilesRequest) returns (ListExecutorProfilesResponse);
rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse);
@@ -517,6 +519,23 @@ message GetTaskRequest {
message GetTaskResponse {
Task task = 1;
}
message TaskStepTransition {
string id = 1; // decimal ledger identity; stable across timestamp ties
optional string from_workflow_id = 2;
optional string from_workflow_step_id = 3;
optional string to_workflow_id = 4;
optional string to_workflow_step_id = 5;
string trigger = 6;
string occurred_at = 7; // RFC3339
}
message ListTaskStepTransitionsRequest {
string task_id = 1;
Page page = 2;
}
message ListTaskStepTransitionsResponse {
repeated TaskStepTransition items = 1;
PageInfo page_info = 2;
}
// ── Workspace / Workflow / Steps ─────────────────────────────────────────────
message Workspace {
@@ -554,6 +573,20 @@ message ListWorkflowsResponse {
repeated Workflow workflows = 1;
PageInfo page_info = 2;
}
message WorkflowTransitionGroup {
string kind = 1; // within | entry | exit
optional string from_step_id = 2;
optional string to_step_id = 3;
int64 count = 4;
}
message ListWorkflowTransitionGroupsRequest {
string workflow_id = 1;
Page page = 2;
}
message ListWorkflowTransitionGroupsResponse {
repeated WorkflowTransitionGroup items = 1;
PageInfo page_info = 2;
}
message WorkflowStep {
string id = 1;
@@ -503,9 +503,11 @@ const (
Host_GetConfig_FullMethodName = "/kandev.plugin.v1.Host/GetConfig"
Host_ListTasks_FullMethodName = "/kandev.plugin.v1.Host/ListTasks"
Host_GetTask_FullMethodName = "/kandev.plugin.v1.Host/GetTask"
Host_ListTaskStepTransitions_FullMethodName = "/kandev.plugin.v1.Host/ListTaskStepTransitions"
Host_ListWorkspaces_FullMethodName = "/kandev.plugin.v1.Host/ListWorkspaces"
Host_ListWorkflows_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflows"
Host_ListWorkflowSteps_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflowSteps"
Host_ListWorkflowTransitionGroups_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflowTransitionGroups"
Host_ListAgentProfiles_FullMethodName = "/kandev.plugin.v1.Host/ListAgentProfiles"
Host_ListExecutorProfiles_FullMethodName = "/kandev.plugin.v1.Host/ListExecutorProfiles"
Host_ListRepositories_FullMethodName = "/kandev.plugin.v1.Host/ListRepositories"
@@ -581,9 +583,11 @@ type HostClient interface {
// Reads — capability api_read:<resource>
ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error)
GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*GetTaskResponse, error)
ListTaskStepTransitions(ctx context.Context, in *ListTaskStepTransitionsRequest, opts ...grpc.CallOption) (*ListTaskStepTransitionsResponse, error)
ListWorkspaces(ctx context.Context, in *ListWorkspacesRequest, opts ...grpc.CallOption) (*ListWorkspacesResponse, error)
ListWorkflows(ctx context.Context, in *ListWorkflowsRequest, opts ...grpc.CallOption) (*ListWorkflowsResponse, error)
ListWorkflowSteps(ctx context.Context, in *ListWorkflowStepsRequest, opts ...grpc.CallOption) (*ListWorkflowStepsResponse, error)
ListWorkflowTransitionGroups(ctx context.Context, in *ListWorkflowTransitionGroupsRequest, opts ...grpc.CallOption) (*ListWorkflowTransitionGroupsResponse, error)
ListAgentProfiles(ctx context.Context, in *ListAgentProfilesRequest, opts ...grpc.CallOption) (*ListAgentProfilesResponse, error)
ListExecutorProfiles(ctx context.Context, in *ListExecutorProfilesRequest, opts ...grpc.CallOption) (*ListExecutorProfilesResponse, error)
ListRepositories(ctx context.Context, in *ListRepositoriesRequest, opts ...grpc.CallOption) (*ListRepositoriesResponse, error)
@@ -788,6 +792,16 @@ func (c *hostClient) GetTask(ctx context.Context, in *GetTaskRequest, opts ...gr
return out, nil
}
func (c *hostClient) ListTaskStepTransitions(ctx context.Context, in *ListTaskStepTransitionsRequest, opts ...grpc.CallOption) (*ListTaskStepTransitionsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListTaskStepTransitionsResponse)
err := c.cc.Invoke(ctx, Host_ListTaskStepTransitions_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostClient) ListWorkspaces(ctx context.Context, in *ListWorkspacesRequest, opts ...grpc.CallOption) (*ListWorkspacesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListWorkspacesResponse)
@@ -818,6 +832,16 @@ func (c *hostClient) ListWorkflowSteps(ctx context.Context, in *ListWorkflowStep
return out, nil
}
func (c *hostClient) ListWorkflowTransitionGroups(ctx context.Context, in *ListWorkflowTransitionGroupsRequest, opts ...grpc.CallOption) (*ListWorkflowTransitionGroupsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListWorkflowTransitionGroupsResponse)
err := c.cc.Invoke(ctx, Host_ListWorkflowTransitionGroups_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostClient) ListAgentProfiles(ctx context.Context, in *ListAgentProfilesRequest, opts ...grpc.CallOption) (*ListAgentProfilesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAgentProfilesResponse)
@@ -1089,9 +1113,11 @@ type HostServer interface {
// Reads — capability api_read:<resource>
ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error)
GetTask(context.Context, *GetTaskRequest) (*GetTaskResponse, error)
ListTaskStepTransitions(context.Context, *ListTaskStepTransitionsRequest) (*ListTaskStepTransitionsResponse, error)
ListWorkspaces(context.Context, *ListWorkspacesRequest) (*ListWorkspacesResponse, error)
ListWorkflows(context.Context, *ListWorkflowsRequest) (*ListWorkflowsResponse, error)
ListWorkflowSteps(context.Context, *ListWorkflowStepsRequest) (*ListWorkflowStepsResponse, error)
ListWorkflowTransitionGroups(context.Context, *ListWorkflowTransitionGroupsRequest) (*ListWorkflowTransitionGroupsResponse, error)
ListAgentProfiles(context.Context, *ListAgentProfilesRequest) (*ListAgentProfilesResponse, error)
ListExecutorProfiles(context.Context, *ListExecutorProfilesRequest) (*ListExecutorProfilesResponse, error)
ListRepositories(context.Context, *ListRepositoriesRequest) (*ListRepositoriesResponse, error)
@@ -1212,6 +1238,9 @@ func (UnimplementedHostServer) ListTasks(context.Context, *ListTasksRequest) (*L
func (UnimplementedHostServer) GetTask(context.Context, *GetTaskRequest) (*GetTaskResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetTask not implemented")
}
func (UnimplementedHostServer) ListTaskStepTransitions(context.Context, *ListTaskStepTransitionsRequest) (*ListTaskStepTransitionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListTaskStepTransitions not implemented")
}
func (UnimplementedHostServer) ListWorkspaces(context.Context, *ListWorkspacesRequest) (*ListWorkspacesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListWorkspaces not implemented")
}
@@ -1221,6 +1250,9 @@ func (UnimplementedHostServer) ListWorkflows(context.Context, *ListWorkflowsRequ
func (UnimplementedHostServer) ListWorkflowSteps(context.Context, *ListWorkflowStepsRequest) (*ListWorkflowStepsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListWorkflowSteps not implemented")
}
func (UnimplementedHostServer) ListWorkflowTransitionGroups(context.Context, *ListWorkflowTransitionGroupsRequest) (*ListWorkflowTransitionGroupsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListWorkflowTransitionGroups not implemented")
}
func (UnimplementedHostServer) ListAgentProfiles(context.Context, *ListAgentProfilesRequest) (*ListAgentProfilesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListAgentProfiles not implemented")
}
@@ -1524,6 +1556,24 @@ func _Host_GetTask_Handler(srv interface{}, ctx context.Context, dec func(interf
return interceptor(ctx, in, info, handler)
}
func _Host_ListTaskStepTransitions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListTaskStepTransitionsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServer).ListTaskStepTransitions(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Host_ListTaskStepTransitions_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServer).ListTaskStepTransitions(ctx, req.(*ListTaskStepTransitionsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Host_ListWorkspaces_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListWorkspacesRequest)
if err := dec(in); err != nil {
@@ -1578,6 +1628,24 @@ func _Host_ListWorkflowSteps_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _Host_ListWorkflowTransitionGroups_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListWorkflowTransitionGroupsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServer).ListWorkflowTransitionGroups(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: Host_ListWorkflowTransitionGroups_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServer).ListWorkflowTransitionGroups(ctx, req.(*ListWorkflowTransitionGroupsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Host_ListAgentProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAgentProfilesRequest)
if err := dec(in); err != nil {
@@ -2029,6 +2097,10 @@ var Host_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetTask",
Handler: _Host_GetTask_Handler,
},
{
MethodName: "ListTaskStepTransitions",
Handler: _Host_ListTaskStepTransitions_Handler,
},
{
MethodName: "ListWorkspaces",
Handler: _Host_ListWorkspaces_Handler,
@@ -2041,6 +2113,10 @@ var Host_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListWorkflowSteps",
Handler: _Host_ListWorkflowSteps_Handler,
},
{
MethodName: "ListWorkflowTransitionGroups",
Handler: _Host_ListWorkflowTransitionGroups_Handler,
},
{
MethodName: "ListAgentProfiles",
Handler: _Host_ListAgentProfiles_Handler,
@@ -145,6 +145,7 @@ describe("canvas host action guidance", () => {
onPromote={vi.fn()}
onReleases={vi.fn()}
onShare={vi.fn()}
onRename={vi.fn()}
onSelectCanvas={vi.fn()}
editing={false}
/>,
@@ -141,6 +141,7 @@ export function CanvasHostHeader({
menuOpen,
onOpenActions,
actions,
renameAction,
overflowActions,
}: {
title: string;
@@ -148,12 +149,18 @@ export function CanvasHostHeader({
menuOpen: boolean;
onOpenActions: () => void;
actions?: ReactNode;
renameAction?: ReactNode;
overflowActions?: ReactNode;
}) {
return (
<PanelHeaderBarSplit
data-testid="canvas-host-header"
left={<span className="truncate text-sm font-medium">{title}</span>}
left={
<div className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium">{title}</span>
{renameAction}
</div>
}
right={
<>
{actions}
@@ -369,6 +376,7 @@ export function MobileCanvasActions({
onPromote,
onReleases,
onShare,
onRename,
onSelectCanvas,
editing,
}: {
@@ -380,6 +388,7 @@ export function MobileCanvasActions({
onPromote: () => void;
onReleases: () => void;
onShare: () => void;
onRename: () => void;
onSelectCanvas: (canvas: Canvas) => void;
editing: boolean;
}) {
@@ -402,6 +411,17 @@ export function MobileCanvasActions({
t={t}
/>
)}
{canvas && (
<Button
variant="ghost"
className="min-h-11 w-full justify-start"
onClick={onRename}
data-testid="canvas-mobile-rename"
>
<IconEdit className="mr-2 size-4" />
{t("canvases:renameCanvas")}
</Button>
)}
{canvas?.scope_kind === "workspace" && (
<MobileCanvasEditAction canvas={canvas} editing={editing} onEdit={onEdit} t={t} />
)}
@@ -13,6 +13,7 @@ export function CanvasHostFrame({
menuOpen,
setMenuOpen,
desktopActions,
renameAction,
desktopOverflowActions,
desktopOverflowMenuItems,
desktopOverflowPrimaryAction,
@@ -27,6 +28,7 @@ export function CanvasHostFrame({
menuOpen: boolean;
setMenuOpen: (open: boolean) => void;
desktopActions: ReactNode;
renameAction: ReactNode;
desktopOverflowActions: ReactNode;
desktopOverflowMenuItems: ReactNode;
desktopOverflowPrimaryAction: ReactNode;
@@ -45,6 +47,7 @@ export function CanvasHostFrame({
menuOpen={menuOpen}
onOpenActions={() => setMenuOpen(true)}
actions={isMobile ? null : desktopActions}
renameAction={isMobile ? null : renameAction}
overflowActions={isMobile ? null : desktopOverflowActions}
/>
{canvasBody}
@@ -57,6 +60,14 @@ export function CanvasHostFrame({
return (
<PageShell
title={title}
titleSlot={
!isMobile ? (
<span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium">{title}</span>
{renameAction}
</span>
) : undefined
}
backHref="/"
backLabel={t("sidebar:home")}
scroll="none"
@@ -1,6 +1,10 @@
"use client";
import { CanvasShareDialog } from "./canvas-share-dialog";
import { CanvasRenameDialog } from "./canvas-rename-dialog";
import { Button } from "@kandev/ui/button";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import { IconPencil } from "@tabler/icons-react";
import { CanvasHostFrame } from "./canvas-host-frame";
import { useTranslation } from "react-i18next";
import {
@@ -29,15 +33,18 @@ type CanvasHostRouteViewProps = {
promotionOpen: boolean;
releasesOpen: boolean;
shareOpen: boolean;
renameOpen: boolean;
editing: boolean;
setMenuOpen: (open: boolean) => void;
setPromotionOpen: (open: boolean) => void;
setReleasesOpen: (open: boolean) => void;
setShareOpen: (open: boolean) => void;
setRenameOpen: (open: boolean) => void;
onEdit: () => void;
onPromote: () => void;
onReleases: () => void;
onShare: () => void;
onRename: () => void;
onSelectCanvas: (canvas: Canvas) => void;
onRuntimeReady: () => void;
onRuntimeError: () => void;
@@ -54,6 +61,8 @@ function CanvasHostRouteDialogs({
setReleasesOpen,
shareOpen,
setShareOpen,
renameOpen,
setRenameOpen,
onPromotionCompleted,
onChanged,
}: Pick<
@@ -65,6 +74,8 @@ function CanvasHostRouteDialogs({
| "setReleasesOpen"
| "shareOpen"
| "setShareOpen"
| "renameOpen"
| "setRenameOpen"
| "onPromotionCompleted"
| "onChanged"
>) {
@@ -80,6 +91,12 @@ function CanvasHostRouteDialogs({
onChanged={onChanged}
/>
<CanvasShareDialog canvas={canvas} open={shareOpen} onOpenChange={setShareOpen} />
<CanvasRenameDialog
canvas={canvas}
open={renameOpen}
onOpenChange={setRenameOpen}
onRenamed={onChanged}
/>
</>
);
}
@@ -119,6 +136,7 @@ function CanvasHostRouteMobileActions({
onPromote,
onReleases,
onShare,
onRename,
editing,
hostCanvases,
onSelectCanvas,
@@ -131,6 +149,7 @@ function CanvasHostRouteMobileActions({
| "onPromote"
| "onReleases"
| "onShare"
| "onRename"
| "editing"
| "onSelectCanvas"
> & { hostCanvases: Canvas[] }) {
@@ -143,6 +162,7 @@ function CanvasHostRouteMobileActions({
onPromote={onPromote}
onReleases={onReleases}
onShare={onShare}
onRename={onRename}
editing={editing}
canvases={hostCanvases}
onSelectCanvas={onSelectCanvas}
@@ -181,6 +201,19 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
const { t } = useTranslation();
const title = canvas?.title || t("canvases:canvas");
const desktopActions = createCanvasDesktopActions(canvas, props);
const renameAction = canvas ? (
<Button
variant="ghost"
size="icon"
className={controlSizingClassName("icon")}
aria-label={t("canvases:renameCanvas")}
title={t("canvases:renameCanvas")}
onClick={props.onRename}
data-testid="canvas-rename-action"
>
<IconPencil className="size-4" />
</Button>
) : null;
const mobileActionsButton = isMobile ? (
<CanvasMobileActionsButton menuOpen={menuOpen} onOpenActions={() => setMenuOpen(true)} />
) : null;
@@ -205,6 +238,7 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
onPromote={props.onPromote}
onReleases={props.onReleases}
onShare={props.onShare}
onRename={props.onRename}
editing={props.editing}
hostCanvases={props.hostCanvases}
onSelectCanvas={props.onSelectCanvas}
@@ -219,6 +253,8 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
setReleasesOpen={props.setReleasesOpen}
shareOpen={props.shareOpen}
setShareOpen={props.setShareOpen}
renameOpen={props.renameOpen}
setRenameOpen={props.setRenameOpen}
onPromotionCompleted={props.onPromotionCompleted}
onChanged={props.onChanged}
/>
@@ -232,6 +268,7 @@ export function CanvasHostRouteView(props: CanvasHostRouteViewProps) {
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
desktopActions={desktopActions.actions}
renameAction={renameAction}
desktopOverflowActions={desktopActions.overflowActions}
desktopOverflowMenuItems={desktopActions.overflowMenuItems}
desktopOverflowPrimaryAction={desktopActions.overflowPrimaryAction}
@@ -356,6 +356,7 @@ function useCanvasHost(canvasId: string) {
error,
lifecycleRevision,
load,
refresh,
renewRuntime,
markRuntimeReady,
markRuntimeUnavailable,
@@ -405,6 +406,7 @@ export function CanvasHostRoute({
state,
error,
load,
refresh,
markRuntimeReady,
markRuntimeUnavailable,
setHostError,
@@ -414,6 +416,7 @@ export function CanvasHostRoute({
const [promotionOpen, setPromotionOpen] = useState(false);
const [releasesOpen, setReleasesOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
@@ -460,21 +463,27 @@ export function CanvasHostRoute({
promotionOpen={promotionOpen}
releasesOpen={releasesOpen}
shareOpen={shareOpen}
renameOpen={renameOpen}
editing={editing}
setMenuOpen={setMenuOpen}
setPromotionOpen={setPromotionOpen}
setReleasesOpen={setReleasesOpen}
setShareOpen={setShareOpen}
setRenameOpen={setRenameOpen}
onEdit={() => void edit()}
onPromote={() => setPromotionOpen(true)}
onReleases={() => setReleasesOpen(true)}
onShare={() => setShareOpen(true)}
onRename={() => {
setMenuOpen(false);
setRenameOpen(true);
}}
onSelectCanvas={selectCanvas}
onRuntimeReady={markRuntimeReady}
onRuntimeError={markRuntimeUnavailable}
onRetry={load}
onPromotionCompleted={() => router.push(canvas ? canvasHref(canvas.id) : "/")}
onChanged={load}
onChanged={refresh}
/>
);
}
@@ -0,0 +1,72 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Canvas } from "@/lib/api/domains/canvas-api";
const renameCanvas = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/domains/canvas-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/domains/canvas-api")>()),
renameCanvas,
}));
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: false }),
}));
vi.mock("@kandev/ui/dialog", () => ({
Dialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
open ? <>{children}</> : null,
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
import { CanvasRenameDialog } from "./canvas-rename-dialog";
const canvas = { id: "canvas-1", title: "Old title" } as Canvas;
const CANVAS_NAME_LABEL = "canvases:canvasName";
afterEach(() => {
cleanup();
renameCanvas.mockReset();
});
describe("CanvasRenameDialog", () => {
it("trims and saves the title", async () => {
renameCanvas.mockResolvedValue({ ...canvas, title: "New title" });
const onOpenChange = vi.fn();
const onRenamed = vi.fn();
render(
<CanvasRenameDialog canvas={canvas} open onOpenChange={onOpenChange} onRenamed={onRenamed} />,
);
fireEvent.change(screen.getByLabelText(CANVAS_NAME_LABEL), {
target: { value: " New title " },
});
fireEvent.click(screen.getByRole("button", { name: "common:save" }));
await waitFor(() => expect(renameCanvas).toHaveBeenCalledWith("canvas-1", "New title"));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onRenamed).toHaveBeenCalled();
});
it("retains the draft after failure and makes no request on cancel", async () => {
renameCanvas.mockRejectedValue(new Error("offline"));
const onOpenChange = vi.fn();
render(
<CanvasRenameDialog canvas={canvas} open onOpenChange={onOpenChange} onRenamed={vi.fn()} />,
);
fireEvent.change(screen.getByLabelText(CANVAS_NAME_LABEL), { target: { value: "Draft" } });
fireEvent.click(screen.getByRole("button", { name: "common:save" }));
await screen.findByRole("alert");
expect((screen.getByLabelText(CANVAS_NAME_LABEL) as HTMLInputElement).value).toBe("Draft");
fireEvent.click(screen.getByRole("button", { name: "common:cancel" }));
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(renameCanvas).toHaveBeenCalledTimes(1);
});
it("accepts a 200-code-point title with astral characters", async () => {
renameCanvas.mockResolvedValue(canvas);
render(<CanvasRenameDialog canvas={canvas} open onOpenChange={vi.fn()} onRenamed={vi.fn()} />);
const title = "😀".repeat(200);
fireEvent.change(screen.getByLabelText(CANVAS_NAME_LABEL), { target: { value: title } });
fireEvent.click(screen.getByRole("button", { name: "common:save" }));
await waitFor(() => expect(renameCanvas).toHaveBeenCalledWith("canvas-1", title));
});
});
@@ -0,0 +1,147 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
import { Drawer, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { renameCanvas, type Canvas } from "@/lib/api/domains/canvas-api";
export function CanvasRenameDialog({
canvas,
open,
onOpenChange,
onRenamed,
}: {
canvas: Canvas | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onRenamed: () => void;
}) {
const { t } = useTranslation();
const { isMobile } = useResponsiveBreakpoint();
const [draft, setDraft] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setDraft(canvas?.title ?? "");
setError("");
const timer = window.setTimeout(() => inputRef.current?.select(), 0);
return () => window.clearTimeout(timer);
}, [canvas?.id, open]);
const save = async () => {
if (!canvas || saving) return;
const title = draft.trim();
if (!title || Array.from(title).length > 200) {
setError(t("canvases:renameCanvasInvalid"));
inputRef.current?.focus();
return;
}
setSaving(true);
setError("");
try {
await renameCanvas(canvas.id, title);
onOpenChange(false);
onRenamed();
} catch {
setError(t("canvases:renameCanvasFailed"));
inputRef.current?.focus();
} finally {
setSaving(false);
}
};
const content = (
<RenameForm
draft={draft}
onDraftChange={setDraft}
error={error}
saving={saving}
inputRef={inputRef}
onSave={() => void save()}
onCancel={() => onOpenChange(false)}
/>
);
if (isMobile)
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="pb-[env(safe-area-inset-bottom)]">
<DrawerHeader>
<DrawerTitle>{t("canvases:renameCanvas")}</DrawerTitle>
</DrawerHeader>
{content}
<DrawerFooter className="hidden" />
</DrawerContent>
</Drawer>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("canvases:renameCanvas")}</DialogTitle>
</DialogHeader>
{content}
<DialogFooter className="hidden" />
</DialogContent>
</Dialog>
);
}
function RenameForm({
draft,
onDraftChange,
error,
saving,
inputRef,
onSave,
onCancel,
}: {
draft: string;
onDraftChange: (value: string) => void;
error: string;
saving: boolean;
inputRef: RefObject<HTMLInputElement | null>;
onSave: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
return (
<form
onSubmit={(event) => {
event.preventDefault();
onSave();
}}
className="space-y-3 px-4 pb-4"
>
<Label htmlFor="canvas-rename-input">{t("canvases:canvasName")}</Label>
<Input
id="canvas-rename-input"
ref={inputRef}
value={draft}
onChange={(event) => onDraftChange(Array.from(event.target.value).slice(0, 200).join(""))}
aria-invalid={Boolean(error)}
className="min-h-11"
/>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" className="min-h-11" onClick={onCancel}>
{t("common:cancel")}
</Button>
<Button type="submit" className="min-h-11" disabled={saving}>
{t("common:save")}
</Button>
<span role="status" className="sr-only">
{saving ? t("canvases:renamingCanvas") : ""}
</span>
</div>
</form>
);
}
@@ -1,10 +1,15 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import type { ExportReview } from "@/lib/api/domains/canvas-distribution-api";
const prepare = vi.fn();
const getDefaults = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/domains/canvas-distribution-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/domains/canvas-distribution-api")>()),
getCanvasExportDefaults: getDefaults,
}));
const share = {
review: null as ExportReview | null,
loading: false,
@@ -52,22 +57,26 @@ import { CanvasShareDialog } from "./canvas-share-dialog";
afterEach(() => cleanup());
const PACKAGE_ID = "canvas-one";
const DISPLAY_NAME = "Canvas One";
const PREPARE_LABEL = "canvases:prepareDownloads";
const canvas: Canvas = {
id: "canvas-1",
plugin_instance_id: "instance-1",
plugin_id: "canvas-1",
workspace_id: "workspace-1",
scope_kind: "workspace",
title: "Canvas One",
title: DISPLAY_NAME,
status: "active",
active_release_id: "release-1",
active_release_status: "valid",
active_release: {
id: "release-1",
validation_status: "valid",
package_id: "canvas-one",
package_id: PACKAGE_ID,
version: "1.0.0",
display_name: "Canvas One",
display_name: DISPLAY_NAME,
description: "A portable canvas",
author: "Author",
source_mode: "static",
@@ -76,6 +85,19 @@ const canvas: Canvas = {
} as Canvas;
beforeEach(() => {
getDefaults.mockReset().mockResolvedValue({
expected_release_id: "release-1",
metadata: {
package_id: PACKAGE_ID,
version: "1.0.0",
display_name: DISPLAY_NAME,
description: "A portable canvas",
author: "Author",
source_mode: "static",
min_kandev_version: "0.94.0",
},
missing_required: ["license"],
});
prepare.mockReset().mockResolvedValue(null);
share.cancel.mockReset();
share.reset.mockReset();
@@ -85,23 +107,26 @@ beforeEach(() => {
});
describe("CanvasShareDialog", () => {
it("submits editable distribution metadata, including a user supplied license", () => {
it("submits release defaults with an explicit user supplied license", async () => {
render(<CanvasShareDialog canvas={canvas} open onOpenChange={vi.fn()} />);
fireEvent.change(screen.getByLabelText("canvases:license"), {
const gaps = await screen.findByTestId("canvas-share-required-gaps");
fireEvent.change(within(gaps).getByLabelText("canvases:license"), {
target: { value: "MIT" },
});
fireEvent.click(screen.getByRole("button", { name: "canvases:prepareDownloads" }));
fireEvent.click(screen.getByRole("button", { name: PREPARE_LABEL }));
expect(prepare).toHaveBeenCalledWith(
expect.objectContaining({
package_id: "canvas-one",
version: "1.0.0",
author: "Author",
license: "MIT",
source_mode: "static",
min_kandev_version: "0.94.0",
}),
await waitFor(() =>
expect(prepare).toHaveBeenCalledWith(
expect.objectContaining({
package_id: PACKAGE_ID,
version: "1.0.0",
author: "Author",
license: "MIT",
source_mode: "static",
min_kandev_version: "0.94.0",
}),
),
);
});
@@ -111,7 +136,7 @@ describe("CanvasShareDialog", () => {
canvas_id: "canvas-1",
workspace_id: "workspace-1",
release_id: "release-1",
metadata: { package_id: "canvas-one", version: "1.0.0" },
metadata: { package_id: PACKAGE_ID, version: "1.0.0" },
sha256: "digest",
files: [{ path: "assets/logo.svg", bytes: 12 }],
bundle_bytes: 20,
@@ -131,4 +156,47 @@ describe("CanvasShareDialog", () => {
expect(share.download).toHaveBeenNthCalledWith(1, "bundle");
expect(share.download).toHaveBeenNthCalledWith(2, "source");
});
it("blocks preparation until release defaults load and offers retry on failure", async () => {
getDefaults.mockRejectedValueOnce(new Error("offline"));
render(<CanvasShareDialog canvas={canvas} open onOpenChange={vi.fn()} />);
await screen.findByRole("alert");
expect(
(screen.getByRole("button", { name: PREPARE_LABEL }) as HTMLButtonElement).disabled,
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "canvases:retry" }));
await screen.findByTestId("canvas-share-package-details");
expect(
(screen.getByRole("button", { name: PREPARE_LABEL }) as HTMLButtonElement).disabled,
).toBe(false);
});
it("requires an explicit source mode when the release has no usable default", async () => {
getDefaults.mockResolvedValueOnce({
expected_release_id: "release-1",
metadata: {
package_id: PACKAGE_ID,
version: "1.0.0",
display_name: DISPLAY_NAME,
description: "A portable canvas",
author: "Author",
license: "MIT",
source_mode: "",
min_kandev_version: "0.95.0",
},
missing_required: ["source_mode"],
});
render(<CanvasShareDialog canvas={canvas} open onOpenChange={vi.fn()} />);
const gaps = await screen.findByTestId("canvas-share-required-gaps");
const sourceMode = within(gaps).getByRole("combobox", { name: "canvases:sourceMode" });
fireEvent.click(screen.getByRole("button", { name: PREPARE_LABEL }));
expect(prepare).not.toHaveBeenCalled();
await waitFor(() => expect(document.activeElement).toBe(sourceMode));
fireEvent.click(sourceMode);
fireEvent.click(screen.getByRole("option", { name: "canvases:sourceModeProject" }));
fireEvent.click(screen.getByRole("button", { name: PREPARE_LABEL }));
await waitFor(() =>
expect(prepare).toHaveBeenCalledWith(expect.objectContaining({ source_mode: "project" })),
);
});
});
@@ -25,17 +25,26 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Textarea } from "@kandev/ui/textarea";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useCanvasShare } from "@/hooks/domains/canvas/use-canvas-share";
import type { DistributionMetadata } from "@/lib/api/domains/canvas-distribution-api";
import { useCanvasExportDefaults } from "@/hooks/domains/canvas/use-canvas-export-defaults";
import {
type DistributionMetadata,
type ExportDefaults,
} from "@/lib/api/domains/canvas-distribution-api";
import type { Canvas } from "@/lib/api/domains/canvas-api";
import { formatNumber } from "@/lib/i18n/formats";
import { CanvasShareHelp } from "./canvas-share-help";
type ShareMetadata = DistributionMetadata & { source_mode: "static" | "project" };
type ShareMetadata = Omit<DistributionMetadata, "source_mode"> & {
source_mode: "" | "static" | "project";
};
// eslint-disable-next-line complexity -- Seeding each editable distribution field keeps the form independent from API shape changes.
function seedShareMetadata(canvas: Canvas | null): ShareMetadata {
const release = canvas?.active_release;
const sourceMode = release?.source_mode === "project" ? "project" : "static";
const sourceMode =
release?.source_mode === "project" || release?.source_mode === "static"
? release.source_mode
: "";
return {
package_id: release?.package_id ?? "",
version: release?.version ?? "",
@@ -64,14 +73,37 @@ export function CanvasShareDialog({
const share = useCanvasShare(canvas);
const { reset, cancel, invalidate } = share;
const [metadata, setMetadata] = useState<ShareMetadata>(() => seedShareMetadata(canvas));
const {
defaults,
loading: defaultsLoading,
error: defaultsError,
retry,
} = useCanvasExportDefaults(
canvas?.id,
canvas?.active_release_id,
open && canvas?.active_release_status === "valid",
);
const [detailsOpen, setDetailsOpen] = useState(false);
useEffect(() => {
if (open) {
invalidate();
setMetadata(seedShareMetadata(canvas));
setDetailsOpen(false);
} else {
reset();
}
}, [canvas?.active_release_id, canvas?.id, open, reset]);
}, [canvas?.active_release_id, canvas?.id, open, reset, invalidate]);
useEffect(() => {
if (!defaults) return;
const sourceMode = defaults.metadata.source_mode;
setMetadata({
...seedShareMetadata(null),
...defaults.metadata,
source_mode: sourceMode === "project" || sourceMode === "static" ? sourceMode : "",
});
}, [defaults]);
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) void cancel();
@@ -82,55 +114,55 @@ export function CanvasShareDialog({
invalidate();
setMetadata((current) => ({ ...current, [key]: value }));
};
const prepare = () =>
void share.prepare({
...metadata,
package_id: metadata.package_id?.trim(),
version: metadata.version?.trim(),
display_name: metadata.display_name?.trim(),
description: metadata.description?.trim(),
author: metadata.author?.trim(),
license: metadata.license?.trim(),
min_kandev_version: metadata.min_kandev_version?.trim(),
repo_url: metadata.repo_url?.trim() || undefined,
});
const prepare = () => {
const required = [
"package_id",
"version",
"display_name",
"description",
"author",
"license",
"source_mode",
"min_kandev_version",
] as const;
const missing = required.find((key) => !String(metadata[key] ?? "").trim());
if (missing) {
setDetailsOpen(true);
const fieldId = defaults?.missing_required.includes(missing)
? `canvas-share-required-${missing}`
: `canvas-share-${missing === "min_kandev_version" ? "min-version" : missing.replaceAll("_", "-")}`;
requestAnimationFrame(() => document.getElementById(fieldId)?.focus());
return;
}
void share
.prepare({
...metadata,
package_id: metadata.package_id?.trim(),
version: metadata.version?.trim(),
display_name: metadata.display_name?.trim(),
description: metadata.description?.trim(),
author: metadata.author?.trim(),
license: metadata.license?.trim(),
source_mode: metadata.source_mode || undefined,
min_kandev_version: metadata.min_kandev_version?.trim(),
repo_url: metadata.repo_url?.trim() || undefined,
})
.catch(() => undefined);
};
const body = (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 py-4">
{!canvas?.active_release_id || canvas.active_release_status !== "valid" ? (
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
{t("canvases:shareNoRelease")}
</p>
) : (
<>
<p className="text-sm text-muted-foreground">
{t("canvases:prepareDownloadsDescription")}
</p>
<ShareMetadataForm metadata={metadata} onChange={updateMetadata} />
{!share.review && (
<Button
className="min-h-11 cursor-pointer md:min-h-7 [@media(pointer:coarse)]:min-h-11"
disabled={share.loading}
onClick={prepare}
>
{share.loading ? t("canvases:sharing") : t("canvases:prepareDownloads")}
</Button>
)}
{Boolean(share.error) && (
<p role="alert" className="text-sm text-destructive">
{t("canvases:shareFailed")}
</p>
)}
{share.review && (
<ExportReview
review={share.review}
loading={share.loading}
onDownload={share.download}
/>
)}
<CanvasShareHelp review={share.review} />
</>
)}
</div>
<CanvasShareBody
canvas={canvas}
defaults={defaults}
defaultsLoading={defaultsLoading}
defaultsError={defaultsError}
onRetry={retry}
metadata={metadata}
onChange={updateMetadata}
detailsOpen={detailsOpen}
onDetailsOpenChange={setDetailsOpen}
onPrepare={prepare}
share={share}
/>
);
const footer = (
<div className="flex shrink-0 flex-col-reverse gap-2 border-t px-4 py-3 md:flex-row md:justify-end">
@@ -172,6 +204,120 @@ export function CanvasShareDialog({
);
}
type ShareBodyProps = {
canvas: Canvas | null;
defaults: ExportDefaults | null;
defaultsLoading: boolean;
defaultsError: boolean;
onRetry: () => void;
metadata: ShareMetadata;
onChange: <K extends keyof ShareMetadata>(key: K, value: ShareMetadata[K]) => void;
detailsOpen: boolean;
onDetailsOpenChange: (open: boolean) => void;
onPrepare: () => void;
share: ReturnType<typeof useCanvasShare>;
};
function CanvasShareBody({
canvas,
defaults,
defaultsLoading,
defaultsError,
onRetry,
metadata,
onChange,
detailsOpen,
onDetailsOpenChange,
onPrepare,
share,
}: ShareBodyProps) {
const { t } = useTranslation();
return (
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-4 py-4">
{!canvas?.active_release_id || canvas.active_release_status !== "valid" ? (
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
{t("canvases:shareNoRelease")}
</p>
) : (
<>
{defaultsLoading && (
<p role="status" className="text-sm text-muted-foreground">
{t("canvases:loadingShareDefaults")}
</p>
)}
{defaultsError && (
<div role="alert" className="space-y-2">
<p className="text-sm text-destructive">{t("canvases:shareDefaultsFailed")}</p>
<Button className="min-h-11" variant="outline" onClick={onRetry}>
{t("canvases:retry")}
</Button>
</div>
)}
{defaults && (
<>
<p className="text-sm text-muted-foreground">
{t("canvases:sharingRelease", {
name: metadata.display_name || canvas.title,
version: metadata.version,
})}
</p>
{defaults.missing_required.length > 0 && (
<div
className="rounded-lg border border-border/70 p-4"
data-testid="canvas-share-required-gaps"
>
<p className="mb-3 text-sm font-medium">{t("canvases:shareRequiredDetails")}</p>
<ShareMissingFields
fields={defaults.missing_required}
metadata={metadata}
onChange={onChange}
/>
</div>
)}
<details
open={detailsOpen}
onToggle={(event) => onDetailsOpenChange(event.currentTarget.open)}
className="rounded-lg border border-border/70"
data-testid="canvas-share-package-details"
>
<summary className="min-h-11 cursor-pointer px-4 py-3 font-medium">
{t("canvases:packageDetails")}
</summary>
<ShareMetadataForm metadata={metadata} onChange={onChange} />
</details>
</>
)}
{!share.review && (
<Button
className="min-h-11 cursor-pointer md:min-h-7 [@media(pointer:coarse)]:min-h-11"
disabled={share.loading || !defaults || defaultsLoading}
onClick={onPrepare}
>
{t("canvases:prepareDownloads")}
</Button>
)}
<span role="status" className="sr-only">
{share.loading ? t("canvases:sharing") : ""}
</span>
{Boolean(share.error) && (
<p role="alert" className="text-sm text-destructive">
{t("canvases:shareFailed")}
</p>
)}
{share.review && (
<ExportReview
review={share.review}
loading={share.loading}
onDownload={share.download}
/>
)}
<CanvasShareHelp review={share.review} />
</>
)}
</div>
);
}
function ExportReview({
review,
loading,
@@ -243,6 +389,66 @@ function ExportReview({
);
}
function ShareMissingFields({
fields,
metadata,
onChange,
}: {
fields: string[];
metadata: ShareMetadata;
onChange: <K extends keyof ShareMetadata>(key: K, value: ShareMetadata[K]) => void;
}) {
const { t } = useTranslation();
const labels: Record<string, string> = {
package_id: "packageId",
version: "packageVersion",
display_name: "displayName",
description: "description",
author: "author",
license: "license",
source_mode: "sourceMode",
min_kandev_version: "minKandevVersion",
};
return (
<div className="grid gap-3 sm:grid-cols-2">
{fields
.filter((field) => field in labels)
.map((field) => {
const key = field as keyof ShareMetadata;
const id = `canvas-share-required-${field}`;
return (
<div key={field} className="space-y-2">
<Label htmlFor={id}>{t(`canvases:${labels[field]}`)}</Label>
{field === "source_mode" ? (
<Select
value={metadata.source_mode}
onValueChange={(value) =>
onChange("source_mode", value as ShareMetadata["source_mode"])
}
>
<SelectTrigger id={id} className="min-h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">{t("canvases:sourceModeStatic")}</SelectItem>
<SelectItem value="project">{t("canvases:sourceModeProject")}</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={id}
className="min-h-11"
value={String(metadata[key] ?? "")}
onChange={(event) => onChange(key, event.target.value)}
/>
)}
</div>
);
})}
</div>
);
}
// eslint-disable-next-line max-lines-per-function -- The metadata fields stay together so every edit shares the same invalidation path.
function ShareMetadataForm({
metadata,
@@ -0,0 +1,47 @@
import { expect, test } from "../../fixtures/test-base";
import {
approvePendingCanvas,
canvasHref,
enableCanvasFeature,
removeCanvas,
seedTaskCanvas,
} from "./canvas-fixture";
test("renames a task canvas without replacing its running frame", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData);
canvasId = seeded.canvas.id;
const active = seeded.canvas.pending_release
? await approvePendingCanvas(apiClient, seeded.canvas)
: seeded.canvas;
await testPage.goto(canvasHref(active.id));
const frame = testPage.getByTestId("web-app-frame").locator("iframe");
await expect(frame).toBeVisible();
await frame.evaluate((element) => {
element.setAttribute("data-rename-marker", "same-frame");
});
expect(
(await testPage.getByTestId("canvas-rename-action").boundingBox())?.height,
).toBeLessThanOrEqual(32);
await testPage.getByTestId("canvas-rename-action").click();
const dialog = testPage.getByRole("dialog", { name: "Rename canvas" });
await expect(dialog.getByLabel("Canvas name")).toBeFocused();
await dialog.getByLabel("Canvas name").fill("Updated workflow canvas");
await dialog.getByRole("button", { name: "Save" }).click();
await expect(testPage.getByText("Updated workflow canvas").first()).toBeVisible();
await expect(frame).toHaveAttribute("data-rename-marker", "same-frame");
await testPage.reload();
await expect(testPage.getByText("Updated workflow canvas").first()).toBeVisible();
} finally {
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
@@ -47,9 +47,7 @@ test.describe("Canvas sharing", () => {
const dialog = testPage.getByRole("dialog").last();
await expect(dialog).toBeVisible();
await expect(
dialog.getByText("Kandev creates bounded archives", { exact: false }),
).toBeVisible();
await expect(dialog.getByText("Package details", { exact: true })).toBeVisible();
await dialog.getByRole("button", { name: "Prepare downloads", exact: true }).click();
const review = dialog.getByTestId("canvas-export-review");
@@ -0,0 +1,34 @@
import { expect, test } from "../../fixtures/test-base";
import {
approvePendingCanvas,
canvasHref,
enableCanvasFeature,
removeCanvas,
seedTaskCanvas,
} from "./canvas-fixture";
test("renames from the phone action drawer", async ({ testPage, apiClient, backend, seedData }) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true);
canvasId = seeded.canvas.id;
const active = seeded.canvas.pending_release
? await approvePendingCanvas(apiClient, seeded.canvas)
: seeded.canvas;
await testPage.goto(canvasHref(active.id));
await testPage.getByTestId("canvas-mobile-actions").tap();
await testPage.getByTestId("canvas-mobile-rename").tap();
const dialog = testPage.getByRole("dialog", { name: "Rename canvas" });
await dialog.getByLabel("Canvas name").fill("Phone canvas title");
await dialog.getByRole("button", { name: "Save" }).tap();
await expect(testPage.getByText("Phone canvas title").first()).toBeVisible();
expect(await testPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true,
);
} finally {
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
@@ -36,6 +36,7 @@ test.describe("Canvas sharing on mobile", () => {
const drawer = testPage.getByRole("dialog").last();
await expect(drawer).toBeVisible();
await drawer.getByText("Package details", { exact: true }).tap();
await expectTouchControl(drawer.getByLabel("Package ID", { exact: true }));
await expectTouchControl(drawer.getByRole("combobox", { name: "Source mode" }));
const prepare = drawer.getByRole("button", { name: "Prepare downloads", exact: true });
@@ -0,0 +1,45 @@
import { expect, test } from "../../fixtures/test-base";
import {
approvePendingCanvas,
canvasHref,
enableCanvasFeature,
removeCanvas,
seedTaskCanvas,
} from "./canvas-fixture";
test("phone canvas can read its scoped task trail without page overflow", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, true, {
minimalPermissions: true,
});
canvasId = seeded.canvas.id;
const approved = seeded.canvas.pending_release
? await approvePendingCanvas(apiClient, seeded.canvas)
: seeded.canvas;
await testPage.goto(canvasHref(approved.id));
const body = testPage.frameLocator('[data-testid="web-app-frame"] iframe').locator("body");
await expect(body).toBeVisible();
const result = await body.evaluate(async (_, taskId) => {
const response = await fetch(
`./_kandev/v1/data/tasks/${encodeURIComponent(taskId)}/step-transitions?limit=200`,
);
return { status: response.status, body: await response.json() };
}, seeded.taskId);
expect(result.status).toBe(200);
expect(Array.isArray(result.body.items)).toBe(true);
expect(await testPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
true,
);
} finally {
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
@@ -0,0 +1,172 @@
import { existsSync, readFileSync } from "node:fs";
import { createServer } from "node:http";
import path from "node:path";
import { chromium } from "@playwright/test";
const source = path.resolve(process.argv[2] || "");
if (!existsSync(path.join(source, "script.js")))
throw new Error("Pass the canvas source directory");
const FLOW_ID = "flow";
const TASK_TITLE = "Review this change";
const envelope = (items) => ({ items, page_info: { has_more: false } });
const workflow = { id: FLOW_ID, name: "Feature delivery" };
const steps = [
{ id: "draft", name: "Draft", position: 0 },
{ id: "review", name: "Review", position: 1 },
];
const task = {
id: "task",
title: TASK_TITLE,
state: "IN_PROGRESS",
workflow_id: FLOW_ID,
workflow_step_id: "draft",
updated_at: "2026-09-22T10:00:00Z",
};
const moves = [
{
id: "2",
from_workflow_id: FLOW_ID,
from_workflow_step_id: "review",
to_workflow_id: FLOW_ID,
to_workflow_step_id: "draft",
occurred_at: "2026-09-22T09:00:00Z",
trigger: "manual",
},
];
let scope = "task";
let historyStatus = 200;
function mockData(api) {
switch (api) {
case "context":
return { scope_kind: scope, task_id: scope === "task" ? "task" : undefined };
case "data/tasks/task":
return task;
case "data/tasks":
return envelope([task]);
case "data/workflows":
return envelope([workflow]);
case "data/workflows/flow/steps":
return envelope(steps);
case "data/workflows/flow/transition-groups":
return envelope([{ kind: "within", from_step_id: "review", to_step_id: "draft", count: 1 }]);
case "data/tasks/task/step-transitions":
return historyStatus === 200 ? envelope(moves) : { error: "unavailable" };
default:
return null;
}
}
function serve(request, response) {
const pathname = new URL(request.url, "http://localhost").pathname;
if (pathname.includes("/_kandev/v1/")) {
const api = pathname.split("/_kandev/v1/")[1];
const body = mockData(api);
let status = 200;
if (body === null) status = 404;
else if (api === "data/tasks/task/step-transitions") status = historyStatus;
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body ?? {}));
return;
}
const file = path.join(source, pathname === "/" ? "index.html" : pathname);
if (!file.startsWith(source + path.sep)) {
response.writeHead(403);
response.end();
return;
}
try {
let contentType = "text/html";
if (file.endsWith(".js")) contentType = "text/javascript";
else if (file.endsWith(".css")) contentType = "text/css";
response.writeHead(200, { "content-type": contentType });
response.end(readFileSync(file));
} catch {
response.writeHead(404);
response.end();
}
}
async function checkTask(browser, url) {
const page = await browser.newPage();
await page.addInitScript(() => {
window.__tones = 0;
window.AudioContext = class {
currentTime = 0;
destination = {};
createOscillator() {
return {
frequency: { value: 0 },
connect() {},
start() {
window.__tones += 1;
},
stop() {},
};
}
createGain() {
return { gain: { value: 0 }, connect() {} };
}
};
});
await page.goto(url);
await page.getByText(TASK_TITLE).first().waitFor();
await page.getByText("Return: Review → Draft").waitFor();
if (await page.getByText("sample tasks").count()) throw new Error("Sample data visible");
task.workflow_step_id = "review";
await page.getByRole("button", { name: "Refresh" }).click();
await page.getByText(TASK_TITLE).first().waitFor();
if ((await page.evaluate(() => window.__tones)) !== 0)
throw new Error("Sound played without opt-in");
await page.getByLabel("Play sound for new moves").check();
task.workflow_step_id = "draft";
await page.getByRole("button", { name: "Refresh" }).click();
await page.waitForFunction(() => window.__tones === 1);
await page.close();
}
async function checkWorkspacePhone(browser, url) {
scope = "workspace";
const page = await browser.newPage({
viewport: { width: 390, height: 800 },
isMobile: true,
hasTouch: true,
});
await page.goto(url);
await page.getByLabel("Workflow").waitFor();
await page.getByRole("button", { name: "Task trail" }).click();
await page.getByText("Return: Review → Draft").waitFor();
if (await page.evaluate(() => document.documentElement.scrollWidth > innerWidth))
throw new Error("Horizontal mobile overflow");
await page.close();
}
async function checkOlderHost(browser, url) {
scope = "task";
historyStatus = 404;
const page = await browser.newPage();
await page.goto(url);
await page
.getByText(/does not provide transition history/)
.first()
.waitFor();
await page.getByText(TASK_TITLE).first().waitFor();
await page.close();
}
const server = createServer(serve);
try {
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
const browser = await chromium.launch({ headless: true });
try {
await checkTask(browser, url);
await checkWorkspacePhone(browser, url);
await checkOlderHost(browser, url);
console.log("Exact canvas source passed task, workspace phone, older-host, and sound checks.");
} finally {
await browser.close();
}
} finally {
await new Promise((resolve) => server.close(resolve));
}
@@ -0,0 +1,72 @@
import { expect, test } from "../../fixtures/test-base";
import {
approvePendingCanvas,
canvasHref,
enableCanvasFeature,
promoteCanvas,
removeCanvas,
seedTaskCanvas,
} from "./canvas-fixture";
test("task and workspace transition reads stay inside canvas scope", async ({
testPage,
apiClient,
backend,
seedData,
}) => {
test.setTimeout(180_000);
const releaseFeature = await enableCanvasFeature(backend, apiClient, seedData.workspaceId);
let canvasId: string | undefined;
try {
const seeded = await seedTaskCanvas(testPage, apiClient, seedData, false, {
minimalPermissions: true,
});
canvasId = seeded.canvas.id;
const approved = seeded.canvas.pending_release
? await approvePendingCanvas(apiClient, seeded.canvas)
: seeded.canvas;
const nextStep = seedData.steps.find((step) => step.id !== seedData.startStepId);
expect(nextStep).toBeDefined();
await apiClient.moveTask(seeded.taskId, seedData.workflowId, nextStep!.id);
await apiClient.moveTask(seeded.taskId, seedData.workflowId, seedData.startStepId);
await testPage.goto(canvasHref(approved.id));
const body = testPage.frameLocator('[data-testid="web-app-frame"] iframe').locator("body");
await expect(body).toBeVisible();
const taskRead = await body.evaluate(async (_, taskId) => {
const response = await fetch(
`./_kandev/v1/data/tasks/${encodeURIComponent(taskId)}/step-transitions?limit=200`,
);
return { status: response.status, body: await response.json() };
}, seeded.taskId);
expect(taskRead.status).toBe(200);
expect(
taskRead.body.items.some(
(item: { to_workflow_step_id: string }) =>
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,
);
expect(taskScopeGroups).toBe(403);
const workspace = await promoteCanvas(apiClient, approved);
await testPage.goto(canvasHref(workspace.id));
const groups = await testPage
.frameLocator('[data-testid="web-app-frame"] iframe')
.locator("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(groups.status).toBe(200);
expect(groups.body.items.some((item: { count: number }) => item.count > 0)).toBe(true);
} finally {
if (canvasId) await removeCanvas(apiClient, canvasId);
await releaseFeature();
}
});
@@ -0,0 +1,51 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getDefaults = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/domains/canvas-distribution-api", () => ({
getCanvasExportDefaults: getDefaults,
}));
import { useCanvasExportDefaults } from "./use-canvas-export-defaults";
afterEach(() => cleanup());
beforeEach(() => getDefaults.mockReset());
describe("useCanvasExportDefaults", () => {
it("rejects a response for another release", async () => {
getDefaults.mockResolvedValue({
expected_release_id: "old-release",
metadata: {},
missing_required: [],
});
const { result } = renderHook(() => useCanvasExportDefaults("canvas-1", "new-release", true));
await waitFor(() => expect(result.current.error).toBe(true));
expect(result.current.defaults).toBeNull();
});
it("retries after an error and ignores the previous release after a change", async () => {
getDefaults.mockRejectedValueOnce(new Error("offline"));
getDefaults.mockResolvedValueOnce({
expected_release_id: "release-1",
metadata: { source_mode: "static" },
missing_required: [],
});
getDefaults.mockResolvedValueOnce({
expected_release_id: "release-2",
metadata: { source_mode: "project" },
missing_required: [],
});
const { result, rerender } = renderHook(
({ releaseId }) => useCanvasExportDefaults("canvas-1", releaseId, true),
{
initialProps: { releaseId: "release-1" },
},
);
await waitFor(() => expect(result.current.error).toBe(true));
act(() => result.current.retry());
await waitFor(() => expect(result.current.defaults?.expected_release_id).toBe("release-1"));
rerender({ releaseId: "release-2" });
expect(result.current.defaults).toBeNull();
await waitFor(() => expect(result.current.defaults?.expected_release_id).toBe("release-2"));
});
});
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from "react";
import {
getCanvasExportDefaults,
type ExportDefaults,
} from "@/lib/api/domains/canvas-distribution-api";
export function useCanvasExportDefaults(
canvasId: string | undefined,
releaseId: string | undefined,
enabled: boolean,
) {
const [defaults, setDefaults] = useState<ExportDefaults | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const retry = useCallback(() => setRetryKey((value) => value + 1), []);
useEffect(() => {
setDefaults(null);
setError(false);
if (!enabled || !canvasId || !releaseId) {
setLoading(false);
return;
}
let current = true;
const controller = new AbortController();
setLoading(true);
getCanvasExportDefaults(canvasId, { cache: "no-store", init: { signal: controller.signal } })
.then((value) => {
if (!current) return;
if (value.expected_release_id !== releaseId) throw new Error("stale release");
setDefaults(value);
})
.catch(() => {
if (current) setError(true);
})
.finally(() => {
if (current) setLoading(false);
});
return () => {
current = false;
controller.abort();
};
}, [canvasId, releaseId, enabled, retryKey]);
const currentDefaults = enabled && defaults?.expected_release_id === releaseId ? defaults : null;
return { defaults: currentDefaults, loading, error, retry };
}
+9 -1
View File
@@ -164,6 +164,14 @@ export function getCanvas(canvasId: string, options?: ApiRequestOptions): Promis
return fetchJson<Canvas>(canvasPath(canvasId), options);
}
export function renameCanvas(
canvasId: string,
title: string,
options?: ApiRequestOptions,
): Promise<Canvas> {
return mutate<Canvas>(canvasPath(canvasId), "PATCH", { title }, options);
}
export function getCanvasRuntime(
canvasId: string,
options?: ApiRequestOptions,
@@ -259,7 +267,7 @@ export function removeCanvas(canvasId: string, options?: ApiRequestOptions): Pro
function mutate<T>(
path: string,
method: "POST" | "DELETE",
method: "POST" | "PATCH" | "DELETE",
body: unknown,
options?: ApiRequestOptions,
): Promise<T> {
@@ -30,6 +30,12 @@ export type ExportReview = {
source_download: string;
};
export type ExportDefaults = {
expected_release_id: string;
metadata: Omit<DistributionMetadata, "source_mode"> & { source_mode?: "" | "static" | "project" };
missing_required: string[];
};
export type InstallReview = {
preparation_id: string;
workspace_id: string;
@@ -71,6 +77,16 @@ export type InstallResult = { canvas: Canvas; receipt: Record<string, unknown> }
const EXPORTS = "/api/v1/canvases";
const INSTALLS = "/api/v1/canvases/install-preparations";
export function getCanvasExportDefaults(
canvasId: string,
options?: ApiRequestOptions,
): Promise<ExportDefaults> {
return fetchJson<ExportDefaults>(
`${EXPORTS}/${encodeURIComponent(canvasId)}/export-defaults`,
options,
);
}
export function prepareCanvasExport(
canvasId: string,
request: { workspace_id: string; expected_release_id: string; metadata?: DistributionMetadata },
+1
View File
@@ -5,6 +5,7 @@ type Listener = () => void;
export type CanvasLifecycleAction =
| "canvas.created"
| "canvas.updated"
| "canvas.release.activated"
| "canvas.release.permission_required"
| "canvas.promoted"
+3
View File
@@ -433,6 +433,8 @@ export type TaskStatusSummaryUpdatedPayload = {
export type CanvasLifecyclePayload = {
type?: string;
title?: string;
updated_at?: string;
canvas_id: string;
plugin_instance_id?: string;
workspace_id?: string;
@@ -533,6 +535,7 @@ export type BackendMessageMap = SessionBackendMessageMap &
"workflow.step.deleted": BackendMessage<"workflow.step.deleted", WorkflowStepEventPayload>;
"canvas.created": BackendMessage<"canvas.created", CanvasLifecyclePayload>;
"canvas.updated": BackendMessage<"canvas.updated", CanvasLifecyclePayload>;
"canvas.release.activated": BackendMessage<"canvas.release.activated", CanvasLifecyclePayload>;
"canvas.release.permission_required": BackendMessage<
"canvas.release.permission_required",
@@ -8,6 +8,7 @@ import { registerCanvasesHandlers } from "./canvases";
const LIFECYCLE_ACTIONS = [
"canvas.created",
"canvas.updated",
"canvas.release.activated",
"canvas.release.permission_required",
"canvas.promoted",
+1
View File
@@ -15,6 +15,7 @@ export function registerCanvasesHandlers(_store: StoreApi<AppState>): WsHandlers
recordCanvasLifecycle(message.action, message.payload);
return {
"canvas.created": invalidate,
"canvas.updated": invalidate,
"canvas.release.activated": invalidate,
"canvas.release.permission_required": invalidate,
"canvas.promoted": invalidate,
+11 -1
View File
@@ -147,5 +147,15 @@
"exampleRepository": "your-org/my-canvas",
"examplePreviewUrl": "https://cdn.example.com/my-canvas-cover.webp",
"examplePreviewAlt": "Canvas overview",
"yes": "Yes"
"yes": "Yes",
"renameCanvas": "Rename canvas",
"canvasName": "Canvas name",
"renameCanvasInvalid": "Enter a name of 1 to 200 characters.",
"renameCanvasFailed": "Could not rename the canvas. Try again.",
"renamingCanvas": "Saving name…",
"loadingShareDefaults": "Loading share details…",
"shareDefaultsFailed": "Could not load share details. Try again.",
"sharingRelease": "Sharing {{name}} version {{version}}",
"shareRequiredDetails": "Complete these details",
"packageDetails": "Package details"
}
+11 -1
View File
@@ -147,5 +147,15 @@
"exampleRepository": "your-org/my-canvas",
"examplePreviewUrl": "https://cdn.example.com/my-canvas-cover.webp",
"examplePreviewAlt": "キャンバス概要",
"yes": "はい"
"yes": "はい",
"renameCanvas": "キャンバスの名前を変更",
"canvasName": "キャンバス名",
"renameCanvasInvalid": "1~200文字の名前を入力してください。",
"renameCanvasFailed": "キャンバス名を変更できませんでした。再試行してください。",
"renamingCanvas": "名前を保存中…",
"loadingShareDefaults": "共有情報を読み込み中…",
"shareDefaultsFailed": "共有情報を読み込めませんでした。再試行してください。",
"sharingRelease": "{{name}} バージョン {{version}} を共有",
"shareRequiredDetails": "次の項目を入力してください",
"packageDetails": "パッケージの詳細"
}
+11 -1
View File
@@ -147,5 +147,15 @@
"exampleRepository": "ŷōũŕ-ōŕĝ/ḿŷ-ćàńvàś",
"examplePreviewUrl": "ĥţţƥś://ćďń.ēxàḿƥĺē.ćōḿ/ḿŷ-ćàńvàś-ćōvēŕ.ŵēƀƥ",
"examplePreviewAlt": "Ćàńvàś ōvēŕvĩēŵ",
"yes": "Ŷēś"
"yes": "Ŷēś",
"renameCanvas": "Ŕēńàḿē ćàńvāś",
"canvasName": "Ćàńvāś ńàḿē",
"renameCanvasInvalid": "Ēńţēŕ à ńàḿē ōƒ 1 ţō 200 ćĥàŕàćţēŕś.",
"renameCanvasFailed": "Ćōũĺď ńōţ ŕēńàḿē ţĥē ćàńvāś. Ţŕŷ àĝàĩń.",
"renamingCanvas": "Śàvĩńĝ ńàḿē…",
"loadingShareDefaults": "Ĺōàďĩńĝ śĥàŕē ďēţàĩĺś…",
"shareDefaultsFailed": "Ćōũĺď ńōţ ĺōàď śĥàŕē ďēţàĩĺś. Ţŕŷ àĝàĩń.",
"sharingRelease": "Śĥàŕĩńĝ {{name}} vēŕśĩōń {{version}}",
"shareRequiredDetails": "Ćōḿƥĺēţē ţĥēśē ďēţàĩĺś",
"packageDetails": "Ƥàćķàĝē ďēţàĩĺś"
}
+11 -1
View File
@@ -147,5 +147,15 @@
"exampleRepository": "a-sua-org/o-meu-canvas",
"examplePreviewUrl": "https://cdn.exemplo.pt/o-meu-canvas-capa.webp",
"examplePreviewAlt": "Visão geral do canvas",
"yes": "Sim"
"yes": "Sim",
"renameCanvas": "Mudar o nome do canvas",
"canvasName": "Nome do canvas",
"renameCanvasInvalid": "Introduza um nome com 1 a 200 caracteres.",
"renameCanvasFailed": "Não foi possível mudar o nome do canvas. Tente novamente.",
"renamingCanvas": "A guardar o nome…",
"loadingShareDefaults": "A carregar os detalhes de partilha…",
"shareDefaultsFailed": "Não foi possível carregar os detalhes. Tente novamente.",
"sharingRelease": "A partilhar {{name}} versão {{version}}",
"shareRequiredDetails": "Complete estes detalhes",
"packageDetails": "Detalhes do pacote"
}
+11 -1
View File
@@ -147,5 +147,15 @@
"exampleRepository": "你的组织/我的画布",
"examplePreviewUrl": "https://cdn.example.cn/my-canvas-cover.webp",
"examplePreviewAlt": "画布概览",
"yes": "是"
"yes": "是",
"renameCanvas": "重命名画布",
"canvasName": "画布名称",
"renameCanvasInvalid": "请输入 1 到 200 个字符的名称。",
"renameCanvasFailed": "无法重命名画布。请重试。",
"renamingCanvas": "正在保存名称…",
"loadingShareDefaults": "正在加载分享详情…",
"shareDefaultsFailed": "无法加载分享详情。请重试。",
"sharingRelease": "分享 {{name}} 版本 {{version}}",
"shareRequiredDetails": "请填写以下信息",
"packageDetails": "软件包详情"
}
+11 -1
View File
@@ -147,5 +147,15 @@
"exampleRepository": "你的組織/我的畫布",
"examplePreviewUrl": "https://cdn.example.cn/my-canvas-cover.webp",
"examplePreviewAlt": "畫布概覽",
"yes": "是"
"yes": "是",
"renameCanvas": "重命名畫布",
"canvasName": "畫布名稱",
"renameCanvasInvalid": "請輸入 1 至 200 個字元的名稱。",
"renameCanvasFailed": "無法重命名畫布。請再試一次。",
"renamingCanvas": "正在儲存名稱…",
"loadingShareDefaults": "正在載入分享詳情…",
"shareDefaultsFailed": "無法載入分享詳情。請再試一次。",
"sharingRelease": "分享 {{name}} 版本 {{version}}",
"shareRequiredDetails": "請填寫以下資料",
"packageDetails": "套件詳情"
}
+11 -1
View File
@@ -147,5 +147,15 @@
"exampleRepository": "你的組織/我的畫布",
"examplePreviewUrl": "https://cdn.example.cn/my-canvas-cover.webp",
"examplePreviewAlt": "畫布概覽",
"yes": "是"
"yes": "是",
"renameCanvas": "重新命名畫布",
"canvasName": "畫布名稱",
"renameCanvasInvalid": "請輸入 1 到 200 個字元的名稱。",
"renameCanvasFailed": "無法重新命名畫布。請再試一次。",
"renamingCanvas": "正在儲存名稱…",
"loadingShareDefaults": "正在載入分享詳細資料…",
"shareDefaultsFailed": "無法載入分享詳細資料。請再試一次。",
"sharingRelease": "分享 {{name}} 版本 {{version}}",
"shareRequiredDetails": "請填寫以下資料",
"packageDetails": "套件詳細資料"
}
@@ -0,0 +1,247 @@
---
created: 2026-09-22
status: implemented
requirements:
- REQ-PLUGINS-WORKFLOW-HISTORY-001
- REQ-PLUGINS-WORKFLOW-HISTORY-002
- REQ-PLUGINS-WORKFLOW-HISTORY-003
- REQ-CANVASES-NAME-SHARE-001
- REQ-CANVASES-NAME-SHARE-002
system_design:
- ../../specs/plugins/system-design/workflow-transition-history.md
- ../../specs/canvases/system-design/canvas-name-and-share-defaults.md
legacy_specs: []
---
# Implementation Plan: Live workflow transition canvas
## Overview
Make the issue #3583 canvas display recorded Kandev task moves. Add bounded
task-ledger reads, expose them through the capability-gated Plugin Host and
canvas browser protocol, then replace the canvas sample with live task and
workspace views. This order makes the data contract testable before the app
uses it. The existing task canvas remains active until a valid new release is
published; workspace promotion remains a user-controlled operation.
The same delivery improves the host controls exposed by the example canvas:
rename its instance from the toolbar and prepare share downloads from accurate
active-release defaults. These host changes apply to all task and workspace
canvases, independently of the workflow-history data API.
## Scope
### In scope
- Per-task recorded trail, including null and removed endpoints.
- Workspace-scoped route groups and current unarchived task counts.
- Desktop and phone canvas views, polling, opt-in sound, and read failures.
- Typed gRPC/SDK and browser API parity, permission checks, public docs, and
focused E2E coverage.
- Reuse of the already-published canvas ID
`65d40cfb-7bd3-407a-beb7-be78a551e62a` and its assigned source root.
- Authorized rename of a canvas's host-visible instance title, including
desktop and phone surfaces and navigation refresh.
- Release-bound share defaults, short required-gap form, editable package
details, and the existing reviewed bundle/source downloads.
### Out of scope
- New transition writers, history reconstruction, task writes, and review
verdicts.
- Direct SQLite access or operation while the Kandev backend is unavailable.
- Automatic workspace promotion or grant approval. The workflow-wide view
becomes available only after the user promotes the canvas.
- Automatic license choice, repository publication, registry submission, or
mutation of a live release's package metadata during rename or sharing.
## Technical approach
1. Add task service and repository read methods in
`apps/backend/internal/task/service/` and
`apps/backend/internal/task/repository/sqlite/step_transitions.go`. Query by
monotonic transition ID for the trail and aggregate workflow-side groups
through bounded SQL reads. Add SQLite/Postgres indexes and parity tests.
2. Extend `apps/backend/proto/kandev/plugin/v1/plugin.proto`, generated Go,
`apps/backend/pkg/pluginsdk/host.go`, `apps/backend/internal/plugins/host_data.go`,
and `webapp_protocol*.go`. The task route requires `api_read:tasks`; the
workspace summary requires both task and workflow read grants and a
workspace binding. Update the embedded authoring browser-API reference at
`apps/backend/internal/mcp/canvasskill/files/references/browser-api.md` and
the public plugin/canvas docs.
3. Update only the assigned source root
`.kandev/canvases/65d40cfb-7bd3-407a-beb7-be78a551e62a/` for the
canvas release. Replace the sample with live history, support task and
workspace scope in one app, and publish after local browser checks. Add
isolated canvas-protocol E2E fixtures under `apps/web/e2e/tests/canvas/`.
The task-local canvas source is not a tracked repository fixture; the
repository E2E tests exercise the same host routes with a disposable
package, and the final local Playwright check exercises the exact source
before publication.
4. Add a canvas-service title update and owner-authorized host route, then
expose Rename in desktop toolbar and phone host actions. Refresh host and
navigation projections without restarting the canvas iframe.
5. Expose active-release metadata that the HTTP canvas response currently
omits. Add an authorized export-defaults read that also supplies the
server's compatible distribution minimum, and seed Share from it. Show only
missing required details initially, keep populated fields editable, and
retain the existing export validation and review/download safeguards.
## ASCII UI preview
The following structure is required; wording and spacing are illustrative.
All user-facing copy is localized in the implementation. The canvas has no
task-write controls.
`UI-01: Task trail`, task canvas after load (`AC-PLUGINS-WORKFLOW-HISTORY-003.1`,
`.3`, `.4`):
```text
Desktop
+---------------------------------------------------------------+
| Workflow movement Recorded history [Sound] [Refresh] |
| Task: Fix sign-in edge case State: In review Updated: 14:10 |
+----------------------------+----------------------------------+
| Workflow steps | Task trail (newest first) |
| 01 Draft | Current: Review |
| 02 Build | -> Review from Build 14:10 |
| 03 Review [current] | <- Build from Review 13:20 |
| 04 Done | -> Review from Build 10:42 |
+----------------------------+----------------------------------+
| History begins when recording was enabled. |
+-----------------------------------------------------------+
```
`UI-02: Workspace overview`, after explicit promotion
(`AC-PLUGINS-WORKFLOW-HISTORY-002.1` to `.4`, `.003.2`):
```text
Desktop
+---------------------------------------------------------------+
| [Workflow: Feature delivery v] [Sound] [Refresh] |
| Current tasks: 3 Recorded routes: 9 |
+----------------------------+----------------------------------+
| Steps and route counts | Selected task trail |
| Draft 1 now | [Task: Fix sign-in edge case v] |
| -> Build 3 recorded | Current: Review |
| Build 0 now | <- Build from Review 13:20 |
| -> Review 3 recorded | -> Review from Build 10:42 |
| Review 1 now | |
| <- Build 2 recorded | |
+----------------------------+----------------------------------+
| Entry/exit and removed-step groups remain visible separately. |
+---------------------------------------------------------------+
```
`UI-03: Phone focus`, both scopes (`AC-PLUGINS-WORKFLOW-HISTORY-003.5`, `.6`):
```text
Phone, one document scroll owner
+-----------------------------+
| Workflow movement |
| [Workflow v] [Task v] | task scope fixes these to this task
| [Steps] [Trail] | 44 px touch controls
+-----------------------------+
| Trail (selected) |
| Current: Review |
| <- Build from Review |
| -> Review from Build |
| ... |
+-----------------------------+
| [Enable sound] [Refresh] | clears phone safe area
+-----------------------------+
Loading: Loading recorded history...
Empty: No recorded moves. Current step may still be known.
Unsupported: This Kandev version cannot read recorded history.
Denied: Access changed. Reopen or request workspace permission.
Error: Could not refresh. [Retry]
```
`UI-04: Host rename`, task or workspace canvas
(`AC-CANVASES-NAME-SHARE-001.1` to `.4`):
```text
Desktop: [Issue 3583 investigation] [Rename] [Releases] [Share]
Rename -> [Issue 3583 investigation________] [Cancel] [Save]
Phone: [Issue 3583 investigation] [Actions]
Actions -> Rename -> focused name sheet [Cancel] [Save]
```
`UI-05: Quick sharing`, selected active release
(`AC-CANVASES-NAME-SHARE-002.1` to `.6`):
```text
Share canvas Release: 1.0.0
Package: Issue 3583 investigation canvas-65d40cfb7bd3
Needs your input: License [________]
[Prepare downloads]
[Package details v] ID, version, author, description, compatibility
After preparation: inventory + sizes + private-content reminder
[Download bundle] [Download source]
Phone: same content in one full-height, safe-area-aware scroll surface
```
## Tests
| Acceptance | Evidence |
| --- | --- |
| `001.1` to `001.3` | Task repository tests for creation, backward move, same-time tie, null endpoints, removed steps, and keyset pages; SQLite and Postgres parity. |
| `001.4`, `001.5` | Host and canvas-protocol tests for task scope, missing grant, forbidden task, no actor/session fields, and no write side effect. |
| `002.1` to `002.4` | Repository grouping tests with active/archived/deleted tasks and cross-workflow entry/exit; host scope tests and paginated task-list projection. |
| `003.1` to `003.6` | Canvas E2E fixtures plus exact-source desktop/phone Playwright smoke before publish. |
| `NAME-SHARE-001.1` to `.4` | Service, repository, HTTP, event, and desktop/phone host tests for rename, authorization, persistence, navigation refresh, and no iframe reload. |
| `NAME-SHARE-002.1` to `.6` | Distribution/default tests and desktop/phone E2E for release metadata, missing license, compatibility fallback, preparation, stale release, and both downloads. |
## E2E tests
- `apps/web/e2e/tests/canvas/workflow-transition-history.spec.ts`, project
`chromium`: seeded task moves through Review and back; browser reads only
recorded rows; workspace summary after promotion; permission denial and
refresh recovery (`001.1`, `001.4`, `002.1`, `002.3`, `003.1` to `.4`, `.6`).
- `apps/web/e2e/tests/canvas/mobile-workflow-transition-history.spec.ts`,
project `mobile-chrome`: select workflow/task, switch Steps/Trail, inspect a
return, retry an error, and assert touch targets and no horizontal overflow
(`003.2`, `.3`, `.5`, `.6`).
- `apps/web/e2e/tests/canvas/canvas-host-rename.spec.ts` and
`mobile-canvas-host-rename.spec.ts`: rename from task hosts; check
picker/navigation, validation, and no iframe remount.
- Extend `canvas-sharing.spec.ts` and `mobile-canvas-sharing.spec.ts`:
active-release defaults, one missing license, editable details, stale
release handling, reviewed downloads, focus, and touch sizing.
## Work orders
- [x] [Task 01: Read recorded moves and route groups](task-01-transition-reads.md)
- [x] [Task 02: Expose scoped Host and canvas reads](task-02-host-api.md)
- [x] [Task 03: Publish the live workflow canvas](task-03-live-canvas.md)
- [x] [Task 04: Rename a canvas from host chrome](task-04-canvas-rename.md)
- [x] [Task 05: Simplify reviewed canvas sharing](task-05-quick-sharing.md)
## Verification results
All five work orders are complete. The final task-local canvas release is
`682a8c38-45ff-46a0-87ad-6270e73d4c91` (`1.1.1`), valid and active in
task scope. Exact-source Playwright smoke covered task, workspace phone,
older-host, and sound opt-in states. Focused backend tests, frontend unit tests,
typecheck, lint, localization checks, public-doc validation, and desktop/phone
canvas E2E passed. Workspace promotion remains a user-controlled action.
## Risks
- The ledger began recording after some tasks were created. The app must show
only recorded history and label the start boundary.
- Current step order can change, so a historic move's return label is a
current-order interpretation, not a persisted rejection fact.
- Grouped historical counts include archived tasks while current markers do
not. The UI must name the two measures separately.
- The task-scoped canvas cannot show workflow-wide counts until the user
promotes it and its workspace grants are active.
- The current running host may predate the new routes. The republished canvas
must retain a useful current-task view and show an unsupported-host state
until a host with the API is running.
- Task-local canvas source is not part of a PR checkout; the final release and
exact-source browser check are task artifacts, while Host API code, tests,
and docs are reviewable repository changes.
- Share defaults must come from the exact active release. A UI fallback from
the canvas title cannot safely supply package identity, license, or
compatibility; the server must provide and validate those values.
@@ -0,0 +1,99 @@
---
id: "01-transition-reads"
title: "Read recorded moves and route groups"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-PLUGINS-WORKFLOW-HISTORY-001
- REQ-PLUGINS-WORKFLOW-HISTORY-002
acceptance_criteria:
- AC-PLUGINS-WORKFLOW-HISTORY-001.1
- AC-PLUGINS-WORKFLOW-HISTORY-001.2
- AC-PLUGINS-WORKFLOW-HISTORY-001.3
- AC-PLUGINS-WORKFLOW-HISTORY-002.1
- AC-PLUGINS-WORKFLOW-HISTORY-002.2
system_design:
- ../../specs/plugins/system-design/workflow-transition-history.md
---
# Task 01: Read recorded moves and route groups
## Summary
Expose bounded read methods from the task service over the existing transition
ledger. Keep ledger writers unchanged and make SQLite/Postgres reads agree.
## In scope
- Add a task-history read ordered by descending ledger ID with an opaque,
task-bound cursor and a `limit+1` continuation check.
- Add grouped workflow-side counts for within-workflow moves and entries/exits.
Join to retained tasks and workspace ownership; include archived tasks.
- Add indexes needed for the grouped read on existing and new SQLite/Postgres
stores.
## Out of scope
- Plugin or canvas routes, permission grants, UI, and transition writes.
## Acceptance
1. A task with creation, forward, backward, detach, and same-time moves yields
stable pages without duplicate or lost IDs; a missing/deleted step does not
erase its row.
2. Grouped results count retained moves exactly once and separate entries and
exits, including archived tasks but excluding deleted tasks.
3. Existing database upgrades create the new indexes idempotently on SQLite
and Postgres, and read methods make no writes.
## Verification
```bash
(cd apps/backend && go test ./internal/task/repository/sqlite ./internal/task/service)
(cd apps/backend && go test ./internal/persistence/storeconformance/...)
```
## Files likely touched
- `apps/backend/internal/task/repository/sqlite/step_transitions.go`
- `apps/backend/internal/task/repository/sqlite/base_schema.go`
- `apps/backend/internal/task/repository/sqlite/step_transitions_*_test.go`
- `apps/backend/internal/task/service/service.go`
- `apps/backend/internal/task/service/step_transition_reads.go` and tests
## Dependencies
None.
## Risks
Workflow-side aggregation can scan a large ledger without both source and
destination indexes. Query plans and parity tests must cover that path.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/plugins/requirements/workflow-transition-history.md)
- [System design](../../specs/plugins/system-design/workflow-transition-history.md)
- Existing ledger writer and [task ledger design](../../specs/tasks/system-design/workflow-task-step-transition-ledger.md)
## Results
Implemented task-scoped reads with monotonic transition-ID cursors and
workspace route grouping over the
retained ledger. Added task-ID, source-workflow, and destination-workflow
indexes. SQLite and Postgres schema replay share the same indexes; the
Postgres query test is gated on `KANDEV_TEST_POSTGRES_DSN`.
Passed after the final schema edit:
```text
go test ./internal/task/repository/sqlite ./internal/task/service ./internal/persistence/storeconformance/...
```
Postgres runtime parity was skipped locally because no DSN is configured.
@@ -0,0 +1,110 @@
---
id: "02-host-api"
title: "Expose scoped Host and canvas reads"
status: done
wave: 2
depends_on:
- "01-transition-reads"
plan: "plan.md"
requirements:
- REQ-PLUGINS-WORKFLOW-HISTORY-001
- REQ-PLUGINS-WORKFLOW-HISTORY-002
acceptance_criteria:
- AC-PLUGINS-WORKFLOW-HISTORY-001.1
- AC-PLUGINS-WORKFLOW-HISTORY-001.2
- AC-PLUGINS-WORKFLOW-HISTORY-001.4
- AC-PLUGINS-WORKFLOW-HISTORY-001.5
- AC-PLUGINS-WORKFLOW-HISTORY-002.1
- AC-PLUGINS-WORKFLOW-HISTORY-002.2
- AC-PLUGINS-WORKFLOW-HISTORY-002.3
system_design:
- ../../specs/plugins/system-design/workflow-transition-history.md
---
# Task 02: Expose scoped Host and canvas reads
## Summary
Add typed plugin Host reads and equivalent canvas browser routes for task
trails and workflow route groups. Enforce grants and scope before a ledger
query, then document the additive public contract.
## In scope
- Extend the v1 proto, generated stubs, SDK interfaces and mappings, Plugin
Host adapter, and browser JSON routing.
- Reuse `api_read:tasks` for a scoped task trail. Require both task and
workflow read grants plus workspace canvas scope for route groups.
- Test forbidden task/workflow IDs, task-scope summary denial, pagination,
missing steps, null endpoints, and omission of actor/session identifiers.
- Update the embedded canvas authoring API reference and public plugin/canvas
reference pages.
## Out of scope
- Canvas layout and publication, task writes, and new permission kinds.
## Acceptance
1. gRPC/SDK and browser reads expose the same ordered facts and bounded groups,
with no unscoped or unpublished identifier leaked through an error.
2. Effective grants and current canvas scope gate every browser request;
task scope can read only its own trail and cannot read route groups.
3. The authoring bundle and public docs name the new routes, fields, limits,
permission requirements, and workspace-promotion boundary.
## Verification
```bash
make -C apps/backend proto
(cd apps/backend && go test ./internal/plugins ./pkg/pluginsdk ./internal/mcp/canvasskill)
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
## Files likely touched
- `apps/backend/proto/kandev/plugin/v1/plugin.proto` and generated Go stubs
- `apps/backend/pkg/pluginsdk/host.go` and tests
- `apps/backend/internal/plugins/host_data.go`
- `apps/backend/internal/plugins/webapp_protocol.go`
- `apps/backend/internal/plugins/webapp_protocol_data.go` and tests
- `apps/backend/internal/mcp/canvasskill/files/references/browser-api.md`
- `docs/public/plugins-authoring.md`
- `docs/public/canvases.md`
## Dependencies
Task 01's task-service read methods.
## Risks
The Host gRPC API is additive-only in protocol v1. A browser route that checks
only a resource grant but misses canvas scope could expose other tasks' moves.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/plugins/requirements/workflow-transition-history.md)
- [System design](../../specs/plugins/system-design/workflow-transition-history.md)
- [ADR 0043](../../decisions/0043-plugin-host-data-api.md)
## Results
Added task-history and workflow-group RPCs to protocol v1, generated stubs,
the optional SDK Host extension, the real Host adapter, and scoped canvas
browser routes. Browser tests cover task-scope refusal, missing grants,
foreign workflows, and workflows beyond the first list page. The bundled
browser reference and public canvas/plugin guides document both routes.
Passed after the final implementation edit:
```text
make -C apps/backend proto
(cd apps/backend && go test ./internal/plugins ./pkg/pluginsdk ./internal/mcp/canvasskill)
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
```
@@ -0,0 +1,138 @@
---
id: "03-live-canvas"
title: "Publish the live workflow canvas"
status: done
wave: 3
depends_on:
- "02-host-api"
plan: "plan.md"
requirements:
- REQ-PLUGINS-WORKFLOW-HISTORY-003
- REQ-PLUGINS-WORKFLOW-HISTORY-002
acceptance_criteria:
- AC-PLUGINS-WORKFLOW-HISTORY-002.4
- AC-PLUGINS-WORKFLOW-HISTORY-003.1
- AC-PLUGINS-WORKFLOW-HISTORY-003.2
- AC-PLUGINS-WORKFLOW-HISTORY-003.3
- AC-PLUGINS-WORKFLOW-HISTORY-003.4
- AC-PLUGINS-WORKFLOW-HISTORY-003.5
- AC-PLUGINS-WORKFLOW-HISTORY-003.6
system_design:
- ../../specs/plugins/system-design/workflow-transition-history.md
---
# Task 03: Publish the live workflow canvas
## Summary
Replace the existing canvas's sample path with recorded task history. Add a
workspace overview that becomes active after user-controlled promotion, then
publish a validated release for the current task.
## In scope
- Edit only the source root assigned to canvas
`65d40cfb-7bd3-407a-beb7-be78a551e62a` and republish it through
`publish_canvas_kandev` after local verification.
- Use scoped relative Kandev data routes, fetch every page needed for a
complete visible count, poll only while visible, and offer manual refresh.
- Label current-order backward moves and unknown/removed endpoints honestly.
Keep sound opt-in and ignore initial-load and reconnect differences.
- Implement desktop beside-trail layout and phone Steps/Trail focus with
visible selectors, 44 px controls, keyboard focus, safe-area padding, and
bundled localized copy.
- Add disposable canvas E2E fixtures for route and mobile behavior, plus a
local exact-source Playwright smoke script.
## Out of scope
- Automatically promoting the canvas, changing tasks, or accessing the DB
directly. The final response must distinguish the active task release from
the workspace view that awaits user promotion.
## Acceptance
1. The task canvas shows its real ledger trail and current task; no sample
count or move is presented as live evidence.
2. A promoted workspace canvas can select a workflow/task and show route
counts, current task counts, and trail; phone and desktop reach the same
information with distinct layouts.
3. Loading, empty, older-host, denied, and retry states work; sound is opt-in;
a watched move refreshes within 15 seconds; publication reports an active
release or a precise failure.
## ASCII UI preview
`UI-01: Task trail` and `UI-03: Phone focus` from the
[full plan](plan.md#ascii-ui-preview) are the implementation target:
```text
Desktop: [ordered steps + current marker] | [recorded trail, newest first]
Phone: [workflow/task context] [Steps | Trail] -> one focused scroll view
States: Loading | Empty | Older host | Permission denied | Error + Retry
```
`UI-02: Workspace overview` adds the workflow picker, current task counts,
historical route counts, and task selection after promotion. The phone view
keeps the same data behind the visible selectors and Steps/Trail controls.
These are structural requirements for `AC-PLUGINS-WORKFLOW-HISTORY-003.2`,
`.5`, and `.6`; exact spacing is illustrative.
## Verification
```bash
node --check .kandev/canvases/65d40cfb-7bd3-407a-beb7-be78a551e62a/script.js
node apps/web/e2e/tests/canvas/workflow-canvas-source-smoke.mjs .kandev/canvases/65d40cfb-7bd3-407a-beb7-be78a551e62a
make -C apps/backend build
(cd apps/web && pnpm run build:e2e)
make -C apps/backend e2e-plugin-package
(cd apps/web && pnpm e2e:run --host --project=chromium tests/canvas/workflow-transition-history.spec.ts)
(cd apps/web && pnpm e2e:run --host --project=mobile-chrome tests/canvas/mobile-workflow-transition-history.spec.ts)
```
After checks, call `publish_canvas_kandev` with the assigned source path and
record its release ID and activation status in this work order. Publication
does not promote the canvas; the user controls workspace scope.
## Files likely touched
- `.kandev/canvases/65d40cfb-7bd3-407a-beb7-be78a551e62a/manifest.yaml`
- `.kandev/canvases/65d40cfb-7bd3-407a-beb7-be78a551e62a/index.html`
- `.kandev/canvases/65d40cfb-7bd3-407a-beb7-be78a551e62a/script.js`
- `.kandev/canvases/65d40cfb-7bd3-407a-beb7-be78a551e62a/styles.css`
- `apps/web/e2e/tests/canvas/workflow-transition-history.spec.ts`
- `apps/web/e2e/tests/canvas/mobile-workflow-transition-history.spec.ts`
- `apps/web/e2e/tests/canvas/workflow-canvas-source-smoke.mjs`
## Dependencies
Task 02's browser data routes and docs.
## Risks
The source root is task-local and ignored by Git. Keep the exact-source smoke
result and published release ID in the work-order Results. A workspace scope
change needs explicit user promotion and possibly grant review.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/plugins/requirements/workflow-transition-history.md)
- [System design](../../specs/plugins/system-design/workflow-transition-history.md)
- [Complete UI preview](plan.md#ascii-ui-preview)
- Existing task canvas release `051c4ea1-1f59-4589-ad64-bd6204e8edad`
## Results
The exact task-local source passed `node --check` and
`workflow-canvas-source-smoke.mjs` for task, phone workspace, older-host,
and opt-in sound states. The backend build, web E2E build, fixture packaging, desktop and phone
transition-route E2E tests passed. Published canvas
`65d40cfb-7bd3-407a-beb7-be78a551e62a` release
`682a8c38-45ff-46a0-87ad-6270e73d4c91` (`1.1.1`); it is valid and active
in task scope with no permission review. Workspace scope still awaits
user-controlled promotion. The current running host may show the older-host
state until this PR is deployed.
@@ -0,0 +1,96 @@
---
id: "04-canvas-rename"
title: "Rename a canvas from host chrome"
status: done
wave: 4
depends_on:
- "03-live-canvas"
plan: "plan.md"
requirements:
- REQ-CANVASES-NAME-SHARE-001
acceptance_criteria:
- AC-CANVASES-NAME-SHARE-001.1
- AC-CANVASES-NAME-SHARE-001.2
- AC-CANVASES-NAME-SHARE-001.3
- AC-CANVASES-NAME-SHARE-001.4
system_design:
- ../../specs/canvases/system-design/canvas-name-and-share-defaults.md
---
# Task 04: Rename a canvas from host chrome
## Summary
Let a workspace-authorized user change the canvas instance title directly
from its host toolbar without publishing a new release.
## In scope
- Add conditional title update in the canvas repository/service, an
workspace-management-authorized `PATCH /api/v1/canvases/:canvasID`, and a safe
`canvas.updated` lifecycle event for task and workspace projections.
- Add typed web API and a host-owned name editor beside the desktop toolbar
title. Expose Rename through the phone action drawer and focused sheet.
- Refresh task picker and workspace navigation titles after rename without
remounting the running canvas iframe. Localize host copy in all required
catalogs and update user documentation for canvas management.
## Out of scope
Changing release display names, package IDs, scope, grants, state, or runtime
capabilities; editing inside the canvas application.
## Acceptance
1. Save persists a trimmed valid name across reload/restart; task picker,
workspace navigation, and host toolbar agree.
2. Invalid, unauthorized, stale, or failed updates retain the old name and
keep the draft available for correction. Cancel makes no request.
3. Desktop keyboard and phone touch flows work with localized labels,
predictable focus, 44px phone actions, and no horizontal overflow.
## Verification
```bash
(cd apps/backend && go test ./internal/canvas ./internal/backendapp)
(cd apps/web && pnpm exec vitest run components/settings/canvas-host-components.test.tsx components/settings/canvas-host-route.test.tsx)
(cd apps/web && pnpm e2e:run --host --project=chromium tests/canvas/canvas-host-rename.spec.ts)
(cd apps/web && pnpm e2e:run --host --project=mobile-chrome tests/canvas/mobile-canvas-host-rename.spec.ts)
(cd apps/web && pnpm run i18n:check)
```
## Files likely touched
- `apps/backend/internal/canvas/repository.go`, `service.go`, and tests
- `apps/backend/internal/backendapp/canvas_routes.go` and tests
- `apps/web/lib/api/domains/canvas-api.ts`
- `apps/web/components/settings/canvas-host-components.tsx`
- `apps/web/components/settings/canvas-host-route.tsx` and host action files
- `apps/web/src/locales/*/canvases.json`
- `apps/web/e2e/tests/canvas/canvas-host-rename.spec.ts`
- Relevant `docs/public/**` canvas management page
## Dependencies
Task 03 is sequenced first so the example's release is current before host
management checks; the rename contract itself does not depend on its data API.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/canvases/requirements/canvas-name-and-share-defaults.md)
- [System design](../../specs/canvases/system-design/canvas-name-and-share-defaults.md)
- [Complete UI preview](plan.md#ascii-ui-preview)
## Results
Added conditional title update, workspace-management-authorized HTTP PATCH, safe
`canvas.updated` event, and desktop/phone host editors. The host refreshes
metadata without replacing the iframe. Canvas/backend tests, typecheck,
localization checks, focused unit tests, and desktop/phone rename E2E passed.
PR fixup tightened the HTTP guard to workspace-manage scope and added a viewer
regression test. The rename control uses desktop icon sizing while preserving
phone touch sizing; save keeps a stable accessible name and announces progress.
@@ -0,0 +1,109 @@
---
id: "05-quick-sharing"
title: "Simplify reviewed canvas sharing"
status: done
wave: 5
depends_on:
- "04-canvas-rename"
plan: "plan.md"
requirements:
- REQ-CANVASES-NAME-SHARE-002
acceptance_criteria:
- AC-CANVASES-NAME-SHARE-002.1
- AC-CANVASES-NAME-SHARE-002.2
- AC-CANVASES-NAME-SHARE-002.3
- AC-CANVASES-NAME-SHARE-002.4
- AC-CANVASES-NAME-SHARE-002.5
- AC-CANVASES-NAME-SHARE-002.6
system_design:
- ../../specs/canvases/system-design/canvas-name-and-share-defaults.md
---
# Task 05: Simplify reviewed canvas sharing
## Summary
Make the active release's known distribution data visible before preparation
and shorten the path from Share to reviewed downloads.
## In scope
- Restore safe release metadata in the canvas HTTP projection. Add an
authorized, release-bound export-defaults read that supplies the server's
first compatible distribution version when the manifest omits one.
- Reuse the selected release's ID, version, name, description, author, source
mode, and optional repository URL. Leave absent license and other
author-supplied details explicit. Never guess a license.
- Recompose the desktop Dialog and phone Drawer around required gaps,
Prepare downloads, and an editable Package details disclosure. Keep
inventory, private-content warning, and two separate downloads.
- Localize copy in all required catalogs and update public sharing guidance.
## Out of scope
Auto-publishing, changing the active release, persistent share profiles, and
relaxing the distribution validator or download reauthorization.
## Acceptance
1. The example canvas opens Share with its package ID, current release version, description,
and manifest author already present. License is the only author decision
if the server supplies a valid compatibility minimum.
2. Filling the remaining gaps permits one Prepare action, then shows the
exact release's inventory and both downloads. Editing a field or changing
release invalidates the review and requires fresh preparation.
3. Desktop and phone flows retain drafts on validation error, announce
loading/failure, provide Retry, and never download or publish before the
review/explicit download action.
## Verification
```bash
(cd apps/backend && go test ./internal/canvas ./internal/backendapp)
(cd apps/web && pnpm exec vitest run components/settings/canvas-share-dialog.test.tsx hooks/domains/canvas/use-canvas-share.test.ts)
(cd apps/web && pnpm e2e:run --host --project=chromium tests/canvas/canvas-sharing.spec.ts)
(cd apps/web && pnpm e2e:run --host --project=mobile-chrome tests/canvas/mobile-canvas-sharing.spec.ts)
(cd apps/web && pnpm run i18n:check)
```
## Files likely touched
- `apps/backend/internal/canvas/distribution_export.go` and tests
- `apps/backend/internal/backendapp/canvas_distribution_routes.go` and tests
- `apps/backend/internal/backendapp/canvas_routes.go`
- `apps/web/lib/api/domains/canvas-distribution-api.ts`
- `apps/web/components/settings/canvas-share-dialog.tsx` and tests
- `apps/web/hooks/domains/canvas/use-canvas-share.ts` and tests
- `apps/web/hooks/domains/canvas/use-canvas-export-defaults.ts` and tests
- `apps/web/src/locales/*/canvases.json`
- `apps/web/e2e/tests/canvas/{canvas-sharing,mobile-canvas-sharing}.spec.ts`
- Relevant `docs/public/**` sharing page
## Dependencies
Task 04 is sequenced first because Share can use the latest mutable canvas
name only when a release display name is missing. The two work orders also
touch shared host API files.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/canvases/requirements/canvas-name-and-share-defaults.md)
- [System design](../../specs/canvases/system-design/canvas-name-and-share-defaults.md)
- [Complete UI preview](plan.md#ascii-ui-preview)
## Results
Restored safe release metadata in host responses and added authorized,
release-bound export defaults with server-owned compatibility floor `0.95.0`.
Share presents missing fields first, keeps package details editable, and
retains reviewed inventory and separate downloads. Canvas/backend tests,
typecheck, localization checks, focused unit tests, and desktop/phone sharing
E2E passed.
PR fixup preserves an unset source mode, exposes it as a required selector,
and rejects static export when retained entry files are missing. The defaults
request now lives in a domain hook; focused backend and dialog tests cover the
missing-entry and explicit-choice paths.
+29 -3
View File
@@ -59,6 +59,16 @@ The app uses relative requests such as `./_kandev/v1/data/tasks` and `./_kandev/
A task read includes a read-only summary of that task's dependencies: whether it is blocked, and which tasks it depends on or blocks. Events never carry this summary, so a canvas that displays it refetches the task rather than reading the summary out of the event stream: on a `task.updated`, `task.dependencies_resolved`, or `task.dependency_failed` event for that task or one of its edges, or on a `task.state_changed` event for any task in its cached dependency lists (a predecessor or dependent simply advancing state is not one of the first three events). Kandev refuses an oversized read rather than truncating it silently; the authoring reference has the exact fields and limits.
To show recorded workflow movement, read
`./_kandev/v1/data/tasks/{task_id}/step-transitions`. It returns retained
moves newest first and preserves the IDs of removed steps. A task canvas can
read only its own task. After you promote a canvas to workspace scope, it can
also read `./_kandev/v1/data/workflows/{workflow_id}/transition-groups` when
the release has both task and workflow read grants. These groups count
historical routes, including archived tasks. Use the task list for current
task counts. [Plugin authoring](plugins-authoring.md) covers the browser and
backend Host readers.
The host shows canvas controls outside the app frame. The app runs in a
sandboxed same-origin iframe. Canvas source is trusted with the viewing user's
ordinary user-session authority:
@@ -194,6 +204,11 @@ Kandev allows up to 100 workspace canvas instances across scopes. Archived canva
On desktop, workspace canvases use the workspace Canvases area. On phones, Kandev opens a full-height canvas route and keeps canvas controls in an inset bottom drawer.
To change the name shown in Kandev, choose **Rename canvas** beside the host
toolbar title or in the phone actions drawer. Saving changes the canvas name in
navigation and task pickers. Workspace manage permission is required. Renaming
does not republish or rename the release package.
## Share and install a canvas
Canvas sharing is manual and release-bound. It does not capture screenshots or
@@ -202,9 +217,13 @@ to the canvas package.
1. Open the canvas host or the workspace canvas list.
2. Choose **Share canvas**.
3. Review the active release, package identity, file inventory, and archive
sizes.
4. Choose **Prepare downloads**, then download the bundle or source archive.
3. Check the active release and fill any required gaps, such as the license or
source mode. Kandev preselects static mode only when retained files support
it; an unavailable static export is rejected during preparation.
Kandev fills known package details from the release; expand **Package
details** to review or edit them. It does not choose a license for you.
4. Choose **Prepare downloads** and review the file inventory, archive sizes,
and private-content reminder. Then download the bundle or source archive.
5. Check the downloaded files for private content before sharing them.
The bundle is an installable `.tar.gz`. The source download is a ZIP of the
@@ -213,6 +232,13 @@ temporary and expires after 15 minutes. A release change, lost authorization,
expiry, or cancellation requires a new preparation. Kandev does not change the
running canvas while it prepares these files.
For an authorized browser client, `GET /api/v1/canvases/{id}/export-defaults`
returns the active release ID, editable package metadata, and the names of
missing required fields. The client must send that release ID as
`expected_release_id` to `POST /api/v1/canvases/{id}/exports`. A release change
requires fresh defaults and preparation. `PATCH /api/v1/canvases/{id}` with a
`title` changes only the canvas instance name.
Recipients can install a bundle from **Settings > Plugins > Canvases** by
uploading the file or entering an HTTPS direct link. A registry entry provides
an additional catalog path. Kandev fetches and inspects the exact package,
+10
View File
@@ -141,6 +141,16 @@ 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.
For recorded workflow movement, the browser can GET
`./_kandev/v1/data/tasks/{task_id}/step-transitions` with
`api_read:tasks`. A workspace canvas can GET
`./_kandev/v1/data/workflows/{workflow_id}/transition-groups` with both
`api_read:tasks` and `api_read:workflows`. The latter is denied to a task
canvas until the user promotes it. Both return bounded `{items,page_info}`
pages; use the opaque `next_cursor` to continue. The equivalent optional
backend Host extension is `pluginsdk.TransitionHistory(host)`. It exposes
`ListTask` and `ListWorkflowGroups` with the same fields and grants.
Kandev injects a reserved startup bootstrap into the packaged entry document.
It runs before authored scripts, reports an initial document error when one is
observed, and checks `./_kandev/v1/context` after the document loads. The host
@@ -0,0 +1,102 @@
---
id: canvases-name-and-share-defaults
title: Canvas naming and quick sharing
status: draft
system: canvases
owners:
- canvases
created: 2026-09-22
last_updated: 2026-09-22
---
# Canvas naming and quick sharing requirements
## Overview
Canvas owners need to change the name shown by Kandev without editing or
republishing the application. Preparing a private bundle and source download
should start with the selected release's known metadata so authors only fill
genuine gaps. This extends the [canvas lifecycle](agent-authored-web-apps.md)
and [sharing](marketplace-sharing.md) contracts. Canvas identity and export
preparation remain host operations; embedded applications gain no new write
capability.
## Terminology
- **Canvas name:** Mutable instance title shown in Kandev navigation and host
chrome. It is separate from a release's immutable package display name.
- **Share draft:** Temporary distribution metadata for one active release. It
does not change the live release or canvas name.
## Requirements
### REQ-CANVASES-NAME-SHARE-001: Rename from host toolbar
**Intent:** An authorized user can correct a canvas name where they view it.
#### Acceptance criteria
- **AC-CANVASES-NAME-SHARE-001.1:** The task and workspace canvas host toolbar
shall offer an accessible Rename action beside its displayed name. Desktop
keyboard and phone touch users shall reach the same action without entering
the canvas iframe or an agent editing session.
- **AC-CANVASES-NAME-SHARE-001.2:** Rename shall accept a nonempty, trimmed
title within the existing canvas title limit. The user can save or cancel;
invalid input and authorization or network failure shall keep the current
name and an editable draft with a clear error.
- **AC-CANVASES-NAME-SHARE-001.3:** A successful rename shall update the
existing canvas instance and its title in the open host, task canvas picker,
workspace navigation, and subsequent reads after refresh or restart. It
shall preserve canvas ID, scope, task binding, active/pending releases,
package identity, grants, instance state, and runtime URL.
- **AC-CANVASES-NAME-SHARE-001.4:** Only a user authorized to manage the
canvas's workspace may rename it. A stale or removed canvas shall not be
recreated. The embedded application shall not receive a rename API.
### REQ-CANVASES-NAME-SHARE-002: Release-based share draft
**Intent:** An author can prepare downloads by reviewing a few accurate
fields instead of transcribing release metadata.
#### Acceptance criteria
- **AC-CANVASES-NAME-SHARE-002.1:** Opening Share for a valid active release
shall load an authorized draft bound to that release ID. Package ID, version,
package display name, description, author, source mode, and repository URL
shall come from that release when declared. An absent repository URL shall
remain optional; the mutable canvas name may fill only a missing package
display name.
- **AC-CANVASES-NAME-SHARE-002.2:** Kandev shall supply a valid minimum
compatibility version from the release when declared, otherwise from its
server-owned first-compatible distribution version. The UI shall not guess
this from browser code or expose a placeholder as a real version. A higher
declared minimum shall be preserved.
- **AC-CANVASES-NAME-SHARE-002.3:** Kandev shall never choose a license or
invent an author or description. Undeclared required values shall be shown
as short, visible actions with field-specific validation. Source mode shall
default to static only if the release has no distribution source mode and
its retained files support a static export.
- **AC-CANVASES-NAME-SHARE-002.4:** The first share view shall show the
release being exported, the required gaps, and Prepare downloads. Already
populated fields shall remain reviewable and editable in a secondary
package-details section. A complete draft shall allow one-step preparation;
preparation shall still show inventory, sizes, private-content warning,
and separate bundle/source downloads before a file is downloaded.
- **AC-CANVASES-NAME-SHARE-002.5:** User edits shall survive validation
failures while Share stays open. Changing the active release or a prepared
field shall invalidate the review and downloads; a changed release shall
refresh defaults without silently keeping another release's package
identity. Server validation, expected-release checks, and download
authorization shall remain authoritative. Sharing shall not rename or
publish the live canvas.
- **AC-CANVASES-NAME-SHARE-002.6:** Desktop and phone users shall complete
the same share flow with localized labels, keyboard focus, announced
loading/errors, at least 44 CSS pixel phone controls, safe-area clearance,
one scrolling content area, and no horizontal overflow.
## Out of scope
- Automatically choosing legal terms, publishing to a repository or registry,
and sharing a live instance URL.
- Changing the package ID or version of an immutable active release.
- Granting canvas applications direct access to host rename or export APIs.
@@ -0,0 +1,118 @@
---
id: canvases-name-and-share-defaults-design
title: Canvas naming and quick sharing system design
status: draft
system: canvases
owners:
- canvases
requirements:
- REQ-CANVASES-NAME-SHARE-001
- REQ-CANVASES-NAME-SHARE-002
created: 2026-09-22
last_updated: 2026-09-22
---
# Canvas naming and quick sharing system design
## Requirement mapping
- [Canvas naming and quick sharing requirements](../requirements/canvas-name-and-share-defaults.md):
`REQ-CANVASES-NAME-SHARE-001` and `REQ-CANVASES-NAME-SHARE-002`.
- Preserve the [agent-authored canvas](agent-authored-web-apps.md) lifecycle
and [marketplace sharing](marketplace-sharing.md) preparation safeguards.
## Current boundary and observed gap
`canvas_lifecycle_metadata.title` is the instance title. Releases and their
manifest bytes are immutable. The host toolbar in
`apps/web/components/settings/canvas-host-components.tsx` renders the title
but offers no rename action. The browser canvas API has no rename operation.
`CanvasShareDialog` seeds from `canvas.active_release`, but
`canvasReleaseResponse` in `backendapp/canvas_routes.go` drops the package ID,
version, display name, description, and author already available in
`canvas.ReleaseMetadata`. The current example manifest contains those values,
so the blank fields in Share are a projection defect. The release legitimately
lacks a license and minimum Kandev version. `PrepareExport` can fill from the
immutable manifest, but rejects the remaining required gaps only after the
user submits. Client-only fallback would leave the form misleading.
## Host rename contract
Add `PATCH /api/v1/canvases/:canvasID` with `{ "title": "..." }`, returning the
normal canvas metadata response. The route uses the same feature gate and
workspace authorization as other canvas management routes. The canvas service
checks existence, trims and validates with `MaxTitleLength`, and updates only
the lifecycle title and `updated_at`. Use a conditional repository update so
a concurrent removal cannot create a row or report a false success. Keep the
plugin instance and release stores untouched. A rename emits an owner-scoped
`canvas.updated` lifecycle notification after commit; include ID, scope, title,
and timestamp, without manifest or runtime content. Existing canvas list
consumers refresh their projections on this event. A direct read remains the
fallback after a missed event.
Expose the action next to the toolbar title on desktop. It opens a compact
name editor with the current name selected, Save and Cancel, Enter to save,
and Escape to cancel. Disable duplicate submission and keep the draft open on
failure. On phones, place Rename in the host action drawer and use a focused
sheet with 44px controls and safe-area padding. Both presentations use one
host-owned rename state and accessible label. Update the host's canvas state
from the response without rebuilding the runtime iframe; navigation and task
picker projections receive the owner-scoped event. Use localized copy.
## Share defaults and preparation
Add `GET /api/v1/canvases/:canvasID/export-defaults`, authorized through the
same workspace check as export preparation and available only for a valid,
active release. Return `{ expected_release_id, metadata, missing_required }`
without files or secrets. Read the immutable release manifest, not browser
supplied metadata or a mutable task title. Reuse `fillExportMetadata` for
manifest values and source-mode normalization, then supply the first compatible
canvas-distribution version from server-owned version metadata when the
manifest minimum is absent. Never lower a declared minimum. If a valid
server version cannot be resolved, leave the field missing with an explicit
error rather than exporting a placeholder. Do not infer author or license;
description stays empty if the release did not provide one. A canvas title is
only a display-name fallback. Preserve package ID and version from the active
release. Repository URL is optional.
Fix `canvasReleaseResponse` so safe manifest metadata reaches all host views,
but make the export-defaults endpoint the Share dialog's canonical seed and
source of `expected_release_id`. This also avoids a race between a stale
host snapshot and export preparation. Reuse `normalizeExportMetadata` and the
existing validator in `POST /exports`; defaults are convenience only. Keep
existing behavior for direct API callers that omit metadata.
On Share open, load defaults and show release identity plus missing required
fields. Put populated metadata in a collapsed, editable Package details
section. Prepare downloads stays prominent; if a required value is missing,
focus that field and explain it. License remains a visible author choice and
never defaults to MIT or another license. On successful preparation, retain
the existing inventory, size, private-content reminder, and two download
actions. Form edits invalidate and delete the prepared review. On active
release change, discard the prior release's draft, fetch new defaults, and
disable download until prepared again. On close, discard the temporary draft.
On fetch failure, show Retry; do not expose a falsely complete draft.
## Mobile and accessibility
Retain the desktop Dialog and full-height phone Drawer. Each has a fixed
title/action region and one scrollable body. Focus the first missing field or
the Prepare action after defaults load, announce load/failure/review states,
and preserve focus when expanding Package details. The phone view uses
full-width actions, safe-area spacing, no horizontal overflow, and at least
44px touch targets. All host copy is localized in six catalogs; author data
remains untranslated.
## Validation
- Canvas service, repository, HTTP, and event tests: authorized rename,
whitespace/length rejection, concurrent removal, scope preservation,
restart persistence, and no release/runtime mutation.
- Distribution tests: old and distribution-profile manifests, missing
license/author/description, minimum-version fallback and higher minimum,
static/project mode, stale active release, changed draft, and validation
error retention.
- Desktop and phone E2E: rename updates toolbar/navigation without iframe
reload; Share prefills the example release, asks for license, prepares and
downloads after review, and recovers from stale/failing defaults.
+2
View File
@@ -38,6 +38,8 @@ canonical requirement and system-design documents. Use the catalog command to fi
- [UI](../ui/README.md): renders plugin contributions.
- [Canvases](../canvases/README.md): binds plugin web applications to task
and workspace canvas lifecycles.
- [Tasks](../tasks/README.md): owns task and workflow state, including the
transition ledger projected through the Host data API.
- [Canvas distribution](../canvases/system-design/marketplace-sharing.md): owns
canvas export/import and the canvas-specific marketplace flow; reuses plugin
manifests, static validation, catalog sources, and the runtime trust contract.
@@ -0,0 +1,127 @@
---
id: plugins-workflow-transition-history
title: Workflow transition history for plugin and canvas reads
status: draft
system: plugins
owners:
- kandev
created: 2026-09-22
last_updated: 2026-09-22
---
# Workflow transition history for plugin and canvas reads Requirements
## Overview
A user investigating a task needs to see where it moved, when it moved, and
whether a move went backward through the current workflow order. A workspace
view also needs current task counts and recorded route counts. The task system
already records step changes; this capability makes that evidence available to
an authorized plugin or canvas and uses it in the workflow movement canvas.
Plugins owns the read contract because it owns the capability-gated Host data
API and the canvas browser data protocol. [Tasks](../../tasks/README.md) remains
authoritative for the transition ledger. [Canvases](../../canvases/README.md)
owns task scope and user-controlled workspace promotion.
## Terminology
- **Recorded move:** A committed row in the task step-transition ledger.
- **Backward move (return):** A move between two known steps in one workflow
whose destination has an earlier or equal position in the current workflow
definition. This does not establish why the move happened or whether a review
rejected work.
- **Route count:** The number of retained recorded moves between a source and
destination in one workflow. It is historical, while task counts describe
current unarchived tasks.
## Requirements
### REQ-PLUGINS-WORKFLOW-HISTORY-001: Task transition trail
**Intent:** An authorized reader can inspect the recorded path of a task without
accessing Kandev's database file or reconstructing moves from current state.
#### Acceptance criteria
- **AC-PLUGINS-WORKFLOW-HISTORY-001.1:** When a task has recorded moves, the
read API shall return each retained move's source and destination workflow
and step identifiers, occurrence time, and trigger in a stable newest-first
order with bounded pagination.
- **AC-PLUGINS-WORKFLOW-HISTORY-001.2:** A move shall remain in the trail when
its workflow or step has since been removed. The response shall preserve the
recorded identifiers and null endpoints without inventing a current name.
- **AC-PLUGINS-WORKFLOW-HISTORY-001.3:** When a task has no recorded moves, the
API shall return an empty trail. It shall not reconstruct moves from messages,
current step, or dates before ledger activation.
- **AC-PLUGINS-WORKFLOW-HISTORY-001.4:** A canvas with `api_read:tasks` shall
read the trail only for a task admitted by its current scope. A caller without
the read grant or task access shall receive an error and no history.
- **AC-PLUGINS-WORKFLOW-HISTORY-001.5:** Reading or refreshing a trail shall
never change a task, step, workflow, or ledger row. The public trail shall not
expose session IDs or actor identifiers.
### REQ-PLUGINS-WORKFLOW-HISTORY-002: Workspace route overview
**Intent:** A workspace canvas can show actual traffic through one workflow
without fetching every task's entire history.
#### Acceptance criteria
- **AC-PLUGINS-WORKFLOW-HISTORY-002.1:** For a workflow in the canvas workspace,
a workspace-scoped canvas with both `api_read:tasks` and
`api_read:workflows` shall receive bounded, paginated counts of retained
moves within that workflow, including moves belonging to archived tasks.
- **AC-PLUGINS-WORKFLOW-HISTORY-002.2:** The overview shall distinguish
within-workflow routes from entries into and exits from the workflow. It
shall retain route groups involving removed steps as unresolved identifiers.
- **AC-PLUGINS-WORKFLOW-HISTORY-002.3:** A task-scoped canvas shall not receive
workflow-wide route counts. An out-of-scope or unknown workflow shall not
disclose route data.
- **AC-PLUGINS-WORKFLOW-HISTORY-002.4:** The canvas shall show current task
counts from the existing scoped task list, with archived tasks excluded, and
label route counts as recorded history. A concurrent move may appear on one
read before the other; a refresh shall reconcile the display.
### REQ-PLUGINS-WORKFLOW-HISTORY-003: Live workflow movement canvas
**Intent:** The canvas from issue #3583 shows useful live evidence at the scope
the user has authorized.
#### Acceptance criteria
- **AC-PLUGINS-WORKFLOW-HISTORY-003.1:** In task scope, the canvas shall show
its task's current workflow step, state, timestamps, and recorded trail. It
shall not present sample moves as that task's history.
- **AC-PLUGINS-WORKFLOW-HISTORY-003.2:** After user-controlled promotion to
workspace scope, the same canvas shall let the user choose a workflow and an
unarchived task, show step counts and route counts, and inspect that task's
recorded trail.
- **AC-PLUGINS-WORKFLOW-HISTORY-003.3:** The canvas shall label known backward
moves separately from forward moves. A move involving a missing step or a
different workflow shall be shown as unclassified rather than called a
rejection or return.
- **AC-PLUGINS-WORKFLOW-HISTORY-003.4:** While open, the canvas shall detect
a watched task's step change within 15 seconds and refresh its current state
and trail. An audio cue shall play only after the user enables sound and only
for a subsequent observed step change.
- **AC-PLUGINS-WORKFLOW-HISTORY-003.5:** Desktop and phone views shall offer
the same workflow and task selection, trail, and source status. On phones,
selection and the trail shall be reachable by touch without horizontal page
scrolling; controls shall have visible focus and accessible names.
- **AC-PLUGINS-WORKFLOW-HISTORY-003.6:** Loading, empty history, an older host
without the history route, permission denial, and transient read failure
shall have distinct visible states. A failed read shall offer retry without
displaying stale data as current.
## Out of scope
- Reconstructing moves before the ledger started recording.
- Treating a backward move as proof of a rejected review.
- Writing, moving, or approving tasks from the diagram.
- A separate process that reads SQLite while Kandev's backend is unavailable.
- Automatic promotion or permission approval on behalf of the user.
## Implementation plan
- [Live workflow transition canvas](../../../plans/workflow-transition-canvas/plan.md)
@@ -0,0 +1,169 @@
---
id: plugins-workflow-transition-history-design
title: Workflow transition history for plugin and canvas reads
status: draft
system: plugins
requirements:
- REQ-PLUGINS-WORKFLOW-HISTORY-001
- REQ-PLUGINS-WORKFLOW-HISTORY-002
- REQ-PLUGINS-WORKFLOW-HISTORY-003
owners:
- kandev
created: 2026-09-22
last_updated: 2026-09-22
---
# Workflow transition history for plugin and canvas reads System Design
## Purpose and boundaries
The Plugins system publishes a capability-gated read model for recorded task
moves. The [task transition ledger](../../tasks/system-design/workflow-task-step-transition-ledger.md)
remains the only source of historical facts. This design does not change its
writers or infer pre-activation history. The [canvas lifecycle](../../canvases/system-design/agent-authored-web-apps.md)
continues to own task-to-workspace promotion and grant review.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-PLUGINS-WORKFLOW-HISTORY-001` | [Task trail read](#task-trail-read), [Authorization](#authorization) |
| `REQ-PLUGINS-WORKFLOW-HISTORY-002` | [Workflow route summary](#workflow-route-summary), [Authorization](#authorization) |
| `REQ-PLUGINS-WORKFLOW-HISTORY-003` | [Canvas view](#canvas-view), [Refresh and failure](#refresh-and-failure) |
## Components and responsibilities
1. The task repository adds bounded read queries over
`task_step_transitions`. The task service exposes narrow methods and keeps
task/workflow ownership checks above the repository.
2. The Plugin Host data adapter calls those task service methods. The typed
Host gRPC API, SDK, and canvas JSON routes share the same DTO definitions
and order. No plugin or canvas reads SQLite directly.
3. The existing issue #3583 canvas uses those browser routes. Its task view
works before promotion; its workspace view becomes available only after the
user promotes the same canvas and workspace grants cover its manifest.
## Task trail read
Add `ListTaskStepTransitions(task_id, page)` to the Host data API and
`GET ./_kandev/v1/data/tasks/{task_id}/step-transitions?limit=&cursor=` to the
canvas protocol. Both require `api_read:tasks`. The browser route first uses
the same task scope check as `getWebAppTask`; it never queries the ledger for a
task the binding cannot read.
The public item contains `id` as a decimal string, nullable
`from_workflow_id`, `from_workflow_step_id`, `to_workflow_id`, and
`to_workflow_step_id`, plus `trigger` and RFC3339 `occurred_at`. It omits
`actor_id`, `actor_kind`, and `session_id`. The nullable endpoints represent
creation, attachment, and detachment without invented steps. The browser
response uses the existing `{items,page_info}` envelope.
The repository reads `WHERE task_id = ? AND id < ? ORDER BY id DESC LIMIT
limit+1`, with the cursor omitted on the first page. `id` is the ledger's
monotonic identity, so equal timestamps and late writes do not reorder older
pages. The server treats the cursor as opaque, rejects malformed or foreign
cursor forms, and caps `limit` at 200. An empty page is valid. New moves
arriving during pagination appear on the next refresh, not in an older page.
## Workflow route summary
Add `ListWorkflowTransitionGroups(workflow_id, page)` to the Host data API and
`GET ./_kandev/v1/data/workflows/{workflow_id}/transition-groups?limit=&cursor=`
to the canvas protocol. Both require `api_read:tasks` and
`api_read:workflows`. The browser route is available only to a
workspace-scoped canvas whose workspace contains the workflow. The Host
adapter retains the gRPC plugin's existing instance-level workspace model.
The task repository groups retained rows joined to their task by task ID and
workspace ID. It includes archived tasks and excludes deleted tasks because
task deletion removes their ledger rows. Each group has `kind` (`within`,
`entry`, or `exit`), nullable `from_step_id` and `to_step_id`, and `count`.
`within` means both endpoints name the selected workflow. `entry` means only
the destination names it; `exit` means only the source names it. The other
workflow's identifiers are omitted from entry/exit groups. Removed step IDs
remain in the group. Groups have a stable `(kind, from_step_id, to_step_id)`
order and bounded pagination; counts are recomputed on each new overview
refresh. Add indexes for source- and destination-workflow grouping in both
SQLite and Postgres schema initialization, with a repository test for existing
stores.
The canvas gets unarchived current tasks from the existing paginated task list
filtered by `workflow_id` and derives step counts in memory. It does not use
route counts as current task counts. It fetches all task-list pages; if a read
fails, it marks the overview stale instead of presenting a partial count as
complete. The route summary is read separately, so concurrent moves can make
the two reads briefly disagree. The next refresh reconciles them.
## Authorization
Use the existing manifest resources. Per-task history needs
`api_read:tasks`; the workflow summary needs both `api_read:tasks` and
`api_read:workflows`. No new permission name or write capability is needed.
The canvas protocol checks effective grants, capability binding, and current
scope on every request. Out-of-scope tasks and workflows return no data. A
task-scoped canvas receives `plugin_permission_denied` for workflow-wide
groups even when its own task uses that workflow. Promotion remains an
explicit user action and may require workspace grant review.
The gRPC Host methods use the existing resource gates and service-layer reads.
The summary method checks both gates before calling the service. Host DTOs
are additive to protocol v1; generated proto code and SDK mappings remain in
sync. No arbitrary SQL or database path is exposed.
## Canvas view
The task canvas replaces the sample path in its primary view with the scoped
task trail. It keeps a clear `Recorded history` caption because ledger data
starts at its activation point. A deleted step is labeled `Removed step` with
its recorded identifier available on inspection. A move spanning workflows
is displayed but is not classified as forward or backward.
The workspace view adds a workflow picker, ordered steps, unarchived task
markers, grouped route counts, and selected task trail. The canvas determines
`return` only when both endpoints are known steps of the same workflow and
`to.position <= from.position` in the current step list. This is a positional
label, not a stored rejection verdict; a step reorder can change the label.
The history remains unchanged. Audio is off by default and unlocked by an
explicit `Enable sound` control after a user gesture.
Desktop places workflow steps beside the selected task trail. Phone uses a
single focused view with a visible workflow/task selector and `Steps` / `Trail`
controls; it does not squeeze the desktop columns or require horizontal page
scrolling. The closest Kandev phone precedent is `mobile-column-tabs.tsx`:
one selected step at a time, a visible navigator, and at least 44 px touch
targets. The canvas has one document scroll owner and safe-area bottom space.
Its standalone copy uses a bundled `t()` catalog for supported product
languages because the host does not inject i18next into the iframe.
## Refresh and failure
The canvas polls the selected task every five seconds and the workspace
overview every fifteen seconds while visible. It refreshes the trail after an
observed step change and offers a manual refresh. It compares steps only after
the first successful task read, so initial load and reconnect never ring.
Polling stops when the frame is hidden or removed. A 401 stale runtime token
or 403 grant denial is shown as an authority error. A 404 from the new history
route on an older host shows an unsupported-host state while the existing
current-task read remains usable; publication must not break the active task
canvas before the host is upgraded. Transient read failures show retry and
never relabel cached data as current. Empty history, empty workflow, and no
active tasks have separate messages.
## Persistence and observability
No new domain rows or writes are introduced. Only read indexes are added.
Task-scoped canvas publication remains an immutable release; the existing
release stays active if a later publish is rejected. The app stores no copy
of task history in canvas state. Host route tests measure paging and scope
denials; query tests cover SQLite and Postgres parity. Existing bounded JSON
responses, safe error codes, and request logs apply without a new metric label
containing task or workflow IDs.
## Related decisions
- [ADR 0043: capability-gated Host data API](../../../decisions/0043-plugin-host-data-api.md)
- [Plugin-backed web-app canvases](../../../decisions/2026-08-26-plugin-backed-web-app-canvases.md)
## Implementation plan
- [Live workflow transition canvas](../../../plans/workflow-transition-canvas/plan.md)