feat(canvas): show live workflow history and simplify sharing (#3877)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -5,6 +5,7 @@ type Listener = () => void;
|
||||
|
||||
export type CanvasLifecycleAction =
|
||||
| "canvas.created"
|
||||
| "canvas.updated"
|
||||
| "canvas.release.activated"
|
||||
| "canvas.release.permission_required"
|
||||
| "canvas.promoted"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "パッケージの詳細"
|
||||
}
|
||||
|
||||
@@ -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": "Ƥàćķàĝē ďēţàĩĺś"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "软件包详情"
|
||||
}
|
||||
|
||||
@@ -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": "套件詳情"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user