Skip to content

Commit 91a6e82

Browse files
lefarcengithub-actions[bot]
authored andcommitted
fix(workspace): restore last selection after restart (#7067)
* fix(workspace): restore the last selection on restart * test(workspace): cover persisted restart default * fix(workspace): reuse directory authority for context reads * test(workspace): align unknown seat invite behavior * test(workspace): cover unknown seat invite fallback * fix(workspace): preserve unknown invite capacity * fix(workspace): commit selection after persistence * fix(workspace): preserve unknown seat analytics * fix(workspace): guard stale selection cleanup * fix(workspace): make stale recovery atomic (cherry picked from commit 879236e)
1 parent e190d1d commit 91a6e82

36 files changed

Lines changed: 1034 additions & 940 deletions

apps/daemon/src/collab/active-workspace-selection.ts

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs';
2+
import { randomUUID } from 'node:crypto';
23
import path from 'node:path';
34

45
interface ActiveWorkspaceSelectionFile {
@@ -10,6 +11,11 @@ export interface ActiveWorkspaceSelectionStore {
1011
snapshot(): { workspaceId: string | null; generation: number };
1112
set(workspaceId: string): Promise<void>;
1213
clear(): Promise<void>;
14+
clearIf(workspaceId: string): Promise<boolean>;
15+
replaceIf(
16+
expectedWorkspaceId: string | null,
17+
workspaceId: string,
18+
): Promise<string | null>;
1319
subscribe(listener: (workspaceId: string | null) => void): () => void;
1420
}
1521

@@ -53,6 +59,7 @@ export function createActiveWorkspaceSelectionStore(
5359
const filePath = path.join(dataDir, 'workspace-selection.json');
5460
let cached: string | null | undefined;
5561
let generation = 0;
62+
let mutationTail = Promise.resolve();
5663
const listeners = new Set<(workspaceId: string | null) => void>();
5764

5865
const read = (): string | null => {
@@ -79,6 +86,37 @@ export function createActiveWorkspaceSelectionStore(
7986
}
8087
};
8188

89+
const enqueueMutation = <T>(mutation: () => Promise<T>): Promise<T> => {
90+
const result = mutationTail.then(mutation);
91+
mutationTail = result.then(
92+
() => undefined,
93+
() => undefined,
94+
);
95+
return result;
96+
};
97+
98+
const persist = async (workspaceId: string) => {
99+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
100+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
101+
try {
102+
await fs.promises.writeFile(
103+
tempPath,
104+
JSON.stringify({ workspaceId }, null, 2),
105+
'utf8',
106+
);
107+
await fs.promises.rename(tempPath, filePath);
108+
} catch (error) {
109+
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
110+
throw error;
111+
}
112+
};
113+
114+
const commit = (workspaceId: string) => {
115+
cached = workspaceId;
116+
generation += 1;
117+
notify(workspaceId);
118+
};
119+
82120
return {
83121
get: read,
84122
snapshot() {
@@ -87,21 +125,47 @@ export function createActiveWorkspaceSelectionStore(
87125
async set(workspaceId: string) {
88126
const next = workspaceId.trim();
89127
if (!next) throw new Error('workspaceId is required');
90-
cached = next;
91-
generation += 1;
92-
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
93-
await fs.promises.writeFile(
94-
filePath,
95-
JSON.stringify({ workspaceId: next }, null, 2),
96-
'utf8',
97-
);
98-
notify(next);
128+
await enqueueMutation(async () => {
129+
await persist(next);
130+
commit(next);
131+
});
99132
},
100133
async clear() {
101-
cached = null;
102-
generation += 1;
103-
await fs.promises.rm(filePath, { force: true });
104-
notify(null);
134+
await enqueueMutation(async () => {
135+
await fs.promises.rm(filePath, { force: true });
136+
cached = null;
137+
generation += 1;
138+
notify(null);
139+
});
140+
},
141+
async clearIf(workspaceId: string) {
142+
const expected = workspaceId.trim();
143+
if (!expected) return false;
144+
return enqueueMutation(async () => {
145+
if (read() !== expected) return false;
146+
await fs.promises.rm(filePath, { force: true });
147+
cached = null;
148+
generation += 1;
149+
notify(null);
150+
return true;
151+
});
152+
},
153+
async replaceIf(expectedWorkspaceId: string | null, workspaceId: string) {
154+
const expected = expectedWorkspaceId?.trim() || null;
155+
const next = workspaceId.trim();
156+
if (!next) throw new Error('workspaceId is required');
157+
await enqueueMutation(async () => {
158+
if (read() !== expected) return;
159+
await persist(next);
160+
commit(next);
161+
});
162+
163+
// A user switch can queue while the conditional write is in flight.
164+
// Drain mutations that were already queued when this write settled, then
165+
// report the selection that actually won instead of the temporary value.
166+
const queuedThroughCommit = mutationTail;
167+
await queuedThroughCommit;
168+
return read();
105169
},
106170
subscribe(listener) {
107171
listeners.add(listener);

0 commit comments

Comments
 (0)