From 5280d245eeac272aad081dbd65f0d49957e49ae1 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sun, 12 Jul 2026 17:06:34 +0800 Subject: [PATCH] feat: add durable workspace identities --- frontend/wailsjs/go/api/App.d.ts | 4 + frontend/wailsjs/go/api/App.js | 8 ++ frontend/wailsjs/go/models.ts | 23 ++++ internal/api/app.go | 30 +++++ internal/api/app_test.go | 35 ++++++ internal/core/workspace/manager.go | 153 +++++++++++++++++++++++- internal/core/workspace/manager_test.go | 120 +++++++++++++++++++ 7 files changed, 370 insertions(+), 3 deletions(-) diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index a3fb6fe..c9f04e5 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -74,6 +74,8 @@ export function ListVaultTrash(arg1:string):Promise|stri export function ListWorkspaces():Promise|string>; +export function ListWorkspaceIdentities():Promise|string>; + export function ListWorkspaceTemplates():Promise|string>; export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise; @@ -136,6 +138,8 @@ export function ReloadPlugins():Promise; export function RenameWorkspace(arg1:string,arg2:string):Promise; +export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise; + export function RenameWorkspaceNode(arg1:string,arg2:string):Promise; export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise; diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js index 247f555..f7aeb93 100755 --- a/frontend/wailsjs/go/api/App.js +++ b/frontend/wailsjs/go/api/App.js @@ -134,6 +134,10 @@ export function ListWorkspaces() { return window['go']['api']['App']['ListWorkspaces'](); } +export function ListWorkspaceIdentities() { + return window['go']['api']['App']['ListWorkspaceIdentities'](); +} + export function ListWorkspaceTemplates() { return window['go']['api']['App']['ListWorkspaceTemplates'](); } @@ -258,6 +262,10 @@ export function RenameWorkspace(arg1, arg2) { return window['go']['api']['App']['RenameWorkspace'](arg1, arg2); } +export function RepairWorkspaceIdentity(arg1, arg2) { + return window['go']['api']['App']['RepairWorkspaceIdentity'](arg1, arg2); +} + export function RenameWorkspaceNode(arg1, arg2) { return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 527a865..aee81c1 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1272,6 +1272,7 @@ export namespace workspace { } } export class Metadata { + workspaceId?: string; workspaceName: string; createdFromTemplate?: TemplateSnapshot; features?: Record; @@ -1285,6 +1286,7 @@ export namespace workspace { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); + this.workspaceId = source["workspaceId"]; this.workspaceName = source["workspaceName"]; this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot); this.features = source["features"]; @@ -1327,6 +1329,7 @@ export namespace workspace { } export class TrashResult { + workspaceId: string; originalPath: string; trashPath: string; trashId: string; @@ -1338,6 +1341,7 @@ export namespace workspace { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); + this.workspaceId = source["workspaceId"]; this.originalPath = source["originalPath"]; this.trashPath = source["trashPath"]; this.trashId = source["trashId"]; @@ -1345,6 +1349,7 @@ export namespace workspace { } } export class Workspace { + id: string; name: string; rootPath: string; @@ -1354,9 +1359,27 @@ export namespace workspace { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; this.name = source["name"]; this.rootPath = source["rootPath"]; } } + export class WorkspaceIdentity { + workspaceId: string; + rootPath: string; + state: string; + + static createFrom(source: any = {}) { + return new WorkspaceIdentity(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.workspaceId = source["workspaceId"]; + this.rootPath = source["rootPath"]; + this.state = source["state"]; + } + } + } diff --git a/internal/api/app.go b/internal/api/app.go index 8dcb4bd..94fd336 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -1950,6 +1950,29 @@ func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) { return a.workspace.ListWorkspaceTemplates(), "" } +// ListWorkspaceIdentities returns durable workspace identities for relation-aware plugins. +func (a *App) ListWorkspaceIdentities() ([]workspace.WorkspaceIdentity, string) { + if a.workspace == nil { + return nil, "workspace not initialized" + } + identities, err := a.workspace.ListWorkspaceIdentities() + if err != nil { + return nil, err.Error() + } + return identities, "" +} + +// RepairWorkspaceIdentity resolves a duplicated workspace marker without moving relations. +func (a *App) RepairWorkspaceIdentity(keepName, regenerateName string) string { + if a.workspace == nil { + return "workspace not initialized" + } + if err := a.workspace.RepairWorkspaceIdentity(keepName, regenerateName); err != nil { + return err.Error() + } + return "" +} + // CreateWorkspace creates a top-level physical workspace folder. func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) { if a.workspace == nil { @@ -1961,6 +1984,7 @@ func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, str } a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{ "operation": "create", + "workspaceId": ws.ID, "workspaceRootPath": ws.RootPath, "workspaceName": ws.Name, "templateId": templateID, @@ -1976,8 +2000,13 @@ func (a *App) RenameWorkspace(oldName, newName string) string { if err := a.workspace.RenameWorkspace(oldName, newName); err != nil { return err.Error() } + identity, err := a.workspace.GetWorkspaceIdentity(newName) + if err != nil { + return err.Error() + } a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{ "operation": "rename", + "workspaceId": identity.WorkspaceID, "workspaceRootPath": newName, "workspaceName": newName, "previousWorkspaceRootPath": oldName, @@ -1997,6 +2026,7 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) { } a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{ "operation": "trash", + "workspaceId": result.WorkspaceID, "workspaceRootPath": name, "workspaceName": name, "trashId": result.TrashID, diff --git a/internal/api/app_test.go b/internal/api/app_test.go index a5aa813..7a21c0b 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -1836,6 +1836,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) { if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" { t.Fatalf("workspace.created workspaceRootPath = %#v, want Project", got) } + createdID, _ := received["workspace.created"]["workspaceId"].(string) + if createdID == "" { + t.Fatal("workspace.created workspaceId is empty") + } if got := received["workspace.created"]["templateId"]; got != "client-project" { t.Fatalf("workspace.created templateId = %#v, want client-project", got) } @@ -1845,17 +1849,48 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) { if got := received["workspace.renamed"]["workspaceRootPath"]; got != "Renamed" { t.Fatalf("workspace.renamed workspaceRootPath = %#v, want Renamed", got) } + if got := received["workspace.renamed"]["workspaceId"]; got != createdID { + t.Fatalf("workspace.renamed workspaceId = %#v, want %q", got, createdID) + } if got := received["workspace.renamed"]["previousWorkspaceRootPath"]; got != "Project" { t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got) } if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" { t.Fatalf("workspace.trashed workspaceRootPath = %#v, want Renamed", got) } + if got := received["workspace.trashed"]["workspaceId"]; got != createdID { + t.Fatalf("workspace.trashed workspaceId = %#v, want %q", got, createdID) + } if got := received["workspace.trashed"]["trashPath"]; got == "" { t.Fatalf("workspace.trashed trashPath = %#v, want non-empty", got) } } +func TestWorkspaceIdentityAPIListsAndRepairsDuplicates(t *testing.T) { + app, vaultDir := newFilesTestApp(t, []string{"files.read"}) + app.workspace = workspace.NewManager(vaultDir) + if err := app.workspace.Load(); err != nil { + t.Fatal(err) + } + if _, errStr := app.CreateWorkspace("Original", "default"); errStr != "" { + t.Fatalf("CreateWorkspace: %s", errStr) + } + identities, errStr := app.ListWorkspaceIdentities() + if errStr != "" { + t.Fatalf("ListWorkspaceIdentities: %s", errStr) + } + var original workspace.WorkspaceIdentity + for _, identity := range identities { + if identity.RootPath == "Original" { + original = identity + break + } + } + if original.WorkspaceID == "" || original.State != "active" { + t.Fatalf("identities = %+v", identities) + } +} + func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) { app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app.workspace = workspace.NewManager(vaultDir) diff --git a/internal/core/workspace/manager.go b/internal/core/workspace/manager.go index c29c7a4..41ae554 100644 --- a/internal/core/workspace/manager.go +++ b/internal/core/workspace/manager.go @@ -40,6 +40,7 @@ const ( // Workspace is a physical top-level workspace folder. type Workspace struct { + ID string `json:"id"` Name string `json:"name"` RootPath string `json:"rootPath"` } @@ -66,6 +67,7 @@ type WorkspaceTemplate struct { // Metadata stores semantic workspace metadata that is not the source of truth // for whether the workspace exists. type Metadata struct { + WorkspaceID string `json:"workspaceId,omitempty"` WorkspaceName string `json:"workspaceName"` CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"` Features map[string]bool `json:"features,omitempty"` @@ -74,6 +76,12 @@ type Metadata struct { UpdatedAt string `json:"updatedAt,omitempty"` } +const workspaceIdentityRelativePath = ".verstak/workspace.json" + +type workspaceIdentityMarker struct { + WorkspaceID string `json:"workspaceId"` +} + // MetadataPatch updates metadata fields without replacing unspecified fields. type MetadataPatch struct { Features map[string]bool `json:"features,omitempty"` @@ -82,12 +90,20 @@ type MetadataPatch struct { // TrashResult describes a workspace moved into the internal trash area. type TrashResult struct { + WorkspaceID string `json:"workspaceId"` OriginalPath string `json:"originalPath"` TrashPath string `json:"trashPath"` TrashID string `json:"trashId"` DeletedAt string `json:"deletedAt"` } +// WorkspaceIdentity identifies an existing top-level workspace independently of its path. +type WorkspaceIdentity struct { + WorkspaceID string `json:"workspaceId"` + RootPath string `json:"rootPath"` + State string `json:"state"` +} + // WorkspaceNode is a compatibility shell view of a top-level workspace. // Path is deliberately not serialized; workspaceRootPath is derived from Name/ID. type WorkspaceNode struct { @@ -301,7 +317,11 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) { if !entry.IsDir() { continue } - workspaces = append(workspaces, Workspace{Name: name, RootPath: name}) + workspaceID, err := ensureWorkspaceIdentity(filepath.Join(m.vaultDir, name)) + if err != nil { + return nil, err + } + workspaces = append(workspaces, Workspace{ID: workspaceID, Name: name, RootPath: name}) } sort.Slice(workspaces, func(i, j int) bool { return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name) @@ -309,6 +329,84 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) { return workspaces, nil } +// GetWorkspaceIdentity resolves the durable identity for an existing workspace. +func (m *Manager) GetWorkspaceIdentity(name string) (WorkspaceIdentity, error) { + name = strings.TrimSpace(name) + if err := validateWorkspaceName(name); err != nil { + return WorkspaceIdentity{}, err + } + full := filepath.Join(m.vaultDir, name) + if err := ensureExistingWorkspaceDir(full, name); err != nil { + return WorkspaceIdentity{}, err + } + workspaceID, err := ensureWorkspaceIdentity(full) + if err != nil { + return WorkspaceIdentity{}, err + } + return WorkspaceIdentity{WorkspaceID: workspaceID, RootPath: name, State: "active"}, nil +} + +// ListWorkspaceIdentities returns durable identities and flags copied markers. +func (m *Manager) ListWorkspaceIdentities() ([]WorkspaceIdentity, error) { + workspaces, err := m.ListWorkspaces() + if err != nil { + return nil, err + } + counts := make(map[string]int, len(workspaces)) + for _, workspace := range workspaces { + counts[workspace.ID]++ + } + identities := make([]WorkspaceIdentity, 0, len(workspaces)) + for _, workspace := range workspaces { + state := "active" + if counts[workspace.ID] > 1 { + state = "duplicate" + } + identities = append(identities, WorkspaceIdentity{ + WorkspaceID: workspace.ID, + RootPath: workspace.RootPath, + State: state, + }) + } + return identities, nil +} + +// RepairWorkspaceIdentity keeps the identity on one copied workspace and regenerates the other. +func (m *Manager) RepairWorkspaceIdentity(keepName, regenerateName string) error { + keepName = strings.TrimSpace(keepName) + regenerateName = strings.TrimSpace(regenerateName) + if err := validateWorkspaceName(keepName); err != nil { + return err + } + if err := validateWorkspaceName(regenerateName); err != nil { + return err + } + if keepName == regenerateName { + return fmt.Errorf("workspace identity repair requires two workspaces") + } + keepPath := filepath.Join(m.vaultDir, keepName) + regeneratePath := filepath.Join(m.vaultDir, regenerateName) + if err := ensureExistingWorkspaceDir(keepPath, keepName); err != nil { + return err + } + if err := ensureExistingWorkspaceDir(regeneratePath, regenerateName); err != nil { + return err + } + keepID, err := ensureWorkspaceIdentity(keepPath) + if err != nil { + return err + } + regenerateID, err := ensureWorkspaceIdentity(regeneratePath) + if err != nil { + return err + } + if keepID != regenerateID { + return fmt.Errorf("workspaces do not share an identity") + } + _, err = writeWorkspaceIdentity(regeneratePath) + return err +} + // CreateWorkspace creates a top-level workspace folder and applies a template once. func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { name = strings.TrimSpace(name) @@ -339,11 +437,16 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { } }() + workspaceID, err := ensureWorkspaceIdentity(full) + if err != nil { + return Workspace{}, err + } if err := applyTemplate(full, template); err != nil { return Workspace{}, err } now := time.Now().UTC().Format(time.RFC3339Nano) meta := Metadata{ + WorkspaceID: workspaceID, WorkspaceName: name, CreatedFromTemplate: &TemplateSnapshot{ TemplateID: template.ID, @@ -362,7 +465,47 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { } created = false - return Workspace{Name: name, RootPath: name}, nil + return Workspace{ID: workspaceID, Name: name, RootPath: name}, nil +} + +func writeWorkspaceIdentity(workspacePath string) (string, error) { + workspaceID := uuid.NewString() + data, err := json.Marshal(workspaceIdentityMarker{WorkspaceID: workspaceID}) + if err != nil { + return "", err + } + markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath)) + if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil { + return "", err + } + tmpPath := markerPath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o644); err != nil { + return "", err + } + if err := os.Rename(tmpPath, markerPath); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + return workspaceID, nil +} + +func ensureWorkspaceIdentity(workspacePath string) (string, error) { + markerPath := filepath.Join(workspacePath, filepath.FromSlash(workspaceIdentityRelativePath)) + data, err := os.ReadFile(markerPath) + if os.IsNotExist(err) { + return writeWorkspaceIdentity(workspacePath) + } + if err != nil { + return "", err + } + var marker workspaceIdentityMarker + if err := json.Unmarshal(data, &marker); err != nil { + return "", err + } + if _, err := uuid.Parse(marker.WorkspaceID); err != nil { + return "", fmt.Errorf("invalid workspace identity: %w", err) + } + return marker.WorkspaceID, nil } // ListWorkspaceTemplates returns selectable built-ins in their presentation order. @@ -456,6 +599,10 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) { if err := ensureExistingWorkspaceDir(full, name); err != nil { return TrashResult{}, err } + workspaceID, err := ensureWorkspaceIdentity(full) + if err != nil { + return TrashResult{}, err + } deletedAt := time.Now().UTC().Format(time.RFC3339Nano) trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString() @@ -468,7 +615,7 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) { return TrashResult{}, err } - result := TrashResult{OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt} + result := TrashResult{WorkspaceID: workspaceID, OriginalPath: name, TrashPath: trashRel, TrashID: trashID, DeletedAt: deletedAt} trashMeta := map[string]string{ "originalPath": name, "trashPath": trashRel, diff --git a/internal/core/workspace/manager_test.go b/internal/core/workspace/manager_test.go index d87902e..66b3c19 100644 --- a/internal/core/workspace/manager_test.go +++ b/internal/core/workspace/manager_test.go @@ -292,6 +292,126 @@ func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) { } } +func TestCreateWorkspaceWritesDurableIdentityMarker(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Client", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + markerPath := filepath.Join(vaultDir, "Client", ".verstak", "workspace.json") + data, err := os.ReadFile(markerPath) + if err != nil { + t.Fatalf("read identity marker: %v", err) + } + var marker map[string]string + if err := json.Unmarshal(data, &marker); err != nil { + t.Fatalf("decode identity marker: %v", err) + } + if marker["workspaceId"] == "" { + t.Fatalf("workspaceId = %q, want UUID", marker["workspaceId"]) + } +} + +func TestListWorkspacesKeepsIdentityAfterRename(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + created, err := m.CreateWorkspace("Client", "default") + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := m.RenameWorkspace("Client", "Client-2026"); err != nil { + t.Fatalf("RenameWorkspace: %v", err) + } + + workspaces, err := m.ListWorkspaces() + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(workspaces) != 1 { + t.Fatalf("workspaces = %+v, want one", workspaces) + } + if workspaces[0].Name != "Client-2026" { + t.Fatalf("workspace name = %q, want Client-2026", workspaces[0].Name) + } + if workspaces[0].ID != created.ID { + t.Fatalf("workspace ID = %q, want %q", workspaces[0].ID, created.ID) + } +} + +func TestListWorkspaceIdentitiesMarksCopiedMarkerAsDuplicate(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Original", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil { + t.Fatalf("mkdir copied: %v", err) + } + originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json") + copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json") + if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil { + t.Fatalf("mkdir copied marker: %v", err) + } + data, err := os.ReadFile(originalMarker) + if err != nil { + t.Fatalf("read original marker: %v", err) + } + if err := os.WriteFile(copiedMarker, data, 0o644); err != nil { + t.Fatalf("write copied marker: %v", err) + } + + identities, err := m.ListWorkspaceIdentities() + if err != nil { + t.Fatalf("ListWorkspaceIdentities: %v", err) + } + if len(identities) != 2 { + t.Fatalf("identities = %+v, want two", identities) + } + for _, identity := range identities { + if identity.State != "duplicate" { + t.Fatalf("identity %+v state = %q, want duplicate", identity, identity.State) + } + } +} + +func TestRepairWorkspaceIdentityRegeneratesCopiedMarker(t *testing.T) { + vaultDir := newVaultDir(t) + m := NewManager(vaultDir) + if _, err := m.CreateWorkspace("Original", "default"); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := os.Mkdir(filepath.Join(vaultDir, "Copied"), 0o755); err != nil { + t.Fatalf("mkdir copied: %v", err) + } + originalMarker := filepath.Join(vaultDir, "Original", ".verstak", "workspace.json") + copiedMarker := filepath.Join(vaultDir, "Copied", ".verstak", "workspace.json") + if err := os.MkdirAll(filepath.Dir(copiedMarker), 0o755); err != nil { + t.Fatalf("mkdir copied marker: %v", err) + } + data, err := os.ReadFile(originalMarker) + if err != nil { + t.Fatalf("read original marker: %v", err) + } + if err := os.WriteFile(copiedMarker, data, 0o644); err != nil { + t.Fatalf("write copied marker: %v", err) + } + + if err := m.RepairWorkspaceIdentity("Original", "Copied"); err != nil { + t.Fatalf("RepairWorkspaceIdentity: %v", err) + } + identities, err := m.ListWorkspaceIdentities() + if err != nil { + t.Fatalf("ListWorkspaceIdentities: %v", err) + } + if len(identities) != 2 || identities[0].State != "active" || identities[1].State != "active" { + t.Fatalf("identities after repair = %+v", identities) + } + if identities[0].WorkspaceID == identities[1].WorkspaceID { + t.Fatalf("repair kept duplicate ID %q", identities[0].WorkspaceID) + } +} + func TestCreateAndRenameConflictsAreExplicit(t *testing.T) { vaultDir := newVaultDir(t) mustMkdir(t, filepath.Join(vaultDir, "Existing"))