feat: add durable workspace identities
This commit is contained in:
parent
477b372110
commit
5280d245ee
|
|
@ -74,6 +74,8 @@ export function ListVaultTrash(arg1:string):Promise<Array<files.TrashEntry>|stri
|
|||
|
||||
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 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 RepairWorkspaceIdentity(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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1272,6 +1272,7 @@ export namespace workspace {
|
|||
}
|
||||
}
|
||||
export class Metadata {
|
||||
workspaceId?: string;
|
||||
workspaceName: string;
|
||||
createdFromTemplate?: TemplateSnapshot;
|
||||
features?: Record<string, boolean>;
|
||||
|
|
@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Reference in New Issue