mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
Expose the runtime catalog and carry an explicit runtime ID through workspace creation while preserving omitted-field compatibility for older clients. The runtime POC made useGitActions require WorkspaceGitBoundary, but Command Center mounted that consumer without the boundary and crashed. Bind the active workspace at that consumer. Keep provisional Worktree reservations unpublished until final placement so observers never bind the source checkout as the new workspace. Gate legacy cwd Git compatibility by hostVisiblePath instead of runtime kind because Local and Worktree remain host-visible runtimes.
611 lines
21 KiB
TypeScript
611 lines
21 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
|
|
|
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
|
|
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
|
import {
|
|
createPersistedProjectRecord,
|
|
createPersistedWorkspaceRecord,
|
|
FileBackedProjectRegistry,
|
|
FileBackedWorkspaceRegistry,
|
|
resolveWorkspaceDisplayName,
|
|
resolveWorkspaceName,
|
|
resolveSelectedWorkspaceRuntimeId,
|
|
} from "./workspace-registry.js";
|
|
|
|
describe("resolveWorkspaceName", () => {
|
|
test("prefers the user-set title over the derived display name", () => {
|
|
expect(
|
|
resolveWorkspaceName({ title: "Payments work", derivedDisplayName: "feature/payments" }),
|
|
).toBe("Payments work");
|
|
});
|
|
|
|
test("falls back to the derived display name when there is no title", () => {
|
|
expect(resolveWorkspaceName({ title: null, derivedDisplayName: "feature/payments" })).toBe(
|
|
"feature/payments",
|
|
);
|
|
});
|
|
|
|
test("resolveWorkspaceDisplayName applies the same rule over the persisted record", () => {
|
|
const record = createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: "proj-1",
|
|
cwd: "/tmp/repo",
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
title: "Renamed",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
expect(resolveWorkspaceDisplayName(record)).toBe("Renamed");
|
|
expect(resolveWorkspaceDisplayName({ ...record, title: null })).toBe("main");
|
|
});
|
|
});
|
|
|
|
describe("workspace runtime selection compatibility", () => {
|
|
test.each(["local_checkout", "directory", "worktree"] as const)(
|
|
"keeps an existing %s record on its legacy path until explicit cutover",
|
|
(kind) => {
|
|
const existing = createPersistedWorkspaceRecord({
|
|
workspaceId: `legacy-${kind}`,
|
|
projectId: "legacy-project",
|
|
cwd: "/tmp/legacy",
|
|
kind,
|
|
displayName: "legacy",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
expect(resolveSelectedWorkspaceRuntimeId(existing)).toBeNull();
|
|
expect(
|
|
resolveSelectedWorkspaceRuntimeId({ ...existing, runtime: { runtimeId: "selected" } }),
|
|
).toBe("selected");
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("workspace registries", () => {
|
|
let tmpDir: string;
|
|
let projectRegistry: FileBackedProjectRegistry;
|
|
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
|
const logger = createTestLogger();
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-registry-"));
|
|
projectRegistry = new FileBackedProjectRegistry(
|
|
path.join(tmpDir, "projects", "projects.json"),
|
|
logger,
|
|
);
|
|
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
|
path.join(tmpDir, "projects", "workspaces.json"),
|
|
logger,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("creates, updates, archives, deletes, and lists project records", async () => {
|
|
await projectRegistry.initialize();
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "remote:github.com/acme/repo",
|
|
rootPath: "/tmp/repo",
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "remote:github.com/acme/repo",
|
|
rootPath: "/tmp/repo",
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
}),
|
|
);
|
|
await projectRegistry.archive("remote:github.com/acme/repo", "2026-03-03T00:00:00.000Z");
|
|
|
|
const archived = await projectRegistry.get("remote:github.com/acme/repo");
|
|
expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z");
|
|
expect(await projectRegistry.list()).toHaveLength(1);
|
|
|
|
await projectRegistry.remove("remote:github.com/acme/repo");
|
|
expect(await projectRegistry.get("remote:github.com/acme/repo")).toBeNull();
|
|
expect(await projectRegistry.list()).toEqual([]);
|
|
});
|
|
|
|
test("publishes only project mutations that change the persisted lifecycle", async () => {
|
|
await projectRegistry.initialize();
|
|
const mutations: Array<{
|
|
kind: "upsert" | "archive" | "remove";
|
|
projectId: string;
|
|
project: ReturnType<typeof createPersistedProjectRecord> | null;
|
|
}> = [];
|
|
const unsubscribe = projectRegistry.subscribeToMutations((mutation) => {
|
|
mutations.push(mutation);
|
|
});
|
|
const active = createPersistedProjectRecord({
|
|
projectId: "project-one",
|
|
rootPath: "/tmp/project-one",
|
|
kind: "non_git",
|
|
displayName: "project-one",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
const archived = {
|
|
...active,
|
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
archivedAt: "2026-03-02T00:00:00.000Z",
|
|
};
|
|
|
|
await projectRegistry.upsert(active);
|
|
await projectRegistry.archive(active.projectId, archived.archivedAt);
|
|
await projectRegistry.archive(active.projectId, "2026-03-03T00:00:00.000Z");
|
|
await projectRegistry.archive("project-unknown", "2026-03-03T00:00:00.000Z");
|
|
await projectRegistry.remove(active.projectId);
|
|
await projectRegistry.remove(active.projectId);
|
|
await projectRegistry.remove("project-unknown");
|
|
|
|
expect(mutations).toEqual([
|
|
{ kind: "upsert", projectId: active.projectId, project: active },
|
|
{ kind: "archive", projectId: active.projectId, project: archived },
|
|
{ kind: "remove", projectId: active.projectId, project: null },
|
|
]);
|
|
unsubscribe();
|
|
});
|
|
|
|
test("atomically allocates one opaque project for concurrent exact-root adds", async () => {
|
|
await projectRegistry.initialize();
|
|
const rootPath = path.join(tmpDir, "same-root");
|
|
const projects = await Promise.all(
|
|
Array.from({ length: 20 }, () =>
|
|
projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath,
|
|
kind: "non_git",
|
|
displayName: "same-root",
|
|
timestamp: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(new Set(projects.map((project) => project.projectId))).toEqual(
|
|
new Set([projects[0]!.projectId]),
|
|
);
|
|
expect(projects[0]!.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
|
expect(await projectRegistry.list()).toHaveLength(1);
|
|
});
|
|
|
|
test("keeps readable legacy IDs alongside newly allocated opaque IDs", async () => {
|
|
await projectRegistry.initialize();
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "remote:github.com/acme/repo",
|
|
rootPath: "/tmp/legacy",
|
|
kind: "git",
|
|
displayName: "repo",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
const opaque = await projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath: "/tmp/new",
|
|
kind: "non_git",
|
|
displayName: "new",
|
|
timestamp: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
expect((await projectRegistry.get("remote:github.com/acme/repo"))?.rootPath).toBe(
|
|
"/tmp/legacy",
|
|
);
|
|
expect(opaque.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
|
|
});
|
|
|
|
test("allocates a fresh opaque ID when only an archived exact root exists", async () => {
|
|
await projectRegistry.initialize();
|
|
const rootPath = path.join(tmpDir, "archived-root");
|
|
const archived = createPersistedProjectRecord({
|
|
projectId: "prj_archived",
|
|
rootPath,
|
|
kind: "non_git",
|
|
displayName: "archived-root",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
archivedAt: "2026-03-02T00:00:00.000Z",
|
|
});
|
|
await projectRegistry.upsert(archived);
|
|
|
|
const created = await projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath,
|
|
kind: "non_git",
|
|
displayName: "archived-root",
|
|
timestamp: "2026-03-03T00:00:00.000Z",
|
|
});
|
|
|
|
expect(created).toMatchObject({ rootPath, archivedAt: null });
|
|
expect(created.projectId).not.toBe(archived.projectId);
|
|
expect(await projectRegistry.get(archived.projectId)).toEqual(archived);
|
|
});
|
|
|
|
test("refreshes the oldest active legacy duplicate kind without rewriting its identity", async () => {
|
|
await projectRegistry.initialize();
|
|
const rootPath = path.join(tmpDir, "legacy-root");
|
|
const oldest = createPersistedProjectRecord({
|
|
projectId: "remote:oldest",
|
|
rootPath,
|
|
kind: "git",
|
|
displayName: "oldest",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
const duplicate = createPersistedProjectRecord({
|
|
projectId: "remote:duplicate",
|
|
rootPath,
|
|
kind: "git",
|
|
displayName: "duplicate",
|
|
createdAt: "2026-03-02T00:00:00.000Z",
|
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
});
|
|
await projectRegistry.upsert(oldest);
|
|
await projectRegistry.upsert(duplicate);
|
|
|
|
await expect(
|
|
projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath,
|
|
kind: "non_git",
|
|
displayName: "new-name",
|
|
timestamp: "2026-03-03T00:00:00.000Z",
|
|
}),
|
|
).resolves.toEqual({
|
|
...oldest,
|
|
kind: "non_git",
|
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
|
});
|
|
expect(await projectRegistry.list()).toEqual([
|
|
{ ...oldest, kind: "non_git", updatedAt: "2026-03-03T00:00:00.000Z" },
|
|
duplicate,
|
|
]);
|
|
});
|
|
|
|
test("reuses an active project for Windows lexical-equivalent root spellings", async () => {
|
|
await projectRegistry.initialize();
|
|
const first = await projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath: "C:\\Users\\Paseo\\Repo",
|
|
kind: "git",
|
|
displayName: "Repo",
|
|
timestamp: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
const second = await projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath: "c:/users/paseo/repo/.",
|
|
kind: "git",
|
|
displayName: "Repo",
|
|
timestamp: "2026-03-02T00:00:00.000Z",
|
|
});
|
|
|
|
expect(second).toEqual(first);
|
|
expect(await projectRegistry.list()).toEqual([first]);
|
|
});
|
|
|
|
test("keeps lexical and symlink root spellings distinct without realpath", async () => {
|
|
await projectRegistry.initialize();
|
|
const target = path.join(tmpDir, "target");
|
|
const link = path.join(tmpDir, "link");
|
|
mkdirSync(target);
|
|
symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
|
|
const targetProject = await projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath: target,
|
|
kind: "non_git",
|
|
displayName: "target",
|
|
timestamp: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
const linkProject = await projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath: link,
|
|
kind: "non_git",
|
|
displayName: "link",
|
|
timestamp: "2026-03-02T00:00:00.000Z",
|
|
});
|
|
|
|
expect(linkProject.projectId).not.toBe(targetProject.projectId);
|
|
expect(await projectRegistry.list()).toEqual([targetProject, linkProject]);
|
|
});
|
|
|
|
test("retries a generated project ID collision", async () => {
|
|
const generatedIds = ["prj_collision", "prj_fresh"];
|
|
projectRegistry = new FileBackedProjectRegistry(
|
|
path.join(tmpDir, "projects", "projects.json"),
|
|
logger,
|
|
{ projectIdFactory: () => generatedIds.shift() ?? "prj_unexpected" },
|
|
);
|
|
await projectRegistry.initialize();
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "prj_collision",
|
|
rootPath: path.join(tmpDir, "existing"),
|
|
kind: "non_git",
|
|
displayName: "existing",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const created = await projectRegistry.getOrCreateActiveByRoot({
|
|
rootPath: path.join(tmpDir, "new"),
|
|
kind: "non_git",
|
|
displayName: "new",
|
|
timestamp: "2026-03-02T00:00:00.000Z",
|
|
});
|
|
|
|
expect(created.projectId).toBe("prj_fresh");
|
|
expect(await projectRegistry.list()).toHaveLength(2);
|
|
});
|
|
|
|
test("project record schema accepts records without customName (legacy on-disk records)", async () => {
|
|
await projectRegistry.initialize();
|
|
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "remote:github.com/acme/repo",
|
|
rootPath: "/tmp/repo",
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const record = await projectRegistry.get("remote:github.com/acme/repo");
|
|
expect(record?.customName).toBeNull();
|
|
});
|
|
|
|
test("project record persists a customName override", async () => {
|
|
await projectRegistry.initialize();
|
|
|
|
await projectRegistry.upsert(
|
|
createPersistedProjectRecord({
|
|
projectId: "remote:github.com/acme/repo",
|
|
rootPath: "/home/me/work/repo",
|
|
kind: "git",
|
|
displayName: "acme/repo",
|
|
customName: "Acme (work)",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
const record = await projectRegistry.get("remote:github.com/acme/repo");
|
|
expect(record?.customName).toBe("Acme (work)");
|
|
expect(record?.displayName).toBe("acme/repo");
|
|
});
|
|
|
|
test("creates, updates, archives, deletes, and lists workspace records", async () => {
|
|
await workspaceRegistry.initialize();
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "/tmp/repo",
|
|
projectId: "remote:github.com/acme/repo",
|
|
cwd: "/tmp/repo",
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "/tmp/repo",
|
|
projectId: "remote:github.com/acme/repo",
|
|
cwd: "/tmp/repo",
|
|
kind: "local_checkout",
|
|
displayName: "feature/workspace",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
}),
|
|
);
|
|
await workspaceRegistry.archive("/tmp/repo", "2026-03-03T00:00:00.000Z");
|
|
|
|
const archived = await workspaceRegistry.get("/tmp/repo");
|
|
expect(archived?.displayName).toBe("feature/workspace");
|
|
expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z");
|
|
|
|
await workspaceRegistry.remove("/tmp/repo");
|
|
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
|
|
expect(await workspaceRegistry.list()).toEqual([]);
|
|
});
|
|
|
|
test("keeps a runtime reservation out of listings until its final placement is published", async () => {
|
|
await workspaceRegistry.initialize();
|
|
const reserved = createPersistedWorkspaceRecord({
|
|
workspaceId: "reserved-worktree",
|
|
projectId: "runtime-project",
|
|
cwd: "/tmp/source",
|
|
kind: "worktree",
|
|
displayName: "feature/runtime",
|
|
runtime: { runtimeId: "worktree" },
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
const mutations: unknown[] = [];
|
|
workspaceRegistry.subscribeToMutations((mutation) => mutations.push(mutation));
|
|
|
|
await workspaceRegistry.upsert(reserved, { provisional: true });
|
|
|
|
expect(await workspaceRegistry.get(reserved.workspaceId)).toEqual(reserved);
|
|
expect(await workspaceRegistry.list()).toEqual([]);
|
|
expect(mutations).toEqual([
|
|
expect.objectContaining({ workspaceId: reserved.workspaceId, provisional: true }),
|
|
]);
|
|
|
|
const published = await workspaceRegistry.update(reserved.workspaceId, (workspace) => ({
|
|
...workspace,
|
|
cwd: "/tmp/worktree",
|
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
}));
|
|
|
|
expect(await workspaceRegistry.list()).toEqual([published]);
|
|
expect(mutations).toHaveLength(2);
|
|
expect(mutations[1]).toEqual(
|
|
expect.objectContaining({
|
|
workspaceId: reserved.workspaceId,
|
|
workspace: expect.objectContaining({ cwd: "/tmp/worktree" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("keeps the committed workspace snapshot when a removal cannot be persisted", async () => {
|
|
const registryPath = path.join(tmpDir, "projects", "workspaces.json");
|
|
const backupPath = `${registryPath}.backup`;
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "durable-workspace",
|
|
projectId: "durable-project",
|
|
cwd: "/tmp/durable",
|
|
kind: "local_checkout",
|
|
displayName: "durable",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
await workspaceRegistry.upsert(workspace);
|
|
renameSync(registryPath, backupPath);
|
|
mkdirSync(registryPath);
|
|
|
|
await expect(workspaceRegistry.remove(workspace.workspaceId)).rejects.toThrow();
|
|
expect(await workspaceRegistry.get(workspace.workspaceId)).toEqual(workspace);
|
|
|
|
rmSync(registryPath, { recursive: true });
|
|
renameSync(backupPath, registryPath);
|
|
const reconstructed = new FileBackedWorkspaceRegistry(registryPath, logger);
|
|
await expect(reconstructed.get(workspace.workspaceId)).resolves.toEqual(workspace);
|
|
await expect(workspaceRegistry.remove(workspace.workspaceId)).resolves.toBeUndefined();
|
|
await expect(workspaceRegistry.get(workspace.workspaceId)).resolves.toBeNull();
|
|
});
|
|
|
|
test("does not expose deletion intent until it is durably persisted", async () => {
|
|
const registryPath = path.join(tmpDir, "projects", "workspaces.json");
|
|
const backupPath = `${registryPath}.backup`;
|
|
const workspace = createPersistedWorkspaceRecord({
|
|
workspaceId: "intent-workspace",
|
|
projectId: "intent-project",
|
|
cwd: "/tmp/intent",
|
|
kind: "local_checkout",
|
|
displayName: "intent",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
});
|
|
await workspaceRegistry.upsert(workspace);
|
|
renameSync(registryPath, backupPath);
|
|
mkdirSync(registryPath);
|
|
|
|
await expect(
|
|
workspaceRegistry.requestDeletion(workspace.workspaceId, "2026-03-02T00:00:00.000Z"),
|
|
).rejects.toThrow();
|
|
expect(await workspaceRegistry.get(workspace.workspaceId)).toEqual(workspace);
|
|
|
|
rmSync(registryPath, { recursive: true });
|
|
renameSync(backupPath, registryPath);
|
|
const reconstructedBeforeRetry = new FileBackedWorkspaceRegistry(registryPath, logger);
|
|
await expect(reconstructedBeforeRetry.get(workspace.workspaceId)).resolves.toEqual(workspace);
|
|
|
|
await workspaceRegistry.requestDeletion(workspace.workspaceId, "2026-03-02T00:00:00.000Z");
|
|
const reconstructedAfterRetry = new FileBackedWorkspaceRegistry(registryPath, logger);
|
|
await expect(reconstructedAfterRetry.get(workspace.workspaceId)).resolves.toMatchObject({
|
|
deletionRequestedAt: "2026-03-02T00:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
test("refreshes workspace archive timestamps when an archive is repeated", async () => {
|
|
await workspaceRegistry.initialize();
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "workspace-one",
|
|
projectId: "project-one",
|
|
cwd: "/tmp/repo",
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
await workspaceRegistry.archive("workspace-one", "2026-03-02T00:00:00.000Z");
|
|
await workspaceRegistry.archive("workspace-one", "2026-03-03T00:00:00.000Z");
|
|
|
|
expect(await workspaceRegistry.get("workspace-one")).toMatchObject({
|
|
archivedAt: "2026-03-03T00:00:00.000Z",
|
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
|
});
|
|
});
|
|
|
|
test("persists the consumed change request with the workspace archive", async () => {
|
|
await workspaceRegistry.initialize();
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "workspace-auto-archive",
|
|
projectId: "project-one",
|
|
cwd: "/tmp/repo",
|
|
kind: "worktree",
|
|
displayName: "feature",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
await workspaceRegistry.archive("workspace-auto-archive", "2026-03-02T00:00:00.000Z", {
|
|
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
|
|
});
|
|
|
|
const reloaded = new FileBackedWorkspaceRegistry(
|
|
path.join(tmpDir, "projects", "workspaces.json"),
|
|
logger,
|
|
);
|
|
await reloaded.initialize();
|
|
expect(await reloaded.get("workspace-auto-archive")).toMatchObject({
|
|
archivedAt: "2026-03-02T00:00:00.000Z",
|
|
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
|
|
});
|
|
});
|
|
|
|
test("composes concurrent workspace field updates without losing either change", async () => {
|
|
await workspaceRegistry.initialize();
|
|
await workspaceRegistry.upsert(
|
|
createPersistedWorkspaceRecord({
|
|
workspaceId: "ws-1",
|
|
projectId: "proj-1",
|
|
cwd: "/tmp/repo",
|
|
kind: "local_checkout",
|
|
displayName: "main",
|
|
createdAt: "2026-03-01T00:00:00.000Z",
|
|
updatedAt: "2026-03-01T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
await Promise.all([
|
|
workspaceRegistry.update("ws-1", (record) => ({
|
|
...record,
|
|
title: "Payments work",
|
|
updatedAt: "2026-03-02T00:00:00.000Z",
|
|
})),
|
|
workspaceRegistry.update("ws-1", (record) => ({
|
|
...record,
|
|
pinnedAt: "2026-03-03T00:00:00.000Z",
|
|
updatedAt: "2026-03-03T00:00:00.000Z",
|
|
})),
|
|
]);
|
|
|
|
const reloadedRegistry = new FileBackedWorkspaceRegistry(
|
|
path.join(tmpDir, "projects", "workspaces.json"),
|
|
logger,
|
|
);
|
|
await reloadedRegistry.initialize();
|
|
expect(await reloadedRegistry.get("ws-1")).toMatchObject({
|
|
title: "Payments work",
|
|
pinnedAt: "2026-03-03T00:00:00.000Z",
|
|
});
|
|
});
|
|
});
|