feat: add durable workspace identities

This commit is contained in:
mirivlad 2026-07-12 17:06:34 +08:00
parent 477b372110
commit 5280d245ee
7 changed files with 370 additions and 3 deletions

View File

@ -74,6 +74,8 @@ export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|stri
export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>; export function ListWorkspaces():Promise<Array<workspace.Workspace>|string>;
export function ListWorkspaceIdentities():Promise<Array<workspace.WorkspaceIdentity>|string>;
export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>; export function ListWorkspaceTemplates():Promise<Array<workspace.WorkspaceTemplate>|string>;
export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>; export function MoveVaultPath(arg1:string,arg2:string,arg3:string,arg4:files.MoveOptions):Promise<string>;
@ -136,6 +138,8 @@ export function ReloadPlugins():Promise<number|string>;
export function RenameWorkspace(arg1:string,arg2:string):Promise<string>; export function RenameWorkspace(arg1:string,arg2:string):Promise<string>;
export function RepairWorkspaceIdentity(arg1:string,arg2:string):Promise<string>;
export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>; export function RenameWorkspaceNode(arg1:string,arg2:string):Promise<string>;
export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>; export function RestoreVaultTrash(arg1:string,arg2:string,arg3:files.RestoreOptions):Promise<string|string>;

View File

@ -134,6 +134,10 @@ export function ListWorkspaces() {
return window['go']['api']['App']['ListWorkspaces'](); return window['go']['api']['App']['ListWorkspaces']();
} }
export function ListWorkspaceIdentities() {
return window['go']['api']['App']['ListWorkspaceIdentities']();
}
export function ListWorkspaceTemplates() { export function ListWorkspaceTemplates() {
return window['go']['api']['App']['ListWorkspaceTemplates'](); return window['go']['api']['App']['ListWorkspaceTemplates']();
} }
@ -258,6 +262,10 @@ export function RenameWorkspace(arg1, arg2) {
return window['go']['api']['App']['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) { export function RenameWorkspaceNode(arg1, arg2) {
return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2); return window['go']['api']['App']['RenameWorkspaceNode'](arg1, arg2);
} }

View File

@ -1272,6 +1272,7 @@ export namespace workspace {
} }
} }
export class Metadata { export class Metadata {
workspaceId?: string;
workspaceName: string; workspaceName: string;
createdFromTemplate?: TemplateSnapshot; createdFromTemplate?: TemplateSnapshot;
features?: Record<string, boolean>; features?: Record<string, boolean>;
@ -1285,6 +1286,7 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.workspaceName = source["workspaceName"]; this.workspaceName = source["workspaceName"];
this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot); this.createdFromTemplate = this.convertValues(source["createdFromTemplate"], TemplateSnapshot);
this.features = source["features"]; this.features = source["features"];
@ -1327,6 +1329,7 @@ export namespace workspace {
} }
export class TrashResult { export class TrashResult {
workspaceId: string;
originalPath: string; originalPath: string;
trashPath: string; trashPath: string;
trashId: string; trashId: string;
@ -1338,6 +1341,7 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.workspaceId = source["workspaceId"];
this.originalPath = source["originalPath"]; this.originalPath = source["originalPath"];
this.trashPath = source["trashPath"]; this.trashPath = source["trashPath"];
this.trashId = source["trashId"]; this.trashId = source["trashId"];
@ -1345,6 +1349,7 @@ export namespace workspace {
} }
} }
export class Workspace { export class Workspace {
id: string;
name: string; name: string;
rootPath: string; rootPath: string;
@ -1354,9 +1359,27 @@ export namespace workspace {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"]; this.name = source["name"];
this.rootPath = source["rootPath"]; 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"];
}
}
} }

View File

