pi-webdesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/apps/daemon/src/appearance-preferences.js +218 -0
- package/dist/apps/daemon/src/auth.js +88 -0
- package/dist/apps/daemon/src/bin.js +123 -0
- package/dist/apps/daemon/src/cli.js +48 -0
- package/dist/apps/daemon/src/event-hub.js +155 -0
- package/dist/apps/daemon/src/index.js +102 -0
- package/dist/apps/daemon/src/launcher-control.js +114 -0
- package/dist/apps/daemon/src/launcher.js +73 -0
- package/dist/apps/daemon/src/pi-auth.js +290 -0
- package/dist/apps/daemon/src/pi-resources.js +182 -0
- package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
- package/dist/apps/daemon/src/pi-sessions.js +265 -0
- package/dist/apps/daemon/src/runtime-process.js +241 -0
- package/dist/apps/daemon/src/secret.js +71 -0
- package/dist/apps/daemon/src/server.js +1662 -0
- package/dist/apps/daemon/src/session-projection.js +117 -0
- package/dist/apps/daemon/src/state-lock.js +31 -0
- package/dist/apps/daemon/src/static-web.js +53 -0
- package/dist/apps/daemon/src/task-archive.js +152 -0
- package/dist/apps/daemon/src/task-commit.js +503 -0
- package/dist/apps/daemon/src/task-merge.js +912 -0
- package/dist/apps/daemon/src/task-review.js +204 -0
- package/dist/apps/daemon/src/task-runtime.js +1124 -0
- package/dist/apps/daemon/src/task-validation.js +352 -0
- package/dist/apps/daemon/src/workspace-store.js +140 -0
- package/dist/apps/daemon/src/workspace.js +795 -0
- package/dist/extensions/webdesk.js +34 -0
- package/dist/packages/git/src/commit.js +675 -0
- package/dist/packages/git/src/errors.js +55 -0
- package/dist/packages/git/src/fingerprint.js +286 -0
- package/dist/packages/git/src/index.js +123 -0
- package/dist/packages/git/src/merge.js +1008 -0
- package/dist/packages/git/src/paths.js +58 -0
- package/dist/packages/git/src/repository.js +77 -0
- package/dist/packages/git/src/review.js +396 -0
- package/dist/packages/git/src/runner.js +110 -0
- package/dist/packages/git/src/validation.js +263 -0
- package/dist/packages/git/src/worktree.js +233 -0
- package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
- package/dist/packages/pi-bridge/src/auth.js +80 -0
- package/dist/packages/pi-bridge/src/errors.js +19 -0
- package/dist/packages/pi-bridge/src/handshake.js +43 -0
- package/dist/packages/pi-bridge/src/index.js +76 -0
- package/dist/packages/pi-bridge/src/jsonl.js +105 -0
- package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
- package/dist/packages/pi-bridge/src/resolve.js +59 -0
- package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
- package/dist/packages/pi-bridge/src/resources.js +481 -0
- package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
- package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
- package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
- package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
- package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
- package/dist/packages/pi-bridge/src/runtime.js +0 -0
- package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
- package/dist/packages/pi-bridge/src/sessions.js +314 -0
- package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
- package/dist/packages/protocol/src/index.js +1863 -0
- package/dist/web/assets/index-BOw_fhvO.css +2 -0
- package/dist/web/assets/index-oXs7yAAo.js +119 -0
- package/dist/web/index.html +14 -0
- package/package.json +69 -0
- package/scripts/prepare.mjs +7 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// apps/daemon/src/task-validation.ts
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
captureWorktreeState,
|
|
5
|
+
isGitServiceError,
|
|
6
|
+
isValidationRunnerError,
|
|
7
|
+
startValidationRun
|
|
8
|
+
} from "../../../packages/git/src/index.js";
|
|
9
|
+
import { sanitizeApprovalDisplayText } from "../../../packages/pi-bridge/src/index.js";
|
|
10
|
+
import {
|
|
11
|
+
PROTOCOL_VERSION,
|
|
12
|
+
TASK_VALIDATION_MAX_OUTPUT_CHARS,
|
|
13
|
+
taskValidationSchema
|
|
14
|
+
} from "../../../packages/protocol/src/index.js";
|
|
15
|
+
var TaskValidationOperationError = class extends Error {
|
|
16
|
+
name = "TaskValidationOperationError";
|
|
17
|
+
code;
|
|
18
|
+
status;
|
|
19
|
+
constructor(code, message, status, options) {
|
|
20
|
+
super(message, options);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var WORKTREE_UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
|
|
26
|
+
"GIT_PATH_NOT_FOUND",
|
|
27
|
+
"GIT_PATH_NOT_DIRECTORY",
|
|
28
|
+
"GIT_NOT_A_REPOSITORY",
|
|
29
|
+
"GIT_BARE_REPOSITORY",
|
|
30
|
+
"GIT_NOT_A_WORK_TREE",
|
|
31
|
+
"GIT_HEAD_UNRESOLVED"
|
|
32
|
+
]);
|
|
33
|
+
var WORKTREE_UNAVAILABLE_MESSAGE = "The task worktree is missing or is no longer a healthy Git worktree. Recover it before running validation.";
|
|
34
|
+
var WORKTREE_MISMATCH_MESSAGE = "The recorded worktree no longer matches the task's repository. Recover it before running validation.";
|
|
35
|
+
function createTaskValidationService(options) {
|
|
36
|
+
const capture = options.captureWorktreeState ?? captureWorktreeState;
|
|
37
|
+
const startRun = options.startValidationRun ?? startValidationRun;
|
|
38
|
+
const now = options.now ?? Date.now;
|
|
39
|
+
const activeRuns = /* @__PURE__ */ new Map();
|
|
40
|
+
let disposing = false;
|
|
41
|
+
async function resolveTask(taskId) {
|
|
42
|
+
let task;
|
|
43
|
+
try {
|
|
44
|
+
task = await options.getTask(taskId);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new TaskValidationOperationError(
|
|
47
|
+
"validation-failed",
|
|
48
|
+
"The task could not be resolved from the local workspace.",
|
|
49
|
+
503,
|
|
50
|
+
{ cause: error }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (task === null) {
|
|
54
|
+
throw new TaskValidationOperationError(
|
|
55
|
+
"task-not-found",
|
|
56
|
+
"The selected task no longer exists.",
|
|
57
|
+
404
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (task.status !== "ready") {
|
|
61
|
+
throw new TaskValidationOperationError(
|
|
62
|
+
"task-not-ready",
|
|
63
|
+
`Only a ready task can be validated; this task is ${task.status}.`,
|
|
64
|
+
409
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return task;
|
|
68
|
+
}
|
|
69
|
+
async function resolveRepository(task) {
|
|
70
|
+
let repository;
|
|
71
|
+
try {
|
|
72
|
+
repository = await options.getRepository(task.repositoryId);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new TaskValidationOperationError(
|
|
75
|
+
"validation-failed",
|
|
76
|
+
"The task's repository could not be resolved from the local workspace.",
|
|
77
|
+
503,
|
|
78
|
+
{ cause: error }
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (repository === null) {
|
|
82
|
+
throw new TaskValidationOperationError(
|
|
83
|
+
"task-not-ready",
|
|
84
|
+
"The task's repository is no longer registered.",
|
|
85
|
+
409
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return repository;
|
|
89
|
+
}
|
|
90
|
+
async function captureVerifiedState(task, repository) {
|
|
91
|
+
let canonicalWorktree;
|
|
92
|
+
try {
|
|
93
|
+
canonicalWorktree = await realpath(task.worktreePath);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new TaskValidationOperationError("task-not-ready", WORKTREE_UNAVAILABLE_MESSAGE, 409, {
|
|
96
|
+
cause: error
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
let state;
|
|
100
|
+
try {
|
|
101
|
+
state = await capture({ worktreePath: canonicalWorktree });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (isGitServiceError(error) && WORKTREE_UNAVAILABLE_CODES.has(error.code)) {
|
|
104
|
+
throw new TaskValidationOperationError(
|
|
105
|
+
"task-not-ready",
|
|
106
|
+
WORKTREE_UNAVAILABLE_MESSAGE,
|
|
107
|
+
409,
|
|
108
|
+
{ cause: error }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (isGitServiceError(error, "GIT_STATE_TOO_LARGE") || isGitServiceError(error, "GIT_STATE_UNFINGERPRINTABLE")) {
|
|
112
|
+
throw new TaskValidationOperationError(
|
|
113
|
+
"validation-failed",
|
|
114
|
+
"The worktree has changed content that is too large or cannot be fingerprinted safely yet.",
|
|
115
|
+
422,
|
|
116
|
+
{ cause: error }
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (isGitServiceError(error, "GIT_REPOSITORY_CHANGED_DURING_CAPTURE") || isGitServiceError(error, "GIT_STATE_CHANGED_DURING_CAPTURE") || isGitServiceError(error, "GIT_STATE_DEADLINE_EXCEEDED")) {
|
|
120
|
+
throw new TaskValidationOperationError(
|
|
121
|
+
"validation-failed",
|
|
122
|
+
"The worktree state changed or could not be captured in time. Try again.",
|
|
123
|
+
409,
|
|
124
|
+
{ cause: error }
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
throw new TaskValidationOperationError(
|
|
128
|
+
"validation-failed",
|
|
129
|
+
"Git could not capture the worktree state for this task. Check the daemon output for details.",
|
|
130
|
+
502,
|
|
131
|
+
{ cause: error }
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (state.worktree.root !== canonicalWorktree || state.worktree.commonDir !== repository.commonDir || state.worktree.branch !== task.branch) {
|
|
135
|
+
throw new TaskValidationOperationError("task-not-ready", WORKTREE_MISMATCH_MESSAGE, 409);
|
|
136
|
+
}
|
|
137
|
+
return { canonicalWorktree, state };
|
|
138
|
+
}
|
|
139
|
+
function boundedOutput(raw, alreadyOmitted) {
|
|
140
|
+
const safe = sanitizeApprovalDisplayText(raw);
|
|
141
|
+
if (safe.length <= TASK_VALIDATION_MAX_OUTPUT_CHARS) {
|
|
142
|
+
return { text: safe, omitted: alreadyOmitted };
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
text: safe.slice(0, TASK_VALIDATION_MAX_OUTPUT_CHARS),
|
|
146
|
+
omitted: alreadyOmitted + (safe.length - TASK_VALIDATION_MAX_OUTPUT_CHARS)
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function payload(taskId, result, running, stale) {
|
|
150
|
+
return taskValidationSchema.parse({
|
|
151
|
+
protocol: PROTOCOL_VERSION,
|
|
152
|
+
type: "task-validation",
|
|
153
|
+
taskId,
|
|
154
|
+
result,
|
|
155
|
+
running,
|
|
156
|
+
stale,
|
|
157
|
+
capturedAtMs: now()
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
async function status(taskId) {
|
|
161
|
+
const task = await resolveTask(taskId);
|
|
162
|
+
const repository = await resolveRepository(task);
|
|
163
|
+
const record = task.status === "ready" ? task.validation ?? null : null;
|
|
164
|
+
const running = activeRuns.has(taskId);
|
|
165
|
+
let stale = false;
|
|
166
|
+
if (record !== null) {
|
|
167
|
+
stale = true;
|
|
168
|
+
try {
|
|
169
|
+
const { state } = await captureVerifiedState(task, repository);
|
|
170
|
+
stale = state.stateFingerprint !== record.stateFingerprint;
|
|
171
|
+
} catch {
|
|
172
|
+
stale = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return payload(task.id, record, running, stale);
|
|
176
|
+
}
|
|
177
|
+
async function run(taskId, command) {
|
|
178
|
+
if (disposing) {
|
|
179
|
+
throw new TaskValidationOperationError(
|
|
180
|
+
"validation-failed",
|
|
181
|
+
"The daemon is shutting down and cannot start a validation run.",
|
|
182
|
+
503
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (activeRuns.has(taskId)) {
|
|
186
|
+
throw new TaskValidationOperationError(
|
|
187
|
+
"validation-running",
|
|
188
|
+
"A validation is already running for this task. Wait for it to finish.",
|
|
189
|
+
409
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const active = { run: null };
|
|
193
|
+
activeRuns.set(taskId, active);
|
|
194
|
+
try {
|
|
195
|
+
let errorResult2 = function(message) {
|
|
196
|
+
return {
|
|
197
|
+
command,
|
|
198
|
+
outcome: "error",
|
|
199
|
+
exitCode: null,
|
|
200
|
+
signal: null,
|
|
201
|
+
output: message,
|
|
202
|
+
outputTruncated: false,
|
|
203
|
+
omittedOutputChars: 0,
|
|
204
|
+
startedAtMs: attemptStartedAtMs,
|
|
205
|
+
finishedAtMs: Math.max(now(), attemptStartedAtMs),
|
|
206
|
+
stateFingerprint: preState.stateFingerprint
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
var errorResult = errorResult2;
|
|
210
|
+
if (options.isArchiveActive?.(taskId) === true) {
|
|
211
|
+
throw new TaskValidationOperationError(
|
|
212
|
+
"archive-in-progress",
|
|
213
|
+
"This task is being archived or restored. Wait for that operation to finish.",
|
|
214
|
+
409
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (options.isCommitActive?.(taskId) === true) {
|
|
218
|
+
throw new TaskValidationOperationError(
|
|
219
|
+
"commit-in-progress",
|
|
220
|
+
"A commit preflight or execution is active for this task. Wait for it to finish before validating.",
|
|
221
|
+
409
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (options.isMergeActive?.(taskId) === true) {
|
|
225
|
+
throw new TaskValidationOperationError(
|
|
226
|
+
"merge-in-progress",
|
|
227
|
+
"A merge preflight or execution is active for this task. Wait for it to finish before validating.",
|
|
228
|
+
409
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (options.isAgentWorking !== void 0) {
|
|
232
|
+
let working;
|
|
233
|
+
try {
|
|
234
|
+
working = await options.isAgentWorking(taskId);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
throw new TaskValidationOperationError(
|
|
237
|
+
"validation-failed",
|
|
238
|
+
"The task's runtime state could not be verified. Check the daemon output for details.",
|
|
239
|
+
503,
|
|
240
|
+
{ cause: error }
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (working) {
|
|
244
|
+
throw new TaskValidationOperationError(
|
|
245
|
+
"agent-working",
|
|
246
|
+
"The task's agent is still working. Wait for the turn to settle before validating.",
|
|
247
|
+
409
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const task = await resolveTask(taskId);
|
|
252
|
+
const repository = await resolveRepository(task);
|
|
253
|
+
const { canonicalWorktree, state: preState } = await captureVerifiedState(task, repository);
|
|
254
|
+
const attemptStartedAtMs = now();
|
|
255
|
+
async function recordCompletedResult(result2) {
|
|
256
|
+
try {
|
|
257
|
+
await options.recordValidation(taskId, result2);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw new TaskValidationOperationError(
|
|
260
|
+
"validation-failed",
|
|
261
|
+
"The validation finished but its result could not be recorded. Check the daemon output for details.",
|
|
262
|
+
503,
|
|
263
|
+
{ cause: error }
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
let stale = true;
|
|
267
|
+
try {
|
|
268
|
+
const post = await capture({ worktreePath: canonicalWorktree });
|
|
269
|
+
stale = post.stateFingerprint !== preState.stateFingerprint;
|
|
270
|
+
} catch {
|
|
271
|
+
stale = true;
|
|
272
|
+
}
|
|
273
|
+
return payload(task.id, result2, false, stale);
|
|
274
|
+
}
|
|
275
|
+
if (disposing) {
|
|
276
|
+
throw new TaskValidationOperationError(
|
|
277
|
+
"validation-failed",
|
|
278
|
+
"The daemon is shutting down and cannot start a validation run.",
|
|
279
|
+
503
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
let validationRun;
|
|
283
|
+
try {
|
|
284
|
+
validationRun = startRun({
|
|
285
|
+
worktreePath: canonicalWorktree,
|
|
286
|
+
command,
|
|
287
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
288
|
+
now
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (isValidationRunnerError(error, "VALIDATION_COMMAND_INVALID")) {
|
|
292
|
+
throw new TaskValidationOperationError(
|
|
293
|
+
"invalid-request",
|
|
294
|
+
"Provide a non-empty validation command within Webdesk's length bound and without unsafe invisible characters.",
|
|
295
|
+
400,
|
|
296
|
+
{ cause: error }
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return recordCompletedResult(errorResult2("The validation process could not be started."));
|
|
300
|
+
}
|
|
301
|
+
active.run = validationRun;
|
|
302
|
+
let raw;
|
|
303
|
+
try {
|
|
304
|
+
raw = await validationRun.result;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (isValidationRunnerError(error, "VALIDATION_DISPOSED")) {
|
|
307
|
+
throw new TaskValidationOperationError(
|
|
308
|
+
"validation-failed",
|
|
309
|
+
"The validation run was stopped because the daemon is shutting down.",
|
|
310
|
+
503,
|
|
311
|
+
{ cause: error }
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return recordCompletedResult(
|
|
315
|
+
errorResult2("The validation process failed before producing a result.")
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const display = boundedOutput(raw.output, raw.omittedOutputChars);
|
|
319
|
+
const result = {
|
|
320
|
+
command,
|
|
321
|
+
outcome: raw.outcome,
|
|
322
|
+
exitCode: raw.exitCode,
|
|
323
|
+
signal: raw.signal,
|
|
324
|
+
output: display.text,
|
|
325
|
+
outputTruncated: display.omitted > 0,
|
|
326
|
+
omittedOutputChars: display.omitted,
|
|
327
|
+
startedAtMs: raw.startedAtMs,
|
|
328
|
+
finishedAtMs: raw.finishedAtMs,
|
|
329
|
+
stateFingerprint: preState.stateFingerprint
|
|
330
|
+
};
|
|
331
|
+
return recordCompletedResult(result);
|
|
332
|
+
} finally {
|
|
333
|
+
activeRuns.delete(taskId);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function disposeAll() {
|
|
337
|
+
disposing = true;
|
|
338
|
+
await Promise.all(
|
|
339
|
+
[...activeRuns.values()].map((active) => active.run?.dispose() ?? Promise.resolve())
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
status,
|
|
344
|
+
run,
|
|
345
|
+
isRunning: (taskId) => activeRuns.has(taskId),
|
|
346
|
+
disposeAll
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
export {
|
|
350
|
+
TaskValidationOperationError,
|
|
351
|
+
createTaskValidationService
|
|
352
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// apps/daemon/src/workspace-store.ts
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
WORKSPACE_SCHEMA_VERSION,
|
|
7
|
+
workspaceStateV1Schema,
|
|
8
|
+
workspaceStateV2Schema,
|
|
9
|
+
workspaceStateV3Schema,
|
|
10
|
+
workspaceStateV4Schema,
|
|
11
|
+
workspaceStateSchema
|
|
12
|
+
} from "../../../packages/protocol/src/index.js";
|
|
13
|
+
var WORKSPACE_STATE_FILE = "workspace-v1.json";
|
|
14
|
+
var STALE_TEMP_FILE = /^\.workspace-v1\.json\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i;
|
|
15
|
+
var WorkspaceStateError = class extends Error {
|
|
16
|
+
name = "WorkspaceStateError";
|
|
17
|
+
};
|
|
18
|
+
function emptyState() {
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
|
21
|
+
repositories: [],
|
|
22
|
+
tasks: []
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function errorCode(error) {
|
|
26
|
+
return error !== null && typeof error === "object" && "code" in error ? String(error.code) : void 0;
|
|
27
|
+
}
|
|
28
|
+
function createWorkspaceStore(options) {
|
|
29
|
+
const stateDir = path.resolve(options.stateDir);
|
|
30
|
+
const filePath = path.join(stateDir, WORKSPACE_STATE_FILE);
|
|
31
|
+
let state = null;
|
|
32
|
+
let initialization = null;
|
|
33
|
+
let mutationQueue = Promise.resolve();
|
|
34
|
+
async function persist(next) {
|
|
35
|
+
await mkdir(stateDir, { recursive: true, mode: 448 });
|
|
36
|
+
const tempPath = path.join(
|
|
37
|
+
stateDir,
|
|
38
|
+
`.${WORKSPACE_STATE_FILE}.${process.pid}.${randomUUID()}.tmp`
|
|
39
|
+
);
|
|
40
|
+
let handle = null;
|
|
41
|
+
try {
|
|
42
|
+
handle = await open(tempPath, "wx", 384);
|
|
43
|
+
await handle.writeFile(`${JSON.stringify(next, null, 2)}
|
|
44
|
+
`, "utf8");
|
|
45
|
+
await handle.sync();
|
|
46
|
+
await handle.close();
|
|
47
|
+
handle = null;
|
|
48
|
+
await rename(tempPath, filePath);
|
|
49
|
+
let directory = null;
|
|
50
|
+
try {
|
|
51
|
+
directory = await open(stateDir, "r");
|
|
52
|
+
await directory.sync();
|
|
53
|
+
} catch {
|
|
54
|
+
} finally {
|
|
55
|
+
await directory?.close().catch(() => void 0);
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await handle?.close().catch(() => void 0);
|
|
59
|
+
await unlink(tempPath).catch(() => void 0);
|
|
60
|
+
throw new WorkspaceStateError(
|
|
61
|
+
`Could not atomically write Webdesk workspace metadata at ${filePath}: ${errorCode(error) ?? "write failed"}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function load() {
|
|
66
|
+
await mkdir(stateDir, { recursive: true, mode: 448 });
|
|
67
|
+
for (const name of await readdir(stateDir)) {
|
|
68
|
+
if (STALE_TEMP_FILE.test(name)) {
|
|
69
|
+
await unlink(path.join(stateDir, name)).catch(() => void 0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await readFile(filePath, "utf8");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (errorCode(error) === "ENOENT") {
|
|
77
|
+
const initial = emptyState();
|
|
78
|
+
await persist(initial);
|
|
79
|
+
state = initial;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
throw new WorkspaceStateError(
|
|
83
|
+
`Could not read Webdesk workspace metadata at ${filePath}: ${errorCode(error) ?? "read failed"}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const decoded = JSON.parse(raw);
|
|
88
|
+
const current = workspaceStateSchema.safeParse(decoded);
|
|
89
|
+
if (current.success) {
|
|
90
|
+
state = current.data;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const v4 = workspaceStateV4Schema.safeParse(decoded);
|
|
94
|
+
const v3 = v4.success ? v4 : workspaceStateV3Schema.safeParse(decoded);
|
|
95
|
+
const v2 = v3.success ? v3 : workspaceStateV2Schema.safeParse(decoded);
|
|
96
|
+
const legacy = v2.success ? v2 : workspaceStateV1Schema.safeParse(decoded);
|
|
97
|
+
if (!legacy.success) throw new Error("unsupported workspace schema");
|
|
98
|
+
const migrated = workspaceStateSchema.parse({
|
|
99
|
+
...legacy.data,
|
|
100
|
+
schemaVersion: WORKSPACE_SCHEMA_VERSION
|
|
101
|
+
});
|
|
102
|
+
await persist(migrated);
|
|
103
|
+
state = migrated;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof WorkspaceStateError) throw error;
|
|
106
|
+
throw new WorkspaceStateError(
|
|
107
|
+
`Webdesk workspace metadata at ${filePath} is invalid. The file was preserved for recovery.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function initialize() {
|
|
112
|
+
initialization ??= load();
|
|
113
|
+
return initialization;
|
|
114
|
+
}
|
|
115
|
+
async function read() {
|
|
116
|
+
await initialize();
|
|
117
|
+
return workspaceStateSchema.parse(state);
|
|
118
|
+
}
|
|
119
|
+
async function update(mutate) {
|
|
120
|
+
const operation = mutationQueue.then(async () => {
|
|
121
|
+
await initialize();
|
|
122
|
+
const current = workspaceStateSchema.parse(state);
|
|
123
|
+
const next = workspaceStateSchema.parse(await mutate(current));
|
|
124
|
+
await persist(next);
|
|
125
|
+
state = next;
|
|
126
|
+
return workspaceStateSchema.parse(next);
|
|
127
|
+
});
|
|
128
|
+
mutationQueue = operation.then(
|
|
129
|
+
() => void 0,
|
|
130
|
+
() => void 0
|
|
131
|
+
);
|
|
132
|
+
return operation;
|
|
133
|
+
}
|
|
134
|
+
return { filePath, initialize, read, update };
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
WORKSPACE_STATE_FILE,
|
|
138
|
+
WorkspaceStateError,
|
|
139
|
+
createWorkspaceStore
|
|
140
|
+
};
|