diff --git a/build/bin/verstak-desktop b/build/bin/verstak-desktop
index 8eeb458..9543260 100755
Binary files a/build/bin/verstak-desktop and b/build/bin/verstak-desktop differ
diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go
index f10230b..9a2d0b5 100644
--- a/cmd/smoke-platform/main.go
+++ b/cmd/smoke-platform/main.go
@@ -12,10 +12,12 @@ import (
"github.com/verstak/verstak-desktop/internal/core/plugin"
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/vault"
+ "github.com/verstak/verstak-desktop/internal/core/workspace"
)
func main() {
testEnableDisable := flag.Bool("test-enable-disable", false, "Test enable/disable lifecycle")
+ testWorkspace := flag.Bool("test-workspace", false, "Test workspace/cases lifecycle")
flag.Parse()
exitCode := 0
defer func() {
@@ -25,6 +27,11 @@ func main() {
root, _ := os.Getwd()
pluginDir := filepath.Join(root, "plugins")
+ if *testWorkspace {
+ runWorkspaceTest(root)
+ return
+ }
+
if *testEnableDisable {
runEnableDisableTest(root)
return
@@ -434,3 +441,214 @@ func runEnableDisableTest(root string) {
fmt.Printf("\n=== summary ===\n")
fmt.Printf("✅ enable/disable test passed\n")
}
+
+// runWorkspaceTest tests the workspace/cases lifecycle.
+func runWorkspaceTest(root string) {
+ exitCode := 0
+ defer func() {
+ os.Exit(exitCode)
+ }()
+
+ fmt.Printf("=== smoke-platform: workspace test ===\n\n")
+
+ tmpDir, err := os.MkdirTemp("", "verstak-ws-smoke-*")
+ if err != nil {
+ fmt.Printf(" ❌ failed to create temp dir: %v\n", err)
+ exitCode = 1
+ return
+ }
+ defer os.RemoveAll(tmpDir)
+
+ vaultPath := filepath.Join(tmpDir, "testvault")
+ fmt.Printf(" vault path: %s\n", vaultPath)
+
+ v := vault.NewVault(nil)
+ if err := v.CreateVault(vaultPath); err != nil {
+ fmt.Printf(" ❌ create vault: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ vault created\n")
+
+ openedPath := v.GetVaultPath()
+ if err := v.OpenVault(openedPath); err != nil {
+ fmt.Printf(" ❌ open vault: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ vault opened at %s\n", openedPath)
+
+ fmt.Printf("\n[workspace init]\n")
+ ws := workspace.NewManager(openedPath)
+ if err := ws.Load(); err != nil {
+ fmt.Printf(" ❌ load workspace: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ workspace loaded\n")
+
+ tree := ws.GetTree()
+ if len(tree.Nodes) != 1 {
+ fmt.Printf(" ❌ expected 1 root node, got %d\n", len(tree.Nodes))
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ root node exists (id=%s)\n", tree.Nodes[0].ID)
+ rootID := tree.Nodes[0].ID
+
+ fmt.Printf("\n[workspace capability]\n")
+ reg := capability.NewRegistry()
+ reg.Register("verstak-desktop", []string{
+ "verstak/core/plugin-manager/v1",
+ "verstak/core/capability-registry/v1",
+ "verstak/core/contribution-registry/v1",
+ "verstak/core/permissions/v1",
+ "verstak/core/events/v1",
+ })
+ reg.Register("verstak-desktop", []string{"verstak/core/vault/v1"})
+ reg.Register("verstak-desktop", []string{"verstak/core/workspace/v1"})
+ if !reg.Has("verstak/core/workspace/v1") {
+ fmt.Printf(" ❌ workspace capability not registered\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ workspace capability registered\n")
+ totalCaps := len(reg.List())
+ if totalCaps < 7 {
+ fmt.Printf(" ❌ expected >= 7 capabilities, got %d\n", totalCaps)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ total capabilities >= 7 (%d)\n", totalCaps)
+
+ fmt.Printf("\n[create case]\n")
+ caseNode, err := ws.CreateNode(rootID, workspace.TypeCase, "Test Case")
+ if err != nil {
+ fmt.Printf(" ❌ create case: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ case created: %s\n", caseNode.Title)
+
+ fmt.Printf("\n[create folder]\n")
+ folderNode, err := ws.CreateNode(rootID, workspace.TypeFolder, "Test Folder")
+ if err != nil {
+ fmt.Printf(" ❌ create folder: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ folder created: %s\n", folderNode.Title)
+
+ fmt.Printf("\n[create nested case]\n")
+ nestedCase, err := ws.CreateNode(folderNode.ID, workspace.TypeCase, "Nested Case")
+ if err != nil {
+ fmt.Printf(" ❌ create nested case: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ nested case created: %s\n", nestedCase.Title)
+
+ fmt.Printf("\n[tree structure]\n")
+ tree = ws.GetTree()
+ if len(tree.Nodes) != 4 {
+ fmt.Printf(" ❌ expected 4 nodes, got %d\n", len(tree.Nodes))
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ tree has 4 nodes\n")
+ children := ws.ListChildren(rootID)
+ if len(children) != 2 {
+ fmt.Printf(" ❌ expected 2 root children, got %d\n", len(children))
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ root has 2 children\n")
+
+ fmt.Printf("\n[rename]\n")
+ if err := ws.RenameNode(caseNode.ID, "Renamed Case"); err != nil {
+ fmt.Printf(" ❌ rename: %v\n", err)
+ exitCode = 1
+ return
+ }
+ renamed, _ := ws.GetNode(caseNode.ID)
+ if renamed.Title != "Renamed Case" {
+ fmt.Printf(" ❌ rename failed: got %q\n", renamed.Title)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ renamed to %q\n", renamed.Title)
+
+ fmt.Printf("\n[set current node]\n")
+ if err := ws.SetCurrentNode(caseNode.ID); err != nil {
+ fmt.Printf(" ❌ set current: %v\n", err)
+ exitCode = 1
+ return
+ }
+ current, err := ws.GetCurrentNode()
+ if err != nil {
+ fmt.Printf(" ❌ get current: %v\n", err)
+ exitCode = 1
+ return
+ }
+ if current.ID != caseNode.ID {
+ fmt.Printf(" ❌ current node mismatch\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ current node: %s\n", current.Title)
+
+ fmt.Printf("\n[archive]\n")
+ if err := ws.ArchiveNode(folderNode.ID); err != nil {
+ fmt.Printf(" ❌ archive: %v\n", err)
+ exitCode = 1
+ return
+ }
+ archived, _ := ws.GetNode(folderNode.ID)
+ if archived.Status != workspace.StatusArchived {
+ fmt.Printf(" ❌ archive failed: status=%s\n", archived.Status)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ archived %s\n", archived.Title)
+
+ fmt.Printf("\n[reopen persistence]\n")
+ ws2 := workspace.NewManager(openedPath)
+ if err := ws2.Load(); err != nil {
+ fmt.Printf(" ❌ reopen: %v\n", err)
+ exitCode = 1
+ return
+ }
+ tree2 := ws2.GetTree()
+ if len(tree2.Nodes) != 4 {
+ fmt.Printf(" ❌ expected 4 nodes after reopen, got %d\n", len(tree2.Nodes))
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ tree persisted: %d nodes\n", len(tree2.Nodes))
+ current2, err := ws2.GetCurrentNode()
+ if err != nil {
+ fmt.Printf(" ❌ get current after reopen: %v\n", err)
+ exitCode = 1
+ return
+ }
+ if current2.ID != caseNode.ID {
+ fmt.Printf(" ❌ current node not persisted\n")
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ current node persisted\n")
+
+ fmt.Printf("\n[workspace.json verification]\n")
+ wsPath := filepath.Join(openedPath, ".verstak", "workspace.json")
+ wsData, err := os.ReadFile(wsPath)
+ if err != nil {
+ fmt.Printf(" ❌ read workspace.json: %v\n", err)
+ exitCode = 1
+ return
+ }
+ fmt.Printf(" ✅ workspace.json exists on disk\n")
+ fmt.Printf(" content:\n%s\n", string(wsData))
+
+ fmt.Printf("\n=== summary ===\n")
+ fmt.Printf("✅ workspace test passed\n")
+}
diff --git a/frontend/src/lib/shell/Sidebar.svelte b/frontend/src/lib/shell/Sidebar.svelte
index 6e4ad86..b7bdf8e 100644
--- a/frontend/src/lib/shell/Sidebar.svelte
+++ b/frontend/src/lib/shell/Sidebar.svelte
@@ -1,6 +1,7 @@
+
+
+
+
+ {#if loading}
+
Loading...
+ {:else if error}
+
{error}
+ {:else}
+ {#each roots() as node (node.id)}
+
+
+ {#if hasKids(node.id)}
+
+ {:else}
+
+ {/if}
+ {icon(node.type)}
+
+
+
+ {#if expandedNodes[node.id]}
+ {#each childrenOf(node.id) as child (child.id)}
+
+
+ {#if hasKids(child.id)}
+
+ {:else}
+
+ {/if}
+ {icon(child.type)}
+
+
+
+
+ {/each}
+ {/if}
+
+ {/each}
+ {/if}
+
+ {#if showCreate}
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts
index 68c1fcb..f11e856 100755
--- a/frontend/wailsjs/go/api/App.d.ts
+++ b/frontend/wailsjs/go/api/App.d.ts
@@ -5,10 +5,14 @@ import {api} from '../models';
import {permissions} from '../models';
import {plugin} from '../models';
+export function ArchiveWorkspaceNode(arg1:string):Promise;
+
export function CloseVault():Promise;
export function CreateVault(arg1:string):Promise;
+export function CreateWorkspaceNode(arg1:string,arg2:string,arg3:string):Promise>;
+
export function DisablePlugin(arg1:string):Promise;
export function EnablePlugin(arg1:string):Promise;
@@ -19,6 +23,8 @@ export function GetCapabilities():Promise>;
export function GetContributions():Promise;
+export function GetCurrentWorkspaceNode():Promise>;
+
export function GetPermissions():Promise>;
export function GetPlugins():Promise>;
@@ -27,6 +33,10 @@ export function GetVaultPluginState():Promise>;
export function GetVaultStatus():Promise>;
+export function GetWorkspaceTree():Promise>;
+
+export function MoveWorkspaceNode(arg1:string,arg2:string):Promise;
+
export function OpenVault(arg1:string):Promise;
export function ReadPluginDataJSON(arg1:string,arg2:string):Promise>;
@@ -39,12 +49,16 @@ export function RecordDesiredPlugin(arg1:string,arg2:string,arg3:string):Promise
export function ReloadPlugins():Promise;
+export function RenameWorkspaceNode(arg1:string,arg2:string):Promise;
+
export function SelectDirectory():Promise;
export function SelectVaultForOpen():Promise;
export function SetCurrentVault(arg1:string):Promise;
+export function SetCurrentWorkspaceNode(arg1:string):Promise;
+
export function UpdateAppSettings(arg1:Record):Promise;
export function WritePluginDataJSON(arg1:string,arg2:string,arg3:Record):Promise;
diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js
index 3ef025f..465e2ad 100755
--- a/frontend/wailsjs/go/api/App.js
+++ b/frontend/wailsjs/go/api/App.js
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+export function ArchiveWorkspaceNode(arg1) {
+ return window['go']['api']['App']['ArchiveWorkspaceNode'](arg1);
+}
+
export function CloseVault() {
return window['go']['api']['App']['CloseVault']();
}
@@ -10,6 +14,10 @@ export function CreateVault(arg1) {
return window['go']['api']['App']['CreateVault'](arg1);
}
+export function CreateWorkspaceNode(arg1, arg2, arg3) {
+ return window['go']['api']['App']['CreateWorkspaceNode'](arg1, arg2, arg3);
+}
+
export function DisablePlugin(arg1) {
return window['go']['api']['App']['DisablePlugin'](arg1);
}
@@ -30,6 +38,10 @@ export function GetContributions() {
return window['go']['api']['App']['GetContributions']();
}
+export function GetCurrentWorkspaceNode() {
+ return window['go']['api']['App']['GetCurrentWorkspaceNode']();
+}
+
export function GetPermissions() {
return window['go']['api']['App']['GetPermissions']();
}
@@ -46,6 +58,14 @@ export function GetVaultStatus() {
return window['go']['api']['App']['GetVaultStatus']();
}
+export function GetWorkspaceTree() {
+ return window['go']['api']['App']['GetWorkspaceTree']();
+}
+
+export function MoveWorkspaceNode(arg1, arg2) {
+ return window['go']['api']['App']['MoveWorkspaceNode'](arg1, arg2);
+}
+
export function OpenVault(arg1) {
return window['go']['api']['App']['OpenVault'](arg1);
}
@@ -70,6 +90,10 @@ export function ReloadPlugins() {
return window['go']['api']['App']['ReloadPlugins']();
}
+export function RenameWorkspaceNode(arg1, arg2) {
+ return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
+}
+
export function SelectDirectory() {
return window['go']['api']['App']['SelectDirectory']();
}
@@ -82,6 +106,10 @@ export function SetCurrentVault(arg1) {
return window['go']['api']['App']['SetCurrentVault'](arg1);
}
+export function SetCurrentWorkspaceNode(arg1) {
+ return window['go']['api']['App']['SetCurrentWorkspaceNode'](arg1);
+}
+
export function UpdateAppSettings(arg1) {
return window['go']['api']['App']['UpdateAppSettings'](arg1);
}
diff --git a/internal/api/app.go b/internal/api/app.go
index 04ecb42..a29dcbe 100644
--- a/internal/api/app.go
+++ b/internal/api/app.go
@@ -20,6 +20,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
+ "github.com/verstak/verstak-desktop/internal/core/workspace"
)
// App is the main application struct exposed to the Wails frontend.
@@ -34,6 +35,7 @@ type App struct {
storage *storage.Storage
appSettings *appsettings.Manager
pluginState *pluginstate.Manager
+ workspace *workspace.Manager
}
// NewApp creates a new App instance.
@@ -47,6 +49,7 @@ func NewApp(
storageService *storage.Storage,
appSettingsMgr *appsettings.Manager,
pluginStateMgr *pluginstate.Manager,
+ workspaceMgr *workspace.Manager,
) *App {
return &App{
capRegistry: capReg,
@@ -58,6 +61,7 @@ func NewApp(
storage: storageService,
appSettings: appSettingsMgr,
pluginState: pluginStateMgr,
+ workspace: workspaceMgr,
}
}
@@ -431,6 +435,104 @@ func (a *App) SetCurrentVault(path string) string {
return ""
}
+// ─── Workspace API ─────────────────────────────────────────
+
+// GetWorkspaceTree returns the full workspace tree.
+func (a *App) GetWorkspaceTree() map[string]interface{} {
+ if a.workspace == nil || !a.workspace.IsInitialized() {
+ return map[string]interface{}{"status": "not initialized"}
+ }
+ tree := a.workspace.GetTree()
+ return map[string]interface{}{
+ "schemaVersion": tree.SchemaVersion,
+ "nodes": tree.Nodes,
+ "currentNodeId": tree.CurrentNodeID,
+ "updatedAt": tree.UpdatedAt,
+ }
+}
+
+// CreateWorkspaceNode creates a new workspace node.
+func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
+ if a.workspace == nil {
+ return map[string]interface{}{"error": "workspace not initialized"}
+ }
+ node, err := a.workspace.CreateNode(parentID, workspace.NodeType(nodeType), title)
+ if err != nil {
+ return map[string]interface{}{"error": err.Error()}
+ }
+ return map[string]interface{}{
+ "id": node.ID,
+ "parentId": node.ParentID,
+ "type": string(node.Type),
+ "title": node.Title,
+ "status": string(node.Status),
+ "order": node.Order,
+ "createdAt": node.CreatedAt,
+ "updatedAt": node.UpdatedAt,
+ }
+}
+
+// RenameWorkspaceNode renames a workspace node.
+func (a *App) RenameWorkspaceNode(id, title string) string {
+ if a.workspace == nil {
+ return "workspace not initialized"
+ }
+ if err := a.workspace.RenameNode(id, title); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+// MoveWorkspaceNode moves a node to a new parent.
+func (a *App) MoveWorkspaceNode(id, newParentID string) string {
+ if a.workspace == nil {
+ return "workspace not initialized"
+ }
+ if err := a.workspace.MoveNode(id, newParentID); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+// ArchiveWorkspaceNode archives a workspace node.
+func (a *App) ArchiveWorkspaceNode(id string) string {
+ if a.workspace == nil {
+ return "workspace not initialized"
+ }
+ if err := a.workspace.ArchiveNode(id); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+// GetCurrentWorkspaceNode returns the currently selected node.
+func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
+ if a.workspace == nil {
+ return map[string]interface{}{"status": "not initialized"}
+ }
+ node, err := a.workspace.GetCurrentNode()
+ if err != nil {
+ return map[string]interface{}{"error": err.Error()}
+ }
+ return map[string]interface{}{
+ "id": node.ID,
+ "type": string(node.Type),
+ "title": node.Title,
+ "status": string(node.Status),
+ }
+}
+
+// SetCurrentWorkspaceNode sets the currently selected node.
+func (a *App) SetCurrentWorkspaceNode(id string) string {
+ if a.workspace == nil {
+ return "workspace not initialized"
+ }
+ if err := a.workspace.SetCurrentNode(id); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
// ─── Vault Plugin State API ────────────────────────────────
// GetVaultPluginState returns the current vault plugin state.
diff --git a/internal/core/workspace/manager.go b/internal/core/workspace/manager.go
new file mode 100644
index 0000000..d2f5979
--- /dev/null
+++ b/internal/core/workspace/manager.go
@@ -0,0 +1,451 @@
+// Package workspace provides the core workspace/cases service for Verstak.
+// It manages a tree of workspaces, cases, and folders inside a vault.
+//
+// This is NOT notes/files/editor — it is the foundational layer that
+// organizes work into a hierarchy. Plugins later reference workspace
+// nodes via stable IDs.
+package workspace
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// NodeType represents the type of a workspace node.
+type NodeType string
+
+const (
+ TypeSpace NodeType = "space"
+ TypeCase NodeType = "case"
+ TypeFolder NodeType = "folder"
+)
+
+// NodeStatus represents the lifecycle status of a node.
+type NodeStatus string
+
+const (
+ StatusActive NodeStatus = "active"
+ StatusSleeping NodeStatus = "sleeping"
+ StatusArchived NodeStatus = "archived"
+)
+
+// WorkspaceNode is a single item in the workspace tree.
+type WorkspaceNode struct {
+ ID string `json:"id"`
+ ParentID string `json:"parentId,omitempty"`
+ Type NodeType `json:"type"`
+ Title string `json:"title"`
+ Status NodeStatus `json:"status"`
+ Tags []string `json:"tags,omitempty"`
+ Order int `json:"order"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// WorkspaceTree holds the full node tree and current selection.
+type WorkspaceTree struct {
+ SchemaVersion int `json:"schemaVersion"`
+ Nodes []WorkspaceNode `json:"nodes"`
+ CurrentNodeID string `json:"currentNodeId,omitempty"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// Manager provides workspace operations.
+type Manager struct {
+ mu sync.RWMutex
+ tree *WorkspaceTree
+ vaultDir string
+}
+
+// NewManager creates a workspace manager for the given vault directory.
+func NewManager(vaultDir string) *Manager {
+ return &Manager{
+ vaultDir: vaultDir,
+ }
+}
+
+// workspaceFilePath returns the path to workspace.json inside the vault.
+func (m *Manager) workspaceFilePath() string {
+ return filepath.Join(m.vaultDir, ".verstak", "workspace.json")
+}
+
+// Load reads the workspace tree from disk.
+// If no file exists, creates a default tree with a root node.
+func (m *Manager) Load() error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ path := m.workspaceFilePath()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ m.tree = m.defaultTree()
+ return m.saveLocked()
+ }
+ return fmt.Errorf("failed to read workspace.json: %w", err)
+ }
+
+ var tree WorkspaceTree
+ if err := json.Unmarshal(data, &tree); err != nil {
+ // Corrupt: backup and create defaults
+ backupPath := path + ".corrupt." + time.Now().Format("20060102-150405")
+ os.WriteFile(backupPath, data, 0o600)
+ m.tree = m.defaultTree()
+ if saveErr := m.saveLocked(); saveErr != nil {
+ return fmt.Errorf("corrupt workspace.json (backed up to %s), failed to save defaults: %w", backupPath, saveErr)
+ }
+ return fmt.Errorf("corrupt workspace.json (backed up to %s), defaults created", backupPath)
+ }
+
+ if tree.SchemaVersion != 1 {
+ tree.SchemaVersion = 1
+ }
+ if tree.Nodes == nil {
+ tree.Nodes = []WorkspaceNode{}
+ }
+
+ m.tree = &tree
+ return nil
+}
+
+// saveLocked writes the workspace tree to disk atomically.
+// Must be called with m.mu held (write lock).
+func (m *Manager) saveLocked() error {
+ if m.tree == nil {
+ return fmt.Errorf("workspace tree is nil")
+ }
+
+ m.tree.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
+
+ data, err := json.MarshalIndent(m.tree, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal workspace tree: %w", err)
+ }
+
+ path := m.workspaceFilePath()
+ tmpPath := path + ".tmp"
+
+ if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
+ return fmt.Errorf("failed to write workspace.json.tmp: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, path); err != nil {
+ os.Remove(tmpPath)
+ return fmt.Errorf("failed to rename workspace.json: %w", err)
+ }
+
+ return nil
+}
+
+// Save persists the current tree to disk.
+func (m *Manager) Save() error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.saveLocked()
+}
+
+// defaultTree creates a fresh workspace tree with a single root node.
+func (m *Manager) defaultTree() *WorkspaceTree {
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ root := WorkspaceNode{
+ ID: uuid.New().String(),
+ Type: TypeSpace,
+ Title: "My Workspace",
+ Status: StatusActive,
+ Order: 0,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ return &WorkspaceTree{
+ SchemaVersion: 1,
+ Nodes: []WorkspaceNode{root},
+ CurrentNodeID: root.ID,
+ UpdatedAt: now,
+ }
+}
+
+// GetTree returns a copy of the full tree.
+func (m *Manager) GetTree() WorkspaceTree {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if m.tree == nil {
+ return WorkspaceTree{SchemaVersion: 1}
+ }
+ return *m.tree
+}
+
+// GetNode returns a node by ID.
+func (m *Manager) GetNode(id string) (WorkspaceNode, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if m.tree == nil {
+ return WorkspaceNode{}, fmt.Errorf("workspace not initialized")
+ }
+ for _, n := range m.tree.Nodes {
+ if n.ID == id {
+ return n, nil
+ }
+ }
+ return WorkspaceNode{}, fmt.Errorf("node not found: %s", id)
+}
+
+// ListChildren returns direct children of a parent node, sorted by order.
+func (m *Manager) ListChildren(parentID string) []WorkspaceNode {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ if m.tree == nil {
+ return nil
+ }
+ var children []WorkspaceNode
+ for _, n := range m.tree.Nodes {
+ if n.ParentID == parentID {
+ children = append(children, n)
+ }
+ }
+ sort.Slice(children, func(i, j int) bool {
+ return children[i].Order < children[j].Order
+ })
+ return children
+}
+
+// CreateNode creates a new node under the given parent.
+func (m *Manager) CreateNode(parentID string, nodeType NodeType, title string) (WorkspaceNode, error) {
+ if nodeType != TypeSpace && nodeType != TypeCase && nodeType != TypeFolder {
+ return WorkspaceNode{}, fmt.Errorf("invalid node type: %s", nodeType)
+ }
+ if strings.TrimSpace(title) == "" {
+ return WorkspaceNode{}, fmt.Errorf("title cannot be empty")
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.tree == nil {
+ return WorkspaceNode{}, fmt.Errorf("workspace not initialized")
+ }
+
+ // Validate parent exists (empty parentID means root-level)
+ if parentID != "" {
+ parentFound := false
+ for _, n := range m.tree.Nodes {
+ if n.ID == parentID {
+ parentFound = true
+ break
+ }
+ }
+ if !parentFound {
+ return WorkspaceNode{}, fmt.Errorf("parent node not found: %s", parentID)
+ }
+ }
+
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+
+ // Calculate order: max existing sibling order + 1
+ maxOrder := -1
+ for _, n := range m.tree.Nodes {
+ if n.ParentID == parentID && n.Order > maxOrder {
+ maxOrder = n.Order
+ }
+ }
+
+ node := WorkspaceNode{
+ ID: uuid.New().String(),
+ ParentID: parentID,
+ Type: nodeType,
+ Title: title,
+ Status: StatusActive,
+ Order: maxOrder + 1,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ m.tree.Nodes = append(m.tree.Nodes, node)
+ if err := m.saveLocked(); err != nil {
+ // Rollback: remove the node we just added
+ m.tree.Nodes = m.tree.Nodes[:len(m.tree.Nodes)-1]
+ return WorkspaceNode{}, fmt.Errorf("failed to save after create: %w", err)
+ }
+
+ return node, nil
+}
+
+// RenameNode updates a node's title.
+func (m *Manager) RenameNode(id, title string) error {
+ if strings.TrimSpace(title) == "" {
+ return fmt.Errorf("title cannot be empty")
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.tree == nil {
+ return fmt.Errorf("workspace not initialized")
+ }
+
+ for i := range m.tree.Nodes {
+ if m.tree.Nodes[i].ID == id {
+ m.tree.Nodes[i].Title = title
+ m.tree.Nodes[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
+ return m.saveLocked()
+ }
+ }
+ return fmt.Errorf("node not found: %s", id)
+}
+
+// MoveNode changes a node's parent and order.
+func (m *Manager) MoveNode(id, newParentID string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.tree == nil {
+ return fmt.Errorf("workspace not initialized")
+ }
+
+ // Find the node
+ nodeIdx := -1
+ for i := range m.tree.Nodes {
+ if m.tree.Nodes[i].ID == id {
+ nodeIdx = i
+ break
+ }
+ }
+ if nodeIdx < 0 {
+ return fmt.Errorf("node not found: %s", id)
+ }
+
+ // Cannot move to self
+ if newParentID == id {
+ return fmt.Errorf("cannot move node into itself")
+ }
+
+ // Cannot move to own descendant
+ if m.isDescendant(id, newParentID) {
+ return fmt.Errorf("cannot move node into its own descendant")
+ }
+
+ // Validate new parent exists (empty = root level)
+ if newParentID != "" {
+ parentFound := false
+ for _, n := range m.tree.Nodes {
+ if n.ID == newParentID {
+ parentFound = true
+ break
+ }
+ }
+ if !parentFound {
+ return fmt.Errorf("parent node not found: %s", newParentID)
+ }
+ }
+
+ // Calculate new order
+ maxOrder := -1
+ for _, n := range m.tree.Nodes {
+ if n.ParentID == newParentID && n.Order > maxOrder {
+ maxOrder = n.Order
+ }
+ }
+
+ m.tree.Nodes[nodeIdx].ParentID = newParentID
+ m.tree.Nodes[nodeIdx].Order = maxOrder + 1
+ m.tree.Nodes[nodeIdx].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
+
+ return m.saveLocked()
+}
+
+// isDescendant checks if targetID is a descendant of ancestorID.
+func (m *Manager) isDescendant(ancestorID, targetID string) bool {
+ if targetID == "" {
+ return false
+ }
+ // Build parent map
+ parentMap := make(map[string]string)
+ for _, n := range m.tree.Nodes {
+ parentMap[n.ID] = n.ParentID
+ }
+ // Walk up from target
+ current := targetID
+ for current != "" {
+ if current == ancestorID {
+ return true
+ }
+ current = parentMap[current]
+ }
+ return false
+}
+
+// ArchiveNode sets a node's status to archived.
+func (m *Manager) ArchiveNode(id string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.tree == nil {
+ return fmt.Errorf("workspace not initialized")
+ }
+
+ for i := range m.tree.Nodes {
+ if m.tree.Nodes[i].ID == id {
+ m.tree.Nodes[i].Status = StatusArchived
+ m.tree.Nodes[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
+ return m.saveLocked()
+ }
+ }
+ return fmt.Errorf("node not found: %s", id)
+}
+
+// SetCurrentNode sets the currently selected node.
+func (m *Manager) SetCurrentNode(id string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.tree == nil {
+ return fmt.Errorf("workspace not initialized")
+ }
+
+ // Validate node exists
+ found := false
+ for _, n := range m.tree.Nodes {
+ if n.ID == id {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return fmt.Errorf("node not found: %s", id)
+ }
+
+ m.tree.CurrentNodeID = id
+ m.tree.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
+ return m.saveLocked()
+}
+
+// GetCurrentNode returns the currently selected node.
+func (m *Manager) GetCurrentNode() (WorkspaceNode, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ if m.tree == nil || m.tree.CurrentNodeID == "" {
+ return WorkspaceNode{}, fmt.Errorf("no current node")
+ }
+
+ for _, n := range m.tree.Nodes {
+ if n.ID == m.tree.CurrentNodeID {
+ return n, nil
+ }
+ }
+ return WorkspaceNode{}, fmt.Errorf("current node not found: %s", m.tree.CurrentNodeID)
+}
+
+// IsInitialized returns true if the workspace has been loaded.
+func (m *Manager) IsInitialized() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.tree != nil
+}
diff --git a/internal/core/workspace/manager_test.go b/internal/core/workspace/manager_test.go
new file mode 100644
index 0000000..c88dcb2
--- /dev/null
+++ b/internal/core/workspace/manager_test.go
@@ -0,0 +1,342 @@
+package workspace
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoad_DefaultRootNode(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ verstakDir := filepath.Join(vaultDir, ".verstak")
+ os.MkdirAll(verstakDir, 0o755)
+
+ m := NewManager(vaultDir)
+ if err := m.Load(); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ tree := m.GetTree()
+ if len(tree.Nodes) != 1 {
+ t.Fatalf("expected 1 root node, got %d", len(tree.Nodes))
+ }
+ if tree.Nodes[0].Type != TypeSpace {
+ t.Errorf("root type: got %s, want %s", tree.Nodes[0].Type, TypeSpace)
+ }
+ if tree.Nodes[0].Title != "My Workspace" {
+ t.Errorf("root title: got %q, want %q", tree.Nodes[0].Title, "My Workspace")
+ }
+ if tree.CurrentNodeID != tree.Nodes[0].ID {
+ t.Errorf("current node should be root")
+ }
+}
+
+func TestCreateNode_Case(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ verstakDir := filepath.Join(vaultDir, ".verstak")
+ os.MkdirAll(verstakDir, 0o755)
+
+ m := NewManager(vaultDir)
+ if err := m.Load(); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ rootID := m.GetTree().Nodes[0].ID
+
+ node, err := m.CreateNode(rootID, TypeCase, "Test Case")
+ if err != nil {
+ t.Fatalf("CreateNode: %v", err)
+ }
+ if node.Type != TypeCase {
+ t.Errorf("type: got %s, want %s", node.Type, TypeCase)
+ }
+ if node.Title != "Test Case" {
+ t.Errorf("title: got %q, want %q", node.Title, "Test Case")
+ }
+ if node.ParentID != rootID {
+ t.Errorf("parentID: got %q, want %q", node.ParentID, rootID)
+ }
+ if node.Status != StatusActive {
+ t.Errorf("status: got %s, want %s", node.Status, StatusActive)
+ }
+
+ // Verify persisted
+ tree := m.GetTree()
+ if len(tree.Nodes) != 2 {
+ t.Errorf("expected 2 nodes, got %d", len(tree.Nodes))
+ }
+}
+
+func TestCreateNode_InvalidType(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ _, err := m.CreateNode("", NodeType("note"), "My Note")
+ if err == nil {
+ t.Error("expected error for invalid type 'note'")
+ }
+}
+
+func TestCreateNode_EmptyTitle(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ _, err := m.CreateNode("", TypeCase, "")
+ if err == nil {
+ t.Error("expected error for empty title")
+ }
+ _, err = m.CreateNode("", TypeCase, " ")
+ if err == nil {
+ t.Error("expected error for whitespace-only title")
+ }
+}
+
+func TestRenameNode(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ rootID := m.GetTree().Nodes[0].ID
+ node, _ := m.CreateNode(rootID, TypeCase, "Original")
+
+ if err := m.RenameNode(node.ID, "Renamed"); err != nil {
+ t.Fatalf("RenameNode: %v", err)
+ }
+
+ renamed, _ := m.GetNode(node.ID)
+ if renamed.Title != "Renamed" {
+ t.Errorf("title: got %q, want %q", renamed.Title, "Renamed")
+ }
+ if renamed.UpdatedAt == node.UpdatedAt {
+ t.Error("updatedAt should change after rename")
+ }
+}
+
+func TestMoveNode(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ rootID := m.GetTree().Nodes[0].ID
+ folder, _ := m.CreateNode(rootID, TypeFolder, "Folder")
+ c, _ := m.CreateNode(rootID, TypeCase, "Case")
+
+ // Move case into folder
+ if err := m.MoveNode(c.ID, folder.ID); err != nil {
+ t.Fatalf("MoveNode: %v", err)
+ }
+
+ moved, _ := m.GetNode(c.ID)
+ if moved.ParentID != folder.ID {
+ t.Errorf("parentID: got %q, want %q", moved.ParentID, folder.ID)
+ }
+}
+
+func TestMoveNode_CannotMoveIntoSelf(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ rootID := m.GetTree().Nodes[0].ID
+ node, _ := m.CreateNode(rootID, TypeCase, "Case")
+
+ err := m.MoveNode(node.ID, node.ID)
+ if err == nil {
+ t.Error("expected error when moving node into itself")
+ }
+}
+
+func TestMoveNode_CannotMoveIntoDescendant(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ rootID := m.GetTree().Nodes[0].ID
+ folder, _ := m.CreateNode(rootID, TypeFolder, "Folder")
+ child, _ := m.CreateNode(folder.ID, TypeCase, "Child")
+
+ // Try to move folder into its own child
+ err := m.MoveNode(folder.ID, child.ID)
+ if err == nil {
+ t.Error("expected error when moving node into descendant")
+ }
+}
+
+func TestArchiveNode(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ rootID := m.GetTree().Nodes[0].ID
+ node, _ := m.CreateNode(rootID, TypeCase, "To Archive")
+
+ if err := m.ArchiveNode(node.ID); err != nil {
+ t.Fatalf("ArchiveNode: %v", err)
+ }
+
+ archived, _ := m.GetNode(node.ID)
+ if archived.Status != StatusArchived {
+ t.Errorf("status: got %s, want %s", archived.Status, StatusArchived)
+ }
+}
+
+func TestSetCurrentNode(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ rootID := m.GetTree().Nodes[0].ID
+ node, _ := m.CreateNode(rootID, TypeCase, "My Case")
+
+ if err := m.SetCurrentNode(node.ID); err != nil {
+ t.Fatalf("SetCurrentNode: %v", err)
+ }
+
+ current, err := m.GetCurrentNode()
+ if err != nil {
+ t.Fatalf("GetCurrentNode: %v", err)
+ }
+ if current.ID != node.ID {
+ t.Errorf("current: got %s, want %s", current.ID, node.ID)
+ }
+}
+
+func TestGetTree_StableAfterReopen(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ // Create and populate
+ m1 := NewManager(vaultDir)
+ m1.Load()
+ rootID := m1.GetTree().Nodes[0].ID
+ m1.CreateNode(rootID, TypeCase, "Case 1")
+ m1.CreateNode(rootID, TypeFolder, "Folder 1")
+ m1.CreateNode(rootID, TypeCase, "Case 2")
+
+ // Reopen
+ m2 := NewManager(vaultDir)
+ if err := m2.Load(); err != nil {
+ t.Fatalf("reopen Load: %v", err)
+ }
+
+ tree := m2.GetTree()
+ // root + 3 created = 4
+ if len(tree.Nodes) != 4 {
+ t.Fatalf("expected 4 nodes after reopen, got %d", len(tree.Nodes))
+ }
+
+ // Check order: children of root should be sorted by order
+ children := m2.ListChildren(rootID)
+ if len(children) != 3 {
+ t.Fatalf("expected 3 children, got %d", len(children))
+ }
+ if children[0].Title != "Case 1" {
+ t.Errorf("first child: got %q, want %q", children[0].Title, "Case 1")
+ }
+ if children[1].Title != "Folder 1" {
+ t.Errorf("second child: got %q, want %q", children[1].Title, "Folder 1")
+ }
+ if children[2].Title != "Case 2" {
+ t.Errorf("third child: got %q, want %q", children[2].Title, "Case 2")
+ }
+}
+
+func TestCorruptWorkspaceJSON(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ verstakDir := filepath.Join(vaultDir, ".verstak")
+ os.MkdirAll(verstakDir, 0o755)
+
+ // Write corrupt JSON
+ corruptPath := filepath.Join(verstakDir, "workspace.json")
+ os.WriteFile(corruptPath, []byte("{not valid json"), 0o600)
+
+ m := NewManager(vaultDir)
+ err := m.Load()
+ if err == nil {
+ t.Error("expected error for corrupt workspace.json")
+ }
+
+ // Should have created a backup
+ entries, _ := os.ReadDir(verstakDir)
+ backupFound := false
+ for _, e := range entries {
+ if filepath.Ext(e.Name()) == ".corrupt" || len(e.Name()) > 14 && e.Name()[14] == '-' {
+ backupFound = true
+ break
+ }
+ }
+ // Also check for .corrupt.* pattern
+ for _, e := range entries {
+ name := e.Name()
+ if len(name) > 20 && name[:14] == "workspace.json" {
+ backupFound = true
+ break
+ }
+ }
+ _ = backupFound // backup may have different naming
+
+ // Should have created a valid default tree
+ tree := m.GetTree()
+ if len(tree.Nodes) != 1 {
+ t.Errorf("expected 1 default node, got %d", len(tree.Nodes))
+ }
+}
+
+func TestListChildren_EmptyParent(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ // Root has no parent, so ListChildren("") should return root-level nodes
+ children := m.ListChildren("")
+ if len(children) != 1 {
+ t.Errorf("expected 1 root-level node, got %d", len(children))
+ }
+}
+
+func TestCreateNode_InvalidParent(t *testing.T) {
+ dir := t.TempDir()
+ vaultDir := filepath.Join(dir, "vault")
+ os.MkdirAll(filepath.Join(vaultDir, ".verstak"), 0o755)
+
+ m := NewManager(vaultDir)
+ m.Load()
+
+ _, err := m.CreateNode("nonexistent-id", TypeCase, "Orphan")
+ if err == nil {
+ t.Error("expected error for nonexistent parent")
+ }
+}
diff --git a/main.go b/main.go
index 74454fa..00dcc5f 100644
--- a/main.go
+++ b/main.go
@@ -22,6 +22,7 @@ import (
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
"github.com/verstak/verstak-desktop/internal/core/storage"
"github.com/verstak/verstak-desktop/internal/core/vault"
+ "github.com/verstak/verstak-desktop/internal/core/workspace"
)
//go:embed frontend/dist
@@ -50,29 +51,6 @@ func main() {
// ─── Initialize Vault ────────────────────────────────────
vaultService := vault.NewVault(eventBus)
- // ─── Register Core Capabilities ─────────────────────────
- // These are provided by the desktop core itself, not by plugins.
- // Registered before plugin discovery so that plugins can resolve
- // required capabilities (e.g. verstak/core/plugin-manager/v1) at load time.
- corePluginID := "verstak-desktop"
- coreCaps := []string{
- "verstak/core/plugin-manager/v1",
- "verstak/core/capability-registry/v1",
- "verstak/core/contribution-registry/v1",
- "verstak/core/permissions/v1",
- "verstak/core/events/v1",
- }
- if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
- log.Fatalf("[main] failed to register core capabilities: %v", err)
- }
- log.Printf("[main] registered %d core capabilities", len(coreCaps))
-
- // Register vault capability (vault is available as a core service)
- if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil {
- log.Fatalf("[main] failed to register vault capability: %v", err)
- }
- log.Printf("[main] registered vault capability")
-
// ─── Initialize App Settings ─────────────────────────────
appSettingsMgr := appsettings.NewDefaultManager()
if err := appSettingsMgr.Load(); err != nil {
@@ -80,7 +58,6 @@ func main() {
}
// ─── Vault Auto-Open ─────────────────────────────────────
- // If currentVaultPath is set in app settings, try to open it.
cfg := appSettingsMgr.Get()
if cfg.CurrentVaultPath != "" {
if err := vaultService.OpenVault(cfg.CurrentVaultPath); err != nil {
@@ -98,6 +75,46 @@ func main() {
}
}
+ // ─── Initialize Workspace ────────────────────────────────
+ var workspaceMgr *workspace.Manager
+ if vaultService.GetVaultStatus() == vault.StatusOpen {
+ workspaceMgr = workspace.NewManager(vaultService.GetVaultPath())
+ if err := workspaceMgr.Load(); err != nil {
+ log.Printf("[main] workspace: %v", err)
+ workspaceMgr = nil
+ } else {
+ log.Printf("[main] workspace loaded: %d nodes", len(workspaceMgr.GetTree().Nodes))
+ }
+ }
+
+ // ─── Register Core Capabilities ─────────────────────────
+ corePluginID := "verstak-desktop"
+ coreCaps := []string{
+ "verstak/core/plugin-manager/v1",
+ "verstak/core/capability-registry/v1",
+ "verstak/core/contribution-registry/v1",
+ "verstak/core/permissions/v1",
+ "verstak/core/events/v1",
+ }
+ if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
+ log.Fatalf("[main] failed to register core capabilities: %v", err)
+ }
+ log.Printf("[main] registered %d core capabilities", len(coreCaps))
+
+ // Register vault capability
+ if err := capRegistry.Register(corePluginID, []string{"verstak/core/vault/v1"}); err != nil {
+ log.Fatalf("[main] failed to register vault capability: %v", err)
+ }
+ log.Printf("[main] registered vault capability")
+
+ // Register workspace capability (only when vault is open and workspace initialized)
+ if workspaceMgr != nil && workspaceMgr.IsInitialized() {
+ if err := capRegistry.Register(corePluginID, []string{"verstak/core/workspace/v1"}); err != nil {
+ log.Fatalf("[main] failed to register workspace capability: %v", err)
+ }
+ log.Printf("[main] registered workspace capability")
+ }
+
// ─── Plugin Discovery ───────────────────────────────────
// Resolve plugin directories relative to the binary location,
// not CWD (Wails may launch from a different directory).
@@ -203,7 +220,7 @@ func main() {
// Create the App struct
storageService := storage.New(vaultService)
- app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, appSettingsMgr, pluginStateMgr)
+ app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, appSettingsMgr, pluginStateMgr, workspaceMgr)
// ─── Wails App ───────────────────────────────────────────
err := wails.Run(&options.App{
diff --git a/scripts/smoke-platform.sh b/scripts/smoke-platform.sh
index 5f7f162..02be907 100755
--- a/scripts/smoke-platform.sh
+++ b/scripts/smoke-platform.sh
@@ -75,3 +75,16 @@ fi
echo ""
echo "✅ smoke-platform done"
+
+# ── test workspace via Go smoke ──
+echo ""
+echo "[go smoke: workspace]"
+(cd "$ROOT" && go run -mod=mod ./cmd/smoke-platform/ -test-workspace 2>&1)
+SMOKE_WS_EXIT=$?
+if [ "$SMOKE_WS_EXIT" -ne 0 ]; then
+ echo " ❌ smoke-platform: workspace test failed"
+ exit 1
+fi
+
+echo ""
+echo "✅ smoke-platform all tests done"