Treat every added folder as an independent project (#2098)
* fix(projects): detect when projects become Git repositories Project identity was coupled to Git placement, while non-Git roots were dropped by session-scoped observation. Keep identity tied to the selected root and observe Git transitions daemon-wide so empty projects update without rehoming workspaces. * fix(projects): preserve metadata across Git read failures Refresh archived workspace facts before persistence and treat only confirmed non-repositories as non-Git so transient failures cannot rewrite stored project metadata. * fix(projects): close project update races * fix(projects): refresh checkout metadata when reopening folders Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors. * test(projects): accept Windows aliases for repaired roots * test(server): retry transient Windows cleanup locks * fix(projects): propagate project metadata refreshes Project-only Git transitions now fan out workspace updates for legacy clients. Explicit project creation refreshes stored kind, and equivalent cwd spellings reuse existing workspace records. * fix(projects): refresh worktree source project kind * fix(projects): preserve exact-folder runtime isolation * fix(projects): preserve exact cwd across worktree lifecycle * fix(projects): harden exact-folder worktree flows * fix(projects): derive exact cwd from matched path identity * fix(projects): preserve nested worktree lifecycle * refactor(projects): centralize workspace placement * test(e2e): verify isolated server ports * fix(projects): preserve placement reshape compatibility * fix(projects): validate created worktree placement * fix(projects): preserve placement through workspace lifecycle Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation. * fix(worktrees): preserve source checkout root Convert Git's common administrative directory back to the source checkout root when legacy archive placement is reconstructed. * fix(worktrees): compare filesystem identities Resolve discovered worktrees and teardown locations through the shared realpath-aware matcher so Windows short and long path spellings cannot split placement identity. * fix(worktrees): centralize path containment Route worktree ownership, listing, resolution, and deletion through the realpath-aware containment primitive so Windows path aliases cannot be filtered by an earlier raw prefix check. * fix(worktrees): remove duplicate ownership discovery * test(e2e): isolate daemon restart ownership * fix(worktrees): make lifecycle operations transactional * fix(workspaces): share git watches by cwd * test(worktrees): make teardown proof portable * fix(sync): integrate project updates with directory owner * fix(workspaces): close reconciliation edge cases * refactor(sync): remove duplicate project reconciliation Keep workspace and project deltas behind the host directory transaction, with owner-boundary coverage for snapshot replay and queued full reconciliation. * fix(sync): buffer project updates before hydration Start the workspace transaction with the online connection epoch so project broadcasts cannot publish a partial directory before the authoritative snapshot commits.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type AddProjectHost,
|
||||
} from "./model";
|
||||
import {
|
||||
addProjectMethodEmptyText,
|
||||
buildAddProjectMethods,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
@@ -110,6 +111,13 @@ describe("Add Project navigation", () => {
|
||||
});
|
||||
|
||||
describe("Add Project options", () => {
|
||||
it("hides every mutating method when the host lacks stable project identity", () => {
|
||||
const outdatedHost = { ...HOST, canAddProject: false };
|
||||
|
||||
expect(buildAddProjectMethods(outdatedHost)).toEqual([]);
|
||||
expect(addProjectMethodEmptyText(outdatedHost)).toBe("Update the host to use Add Project.");
|
||||
});
|
||||
|
||||
it("keeps host-upgrade methods discoverable while hiding local-only Browse", () => {
|
||||
expect(
|
||||
buildAddProjectMethods({
|
||||
|
||||
@@ -34,14 +34,13 @@ export function filterAddProjectHosts(hosts: AddProjectHost[], query: string): A
|
||||
}
|
||||
|
||||
export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOption[] {
|
||||
if (!host.canAddProject) return [];
|
||||
const options: AddProjectMethodOption[] = [];
|
||||
if (host.canAddProject) {
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
id: "directory-search",
|
||||
label: "Search for directory",
|
||||
description: `Find a directory on ${host.label}`,
|
||||
});
|
||||
if (host.canBrowse) {
|
||||
options.push({
|
||||
id: "browse",
|
||||
@@ -66,6 +65,12 @@ export function buildAddProjectMethods(host: AddProjectHost): AddProjectMethodOp
|
||||
return options;
|
||||
}
|
||||
|
||||
export function addProjectMethodEmptyText(host: AddProjectHost | null): string {
|
||||
return host?.canAddProject === false
|
||||
? "Update the host to use Add Project."
|
||||
: "No matching options";
|
||||
}
|
||||
|
||||
function githubMethodDescription(host: AddProjectHost): string {
|
||||
if (!host.canCloneGithubRepositories) {
|
||||
return "Update this host to clone GitHub repositories";
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "@/add-project-flow/model";
|
||||
import {
|
||||
buildAddProjectMethods,
|
||||
addProjectMethodEmptyText,
|
||||
buildCloneLocationOptions,
|
||||
buildManualGithubRepositoryChoices,
|
||||
buildSuggestedParentDirectories,
|
||||
@@ -161,9 +162,10 @@ function progressText(page: AddProjectPage): string {
|
||||
return "Adding project...";
|
||||
}
|
||||
|
||||
function emptyText(page: AddProjectPage): string {
|
||||
function emptyText(page: AddProjectPage, host: AddProjectHost | null): string {
|
||||
if (page.kind === "host") return "No connected hosts";
|
||||
if (page.kind === "github-search") return "Enter a GitHub URL or owner/repo";
|
||||
if (page.kind === "method") return addProjectMethodEmptyText(host);
|
||||
return "No matching options";
|
||||
}
|
||||
|
||||
@@ -297,6 +299,8 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
|
||||
const projectAddByHost = useHostFeatureMap(hostIds, "projectAdd");
|
||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||
const stableProjectIdentityByHost = useHostFeatureMap(hostIds, "stableProjectIdentity");
|
||||
// COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15.
|
||||
const githubCloneByHost = useHostFeatureMap(hostIds, "projectGithubClone");
|
||||
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
|
||||
@@ -308,7 +312,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
() =>
|
||||
hosts.flatMap((host) => {
|
||||
if (connectionStatuses.get(host.serverId) !== "online") return [];
|
||||
const canAddProject = projectAddByHost.get(host.serverId) === true;
|
||||
const canAddProject =
|
||||
projectAddByHost.get(host.serverId) === true &&
|
||||
stableProjectIdentityByHost.get(host.serverId) === true;
|
||||
return [
|
||||
{
|
||||
serverId: host.serverId,
|
||||
@@ -329,6 +335,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
hosts,
|
||||
localServerId,
|
||||
projectAddByHost,
|
||||
stableProjectIdentityByHost,
|
||||
],
|
||||
);
|
||||
const [state, setState] = useState(() =>
|
||||
@@ -893,7 +900,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
rows.length === 0 &&
|
||||
page.kind !== "new-directory-name" ? (
|
||||
<Text style={styles.stateText} testID="add-project-flow-empty">
|
||||
{emptyText(page)}
|
||||
{emptyText(page, host ?? null)}
|
||||
</Text>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { waitForMetro } from "../e2e/global-setup";
|
||||
|
||||
class MetroPort {
|
||||
private response = { status: 500, body: "fallback" };
|
||||
|
||||
private constructor(
|
||||
readonly port: number,
|
||||
private readonly server: Server,
|
||||
) {}
|
||||
|
||||
static async listen(): Promise<MetroPort> {
|
||||
let endpoint!: MetroPort;
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(endpoint.response.status, { "content-type": "text/plain" });
|
||||
response.end(endpoint.response.body);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("Failed to listen for Metro readiness test");
|
||||
}
|
||||
endpoint = new MetroPort(address.port, server);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
serveMetro(): void {
|
||||
this.response = { status: 200, body: "packager-status:running" };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let endpoint: MetroPort | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await endpoint?.close();
|
||||
endpoint = null;
|
||||
});
|
||||
|
||||
test("Metro readiness rejects another HTTP listener on the selected port", async () => {
|
||||
endpoint = await MetroPort.listen();
|
||||
|
||||
await expect(waitForMetro(endpoint.port, { label: "Metro", timeoutMs: 150 })).rejects.toThrow(
|
||||
"Expected Metro status",
|
||||
);
|
||||
|
||||
endpoint.serveMetro();
|
||||
await expect(waitForMetro(endpoint.port, { label: "Metro", timeoutMs: 150 })).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,8 @@ export function useOpenProject(
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const canAddProject = useSessionStore((state) =>
|
||||
normalizedServerId
|
||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true &&
|
||||
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
||||
: false,
|
||||
);
|
||||
const addEmptyProject = useSessionStore((state) => state.addEmptyProject);
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
|
||||
|
||||
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
fetchAgentsCalls = 0;
|
||||
fetchWorkspacesCalls = 0;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
Set<(message: SessionOutboundMessage) => void>
|
||||
>();
|
||||
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
on<TType extends SessionOutboundMessage["type"]>(
|
||||
type: TType,
|
||||
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
|
||||
): () => void {
|
||||
const handlers = this.handlers.get(type) ?? new Set();
|
||||
const registered = handler as unknown as (message: SessionOutboundMessage) => void;
|
||||
handlers.add(registered);
|
||||
this.handlers.set(type, handlers);
|
||||
return () => handlers.delete(registered);
|
||||
}
|
||||
|
||||
emit<TType extends SessionOutboundMessage["type"]>(
|
||||
message: Extract<SessionOutboundMessage, { type: TType }>,
|
||||
): void {
|
||||
for (const handler of this.handlers.get(message.type) ?? []) handler(message);
|
||||
}
|
||||
|
||||
holdWorkspaceFetch(): (result: WorkspaceFetchResult) => void {
|
||||
let complete!: (result: WorkspaceFetchResult) => void;
|
||||
this.pendingWorkspaceFetch = new Promise((resolve) => {
|
||||
complete = resolve;
|
||||
});
|
||||
return complete;
|
||||
}
|
||||
|
||||
async fetchAgents(): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
@@ -20,8 +49,13 @@ class FakeDirectoryClient {
|
||||
};
|
||||
}
|
||||
|
||||
async fetchWorkspaces(): Promise<Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>> {
|
||||
async fetchWorkspaces(): Promise<WorkspaceFetchResult> {
|
||||
this.fetchWorkspacesCalls += 1;
|
||||
if (this.pendingWorkspaceFetch) {
|
||||
const pending = this.pendingWorkspaceFetch;
|
||||
this.pendingWorkspaceFetch = null;
|
||||
return pending;
|
||||
}
|
||||
return {
|
||||
requestId: "workspaces",
|
||||
entries: [],
|
||||
@@ -110,4 +144,114 @@ describe("DirectorySync session readiness", () => {
|
||||
expect(client.fetchAgentsCalls).toBe(1);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("buffers workspace and project updates in the same hydration transaction", async () => {
|
||||
const serverId = "workspace-project-transaction";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true },
|
||||
});
|
||||
const completeFetch = client.holdWorkspaceFetch();
|
||||
|
||||
const refresh = directory.refreshWorkspaces({ subscribe: true });
|
||||
await Promise.resolve();
|
||||
client.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "remove",
|
||||
id: "removed-workspace",
|
||||
emptyProject: {
|
||||
projectId: "workspace-project",
|
||||
projectDisplayName: "Project from workspace update",
|
||||
projectRootPath: "/repo/workspace-project",
|
||||
projectKind: "git",
|
||||
},
|
||||
},
|
||||
});
|
||||
client.emit({
|
||||
type: "project.update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Renamed during hydration",
|
||||
projectRootPath: "/moved/snapshot-project",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
});
|
||||
completeFetch({
|
||||
requestId: "workspaces",
|
||||
entries: [],
|
||||
emptyProjects: [
|
||||
{
|
||||
projectId: "snapshot-project",
|
||||
projectDisplayName: "Stale snapshot project",
|
||||
projectRootPath: "/repo/snapshot-project",
|
||||
projectKind: "git",
|
||||
},
|
||||
],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
});
|
||||
await refresh;
|
||||
|
||||
const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects;
|
||||
expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([
|
||||
"snapshot-project",
|
||||
"workspace-project",
|
||||
]);
|
||||
expect(emptyProjects?.get("snapshot-project")).toMatchObject({
|
||||
projectDisplayName: "Renamed during hydration",
|
||||
projectRootPath: "/moved/snapshot-project",
|
||||
projectKind: "directory",
|
||||
});
|
||||
expect(emptyProjects?.get("workspace-project")).toMatchObject({
|
||||
projectDisplayName: "Project from workspace update",
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("buffers project updates from the online epoch before workspace hydration starts", async () => {
|
||||
const serverId = "project-before-workspace-hydration";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true },
|
||||
});
|
||||
|
||||
client.emit({
|
||||
type: "project.update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "early-project",
|
||||
projectDisplayName: "Early project",
|
||||
projectRootPath: "/repo/early-project",
|
||||
projectKind: "git",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(false);
|
||||
|
||||
await directory.refreshWorkspaces({ subscribe: true });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"),
|
||||
).toMatchObject({
|
||||
projectDisplayName: "Early project",
|
||||
projectRootPath: "/repo/early-project",
|
||||
});
|
||||
directory.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,10 @@ export class DirectorySync {
|
||||
if (!connection.client || connection.status !== "online") return true;
|
||||
const client = connection.client;
|
||||
const source = connection.source;
|
||||
this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
}));
|
||||
const subscriptions = [
|
||||
client.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
|
||||
@@ -119,6 +123,12 @@ export class DirectorySync {
|
||||
this.workspaces.applyDelta(message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("project.update", (message) => {
|
||||
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
|
||||
if (!this.workspaceTransactions.record(source, message.payload)) {
|
||||
this.workspaces.applyDelta(message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("agent_deleted", (message) => {
|
||||
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
|
||||
this.agents.remove(message.payload.agentId);
|
||||
|
||||
@@ -55,3 +55,91 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
const serverId = "project-update-replica";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const attachedMain = normalizeWorkspaceDescriptor(workspace("attached-main", "attached"));
|
||||
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
|
||||
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
|
||||
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
|
||||
const staleAttachedProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Stale attached project",
|
||||
projectRootPath: "/repo/attached",
|
||||
projectKind: "git",
|
||||
});
|
||||
const removedProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "removed",
|
||||
projectDisplayName: "Removed project",
|
||||
projectRootPath: "/repo/removed",
|
||||
projectKind: "git",
|
||||
});
|
||||
const unchangedEmptyProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "unchanged-empty",
|
||||
projectDisplayName: "Unchanged empty project",
|
||||
projectRootPath: "/repo/unchanged-empty",
|
||||
projectKind: "git",
|
||||
});
|
||||
|
||||
replica.commitSnapshot(
|
||||
{
|
||||
workspaces: new Map([
|
||||
[attachedMain.id, attachedMain],
|
||||
[attachedFeature.id, attachedFeature],
|
||||
[removed.id, removed],
|
||||
[unrelated.id, unrelated],
|
||||
]),
|
||||
emptyProjects: new Map([
|
||||
[staleAttachedProject.projectId, staleAttachedProject],
|
||||
[removedProject.projectId, removedProject],
|
||||
[unchangedEmptyProject.projectId, unchangedEmptyProject],
|
||||
]),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/attached",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "new-empty",
|
||||
projectDisplayName: "New empty project",
|
||||
projectRootPath: "/repo/new-empty",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
{ kind: "remove", projectId: "removed" },
|
||||
],
|
||||
);
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(session?.workspaces.get(attachedMain.id)).toMatchObject({
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/attached",
|
||||
projectKind: "directory",
|
||||
});
|
||||
expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectRootPath: "/moved/attached",
|
||||
});
|
||||
expect(session?.workspaces.has(removed.id)).toBe(false);
|
||||
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]);
|
||||
expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject);
|
||||
expect(session?.emptyProjects.get("new-empty")).toMatchObject({
|
||||
projectDisplayName: "New empty project",
|
||||
projectRootPath: "/repo/new-empty",
|
||||
});
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
@@ -14,20 +14,50 @@ import {
|
||||
|
||||
export type WorkspaceDirectoryDelta = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
{ type: "workspace_update" | "project.update" }
|
||||
>["payload"];
|
||||
type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||
|
||||
export interface WorkspaceDirectorySnapshot {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
}
|
||||
|
||||
function applyProjectDelta(
|
||||
snapshot: WorkspaceDirectorySnapshot,
|
||||
delta: ProjectDirectoryDelta,
|
||||
): void {
|
||||
if (delta.kind === "remove") {
|
||||
snapshot.emptyProjects.delete(delta.projectId);
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const project = normalizeEmptyProjectDescriptor(delta.project);
|
||||
let hasAttachedWorkspace = false;
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId !== project.projectId) continue;
|
||||
hasAttachedWorkspace = true;
|
||||
snapshot.workspaces.set(workspaceId, {
|
||||
...workspace,
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
projectCustomName: project.projectCustomName,
|
||||
projectRootPath: project.projectRootPath,
|
||||
projectKind: project.projectKind,
|
||||
});
|
||||
}
|
||||
if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId);
|
||||
else snapshot.emptyProjects.set(project.projectId, project);
|
||||
}
|
||||
|
||||
export class WorkspaceDirectoryReplica {
|
||||
constructor(private readonly serverId: string) {}
|
||||
|
||||
applyDelta(delta: WorkspaceDirectoryDelta): void {
|
||||
const state = this.reconcile(this.read(), [delta]);
|
||||
this.commit(state, delta.kind === "remove" ? [delta.id] : []);
|
||||
this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []);
|
||||
}
|
||||
|
||||
commitSnapshot(
|
||||
@@ -35,9 +65,10 @@ export class WorkspaceDirectoryReplica {
|
||||
deltas: readonly WorkspaceDirectoryDelta[],
|
||||
): void {
|
||||
const removedWorkspaceIds = deltas.flatMap((delta) =>
|
||||
delta.kind === "remove" ? [delta.id] : [],
|
||||
delta.kind === "remove" && "id" in delta ? [delta.id] : [],
|
||||
);
|
||||
this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
|
||||
}
|
||||
|
||||
private read(): WorkspaceDirectorySnapshot {
|
||||
@@ -60,6 +91,10 @@ export class WorkspaceDirectoryReplica {
|
||||
}
|
||||
}
|
||||
for (const delta of deltas) {
|
||||
if ("projectId" in delta || "project" in delta) {
|
||||
applyProjectDelta({ workspaces, emptyProjects }, delta);
|
||||
continue;
|
||||
}
|
||||
if (delta.kind === "remove") {
|
||||
workspaces.delete(delta.id);
|
||||
if (delta.emptyProject) {
|
||||
@@ -84,7 +119,6 @@ export class WorkspaceDirectoryReplica {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
||||
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
|
||||
store.setHasHydratedWorkspaces(this.serverId, true);
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
||||
|
||||
@@ -17,6 +17,7 @@ function createWorkspace(
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectCustomName: input.projectCustomName ?? null,
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
|
||||
@@ -320,6 +320,8 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -368,6 +370,9 @@ export interface SessionState {
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -489,6 +494,13 @@ interface SessionStoreActions {
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
status: WorkspaceRestoreStatus,
|
||||
) => void;
|
||||
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
setAgentLastActivityBatch: (
|
||||
@@ -567,6 +579,7 @@ function createInitialSessionState(
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -1347,6 +1360,54 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === status) {
|
||||
return prev;
|
||||
}
|
||||
// A late dir-gone timeout must not override a successful restore:
|
||||
// only mark failed while still restoring and the descriptor is absent.
|
||||
if (
|
||||
status === "failed" &&
|
||||
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
|
||||
session.workspaces.has(workspaceId))
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.set(workspaceId, status);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.delete(workspaceId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mergeWorkspaces: (serverId, workspaces) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1360,10 +1421,18 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
changed = true;
|
||||
}
|
||||
const existing = next.get(workspace.id);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1383,6 +1452,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1601,3 +1671,14 @@ export const useSessionStore = create<SessionStore>()(
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export function useWorkspaceRestoreStatus(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceRestoreStatus | null {
|
||||
return useSessionStore((state) =>
|
||||
serverId && workspaceId
|
||||
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user