@ -1950,6 +1950,29 @@ func (a *App) ListWorkspaceTemplates() ([]workspace.WorkspaceTemplate, string) {
return a.workspace.ListWorkspaceTemplates(), "" 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. // CreateWorkspace creates a top-level physical workspace folder.
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) { func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
if a.workspace == nil { if a.workspace == nil {
@ -1961,6 +1984,7 @@ func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, str
} }
a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{ a.publishWorkspaceLifecycleEvent(workspaceCreatedEventName, map[string]interface{}{
"operation": "create", "operation": "create",
"workspaceId": ws.ID,
"workspaceRootPath": ws.RootPath, "workspaceRootPath": ws.RootPath,
"workspaceName": ws.Name, "workspaceName": ws.Name,
"templateId": templateID, "templateId": templateID,
@ -1976,8 +2000,13 @@ func (a *App) RenameWorkspace(oldName, newName string) string {
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil { if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
return err.Error() return err.Error()
} }
identity, err := a.workspace.GetWorkspaceIdentity(newName)
if err != nil {
return err.Error()
}
a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{ a.publishWorkspaceLifecycleEvent(workspaceRenamedEventName, map[string]interface{}{
"operation": "rename", "operation": "rename",
"workspaceId": identity.WorkspaceID,
"workspaceRootPath": newName, "workspaceRootPath": newName,
"workspaceName": newName, "workspaceName": newName,
"previousWorkspaceRootPath": oldName, "previousWorkspaceRootPath": oldName,
@ -1997,6 +2026,7 @@ func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
} }
a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{ a.publishWorkspaceLifecycleEvent(workspaceTrashedEventName, map[string]interface{}{
"operation": "trash", "operation": "trash",
"workspaceId": result.WorkspaceID,
"workspaceRootPath": name, "workspaceRootPath": name,
"workspaceName": name, "workspaceName": name,
"trashId": result.TrashID, "trashId": result.TrashID,

View File

@ -1836,6 +1836,10 @@ func TestWorkspaceAPIPublishesLifecycleEvents(t *testing.T) {
if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" { if got := received["workspace.created"]["workspaceRootPath"]; got != "Project" {
t.Fatalf("workspace.created workspaceRootPath = %#v, want Project", got) 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" { if got := received["workspace.created"]["templateId"]; got != "client-project" {
t.Fatalf("workspace.created templateId = %#v, want client-project", got) 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" { if got := received["workspace.renamed"]["workspaceRootPath"]; got != "Renamed" {
t.Fatalf("workspace.renamed workspaceRootPath = %#v, want Renamed", got) 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" { if got := received["workspace.renamed"]["previousWorkspaceRootPath"]; got != "Project" {
t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got) t.Fatalf("workspace.renamed previousWorkspaceRootPath = %#v, want Project", got)
} }
if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" { if got := received["workspace.trashed"]["workspaceRootPath"]; got != "Renamed" {
t.Fatalf("workspace.trashed workspaceRootPath = %#v, want Renamed", got) 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 == "" { if got := received["workspace.trashed"]["trashPath"]; got == "" {
t.Fatalf("workspace.trashed trashPath = %#v, want non-empty", 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) { func TestMoveWorkspaceNodeCompatibilityIsUnsupported(t *testing.T) {
app, vaultDir := newFilesTestApp(t, []string{"files.read"}) app, vaultDir := newFilesTestApp(t, []string{"files.read"})
app.workspace = workspace.NewManager(vaultDir) app.workspace = workspace.NewManager(vaultDir)

View File

@ -40,6 +40,7 @@ const (
// Workspace is a physical top-level workspace folder. // Workspace is a physical top-level workspace folder.
type Workspace struct { type Workspace struct {
ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
RootPath string `json:"rootPath"` RootPath string `json:"rootPath"`
} }
@ -66,6 +67,7 @@ type WorkspaceTemplate struct {
// Metadata stores semantic workspace metadata that is not the source of truth // Metadata stores semantic workspace metadata that is not the source of truth
// for whether the workspace exists. // for whether the workspace exists.
type Metadata struct { type Metadata struct {
WorkspaceID string `json:"workspaceId,omitempty"`
WorkspaceName string `json:"workspaceName"` WorkspaceName string `json:"workspaceName"`
CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"` CreatedFromTemplate *TemplateSnapshot `json:"createdFromTemplate,omitempty"`
Features map[string]bool `json:"features,omitempty"` Features map[string]bool `json:"features,omitempty"`
@ -74,6 +76,12 @@ type Metadata struct {
UpdatedAt string `json:"updatedAt,omitempty"` 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. // MetadataPatch updates metadata fields without replacing unspecified fields.
type MetadataPatch struct { type MetadataPatch struct {
Features map[string]bool `json:"features,omitempty"` Features map[string]bool `json:"features,omitempty"`
@ -82,12 +90,20 @@ type MetadataPatch struct {
// TrashResult describes a workspace moved into the internal trash area. // TrashResult describes a workspace moved into the internal trash area.
type TrashResult struct { type TrashResult struct {
WorkspaceID string `json:"workspaceId"`
OriginalPath string `json:"originalPath"` OriginalPath string `json:"originalPath"`
TrashPath string `json:"trashPath"` TrashPath string `json:"trashPath"`
TrashID string `json:"trashId"` TrashID string `json:"trashId"`
DeletedAt string `json:"deletedAt"` 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. // WorkspaceNode is a compatibility shell view of a top-level workspace.
// Path is deliberately not serialized; workspaceRootPath is derived from Name/ID. // Path is deliberately not serialized; workspaceRootPath is derived from Name/ID.
type WorkspaceNode struct { type WorkspaceNode struct {
@ -301,7 +317,11 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
if !entry.IsDir() { if !entry.IsDir() {
continue 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 { sort.Slice(workspaces, func(i, j int) bool {
return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name) return strings.ToLower(workspaces[i].Name) < strings.ToLower(workspaces[j].Name)
@ -309,6 +329,84 @@ func (m *Manager) ListWorkspaces() ([]Workspace, error) {
return workspaces, nil 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. // CreateWorkspace creates a top-level workspace folder and applies a template once.
func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) { func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
name = strings.TrimSpace(name) 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 { if err := applyTemplate(full, template); err != nil {
return Workspace{}, err return Workspace{}, err
} }
now := time.Now().UTC().Format(time.RFC3339Nano) now := time.Now().UTC().Format(time.RFC3339Nano)
meta := Metadata{ meta := Metadata{
WorkspaceID: workspaceID,
WorkspaceName: name, WorkspaceName: name,
CreatedFromTemplate: &TemplateSnapshot{ CreatedFromTemplate: &TemplateSnapshot{
TemplateID: template.ID, TemplateID: template.ID,
@ -362,7 +465,47 @@ func (m *Manager) CreateWorkspace(name, templateID string) (Workspace, error) {
} }
created = false 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. // 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 { if err := ensureExistingWorkspaceDir(full, name); err != nil {
return TrashResult{}, err return TrashResult{}, err
} }
workspaceID, err := ensureWorkspaceIdentity(full)
if err != nil {
return TrashResult{}, err
}
deletedAt := time.Now().UTC().Format(time.RFC3339Nano) deletedAt := time.Now().UTC().Format(time.RFC3339Nano)
trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString() trashID := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + uuid.NewString()
@ -468,7 +615,7 @@ func (m *Manager) TrashWorkspace(name string) (TrashResult, error) {
return TrashResult{}, err 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{ trashMeta := map[string]string{
"originalPath": name, "originalPath": name,
"trashPath": trashRel, "trashPath": trashRel,

View File

@ -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) { func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
vaultDir := newVaultDir(t) vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Existing")) mustMkdir(t, filepath.Join(vaultDir, "Existing"))