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 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>;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue