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.
Files changed (64) hide show
  1. package/README.md +111 -0
  2. package/dist/apps/daemon/src/appearance-preferences.js +218 -0
  3. package/dist/apps/daemon/src/auth.js +88 -0
  4. package/dist/apps/daemon/src/bin.js +123 -0
  5. package/dist/apps/daemon/src/cli.js +48 -0
  6. package/dist/apps/daemon/src/event-hub.js +155 -0
  7. package/dist/apps/daemon/src/index.js +102 -0
  8. package/dist/apps/daemon/src/launcher-control.js +114 -0
  9. package/dist/apps/daemon/src/launcher.js +73 -0
  10. package/dist/apps/daemon/src/pi-auth.js +290 -0
  11. package/dist/apps/daemon/src/pi-resources.js +182 -0
  12. package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
  13. package/dist/apps/daemon/src/pi-sessions.js +265 -0
  14. package/dist/apps/daemon/src/runtime-process.js +241 -0
  15. package/dist/apps/daemon/src/secret.js +71 -0
  16. package/dist/apps/daemon/src/server.js +1662 -0
  17. package/dist/apps/daemon/src/session-projection.js +117 -0
  18. package/dist/apps/daemon/src/state-lock.js +31 -0
  19. package/dist/apps/daemon/src/static-web.js +53 -0
  20. package/dist/apps/daemon/src/task-archive.js +152 -0
  21. package/dist/apps/daemon/src/task-commit.js +503 -0
  22. package/dist/apps/daemon/src/task-merge.js +912 -0
  23. package/dist/apps/daemon/src/task-review.js +204 -0
  24. package/dist/apps/daemon/src/task-runtime.js +1124 -0
  25. package/dist/apps/daemon/src/task-validation.js +352 -0
  26. package/dist/apps/daemon/src/workspace-store.js +140 -0
  27. package/dist/apps/daemon/src/workspace.js +795 -0
  28. package/dist/extensions/webdesk.js +34 -0
  29. package/dist/packages/git/src/commit.js +675 -0
  30. package/dist/packages/git/src/errors.js +55 -0
  31. package/dist/packages/git/src/fingerprint.js +286 -0
  32. package/dist/packages/git/src/index.js +123 -0
  33. package/dist/packages/git/src/merge.js +1008 -0
  34. package/dist/packages/git/src/paths.js +58 -0
  35. package/dist/packages/git/src/repository.js +77 -0
  36. package/dist/packages/git/src/review.js +396 -0
  37. package/dist/packages/git/src/runner.js +110 -0
  38. package/dist/packages/git/src/validation.js +263 -0
  39. package/dist/packages/git/src/worktree.js +233 -0
  40. package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
  41. package/dist/packages/pi-bridge/src/auth.js +80 -0
  42. package/dist/packages/pi-bridge/src/errors.js +19 -0
  43. package/dist/packages/pi-bridge/src/handshake.js +43 -0
  44. package/dist/packages/pi-bridge/src/index.js +76 -0
  45. package/dist/packages/pi-bridge/src/jsonl.js +105 -0
  46. package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
  47. package/dist/packages/pi-bridge/src/resolve.js +59 -0
  48. package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
  49. package/dist/packages/pi-bridge/src/resources.js +481 -0
  50. package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
  51. package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
  52. package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
  53. package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
  54. package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
  55. package/dist/packages/pi-bridge/src/runtime.js +0 -0
  56. package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
  57. package/dist/packages/pi-bridge/src/sessions.js +314 -0
  58. package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
  59. package/dist/packages/protocol/src/index.js +1863 -0
  60. package/dist/web/assets/index-BOw_fhvO.css +2 -0
  61. package/dist/web/assets/index-oXs7yAAo.js +119 -0
  62. package/dist/web/index.html +14 -0
  63. package/package.json +69 -0
  64. package/scripts/prepare.mjs +7 -0
@@ -0,0 +1,795 @@
1
+ // apps/daemon/src/workspace.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, mkdir, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import {
6
+ GitWorktreeIncompleteError,
7
+ createTaskWorktree,
8
+ inspectRepository,
9
+ isGitServiceError,
10
+ preflightTaskWorktree
11
+ } from "../../../packages/git/src/index.js";
12
+ import {
13
+ PROTOCOL_VERSION,
14
+ workspaceSnapshotSchema,
15
+ workspaceStateSchema
16
+ } from "../../../packages/protocol/src/index.js";
17
+ import { createWorkspaceStore } from "./workspace-store.js";
18
+ var WorkspaceOperationError = class extends Error {
19
+ name = "WorkspaceOperationError";
20
+ code;
21
+ status;
22
+ constructor(code, message, status, options) {
23
+ super(message, options);
24
+ this.code = code;
25
+ this.status = status;
26
+ }
27
+ };
28
+ function snapshot(state) {
29
+ return workspaceSnapshotSchema.parse({
30
+ protocol: PROTOCOL_VERSION,
31
+ type: "workspace-snapshot",
32
+ state
33
+ });
34
+ }
35
+ function slugify(title) {
36
+ const slug = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
37
+ return slug || "task";
38
+ }
39
+ var CONFLICT_CODES = /* @__PURE__ */ new Set([
40
+ "GIT_BRANCH_EXISTS",
41
+ "GIT_WORKTREE_TARGET_EXISTS",
42
+ "GIT_WORKTREE_TARGET_REGISTERED"
43
+ ]);
44
+ function gitError(error) {
45
+ if (error instanceof GitWorktreeIncompleteError) {
46
+ return new WorkspaceOperationError(
47
+ "git-incomplete",
48
+ "Git may have created only part of the task worktree. Webdesk preserved it for recovery.",
49
+ 409,
50
+ { cause: error }
51
+ );
52
+ }
53
+ if (isGitServiceError(error)) {
54
+ return new WorkspaceOperationError(
55
+ CONFLICT_CODES.has(error.code) ? "git-conflict" : "git-preflight-failed",
56
+ error.message,
57
+ CONFLICT_CODES.has(error.code) ? 409 : 422,
58
+ { cause: error }
59
+ );
60
+ }
61
+ if (error instanceof WorkspaceOperationError) {
62
+ return error;
63
+ }
64
+ return new WorkspaceOperationError(
65
+ "workspace-unavailable",
66
+ "The local workspace service could not complete the operation.",
67
+ 503,
68
+ { cause: error }
69
+ );
70
+ }
71
+ function failedTask(task, error) {
72
+ const mapped = gitError(error);
73
+ const recoveryFacts = error instanceof GitWorktreeIncompleteError ? error.facts : void 0;
74
+ return {
75
+ id: task.id,
76
+ repositoryId: task.repositoryId,
77
+ title: task.title,
78
+ branch: task.branch,
79
+ worktreePath: task.worktreePath,
80
+ baseCommit: task.baseCommit,
81
+ createdAtMs: task.createdAtMs,
82
+ status: "failed",
83
+ failure: {
84
+ code: mapped.code,
85
+ message: mapped.message,
86
+ ...recoveryFacts ? { recoveryFacts } : {}
87
+ }
88
+ };
89
+ }
90
+ function interruptedTask(task, targetExistsOnDisk) {
91
+ return {
92
+ ...task,
93
+ status: "failed",
94
+ failure: {
95
+ code: "interrupted-provisioning",
96
+ message: "The daemon stopped while this worktree was being created. Inspect the branch and path before recovering it.",
97
+ recoveryFacts: {
98
+ branch: task.branch,
99
+ worktreePath: task.worktreePath,
100
+ baseCommit: task.baseCommit,
101
+ branchExists: null,
102
+ targetRegisteredAsWorktree: null,
103
+ targetExistsOnDisk
104
+ }
105
+ }
106
+ };
107
+ }
108
+ function createWorkspaceService(options) {
109
+ const stateDir = path.resolve(options.stateDir);
110
+ const store = options.store ?? createWorkspaceStore({ stateDir });
111
+ const git = options.git ?? {
112
+ inspectRepository,
113
+ preflightTaskWorktree,
114
+ createTaskWorktree
115
+ };
116
+ const now = options.now ?? Date.now;
117
+ const createId = options.createId ?? randomUUID;
118
+ let initialization = null;
119
+ let canonicalStateRoot = null;
120
+ function expectedTaskWorktreePath(repositoryId, taskId) {
121
+ if (canonicalStateRoot === null) {
122
+ throw new WorkspaceOperationError(
123
+ "workspace-unavailable",
124
+ "Webdesk could not resolve its canonical state directory.",
125
+ 503
126
+ );
127
+ }
128
+ return path.join(canonicalStateRoot, "worktrees", repositoryId, taskId);
129
+ }
130
+ async function ensureRealOwnedDirectory(directoryPath) {
131
+ try {
132
+ await mkdir(directoryPath, { mode: 448 });
133
+ } catch (error) {
134
+ if (error.code !== "EEXIST") throw error;
135
+ }
136
+ const state = await lstat(directoryPath);
137
+ if (!state.isDirectory() || state.isSymbolicLink()) {
138
+ throw new Error(`${directoryPath} is not a real directory`);
139
+ }
140
+ if (await realpath(directoryPath) !== directoryPath) {
141
+ throw new Error(`${directoryPath} resolves outside its canonical location`);
142
+ }
143
+ }
144
+ async function prepareTaskWorktreeParent(repositoryId) {
145
+ if (canonicalStateRoot === null) {
146
+ throw new WorkspaceOperationError(
147
+ "workspace-unavailable",
148
+ "Webdesk could not resolve its canonical state directory.",
149
+ 503
150
+ );
151
+ }
152
+ try {
153
+ const worktreesRoot = path.join(canonicalStateRoot, "worktrees");
154
+ await ensureRealOwnedDirectory(worktreesRoot);
155
+ await ensureRealOwnedDirectory(path.join(worktreesRoot, repositoryId));
156
+ } catch (error) {
157
+ throw new WorkspaceOperationError(
158
+ "git-preflight-failed",
159
+ "Webdesk refused the task because its daemon-owned worktree parent is missing, symlinked, or outside the canonical state directory.",
160
+ 409,
161
+ { cause: error }
162
+ );
163
+ }
164
+ }
165
+ async function reconcileProvisioningTask(task, state, canonicalStateDir) {
166
+ let canonicalWorktreePath;
167
+ try {
168
+ canonicalWorktreePath = await realpath(task.worktreePath);
169
+ } catch (error) {
170
+ const code = error.code;
171
+ return interruptedTask(task, code === "ENOENT" || code === "ENOTDIR" ? false : null);
172
+ }
173
+ const expectedWorktreePath = path.join(
174
+ canonicalStateDir,
175
+ "worktrees",
176
+ task.repositoryId,
177
+ task.id
178
+ );
179
+ if (canonicalWorktreePath !== expectedWorktreePath) return interruptedTask(task, true);
180
+ const repository = state.repositories.find((record) => record.id === task.repositoryId);
181
+ if (!repository) return interruptedTask(task, true);
182
+ try {
183
+ const worktree = await git.inspectRepository(canonicalWorktreePath);
184
+ if (worktree.root === canonicalWorktreePath && worktree.commonDir === repository.commonDir && worktree.branch === task.branch && worktree.headCommit === task.baseCommit) {
185
+ return { ...task, worktreePath: canonicalWorktreePath, status: "ready" };
186
+ }
187
+ } catch {
188
+ }
189
+ return interruptedTask(task, true);
190
+ }
191
+ async function canonicalizeRecordedTaskPath(task, state, canonicalStateDir) {
192
+ if (task.status !== "ready" && task.status !== "archived") return task;
193
+ const expectedWorktreePath = path.join(
194
+ canonicalStateDir,
195
+ "worktrees",
196
+ task.repositoryId,
197
+ task.id
198
+ );
199
+ const legacyConfiguredPath = path.join(
200
+ stateDir,
201
+ "worktrees",
202
+ task.repositoryId,
203
+ task.id
204
+ );
205
+ let canonicalWorktreePath;
206
+ try {
207
+ canonicalWorktreePath = await realpath(task.worktreePath);
208
+ } catch {
209
+ if (task.worktreePath !== expectedWorktreePath && task.worktreePath !== legacyConfiguredPath) {
210
+ throw new WorkspaceOperationError(
211
+ "workspace-unavailable",
212
+ `Task ${task.id} records a path outside its daemon-owned worktree slot. Webdesk preserved the workspace metadata and refused startup.`,
213
+ 503
214
+ );
215
+ }
216
+ return task;
217
+ }
218
+ if (canonicalWorktreePath !== expectedWorktreePath) {
219
+ throw new WorkspaceOperationError(
220
+ "workspace-unavailable",
221
+ `Task ${task.id} resolves outside its daemon-owned worktree path. Webdesk preserved the workspace metadata and refused startup.`,
222
+ 503
223
+ );
224
+ }
225
+ if (canonicalWorktreePath === task.worktreePath) return task;
226
+ const repository = state.repositories.find((record) => record.id === task.repositoryId);
227
+ if (repository === void 0) return task;
228
+ try {
229
+ const worktree = await git.inspectRepository(canonicalWorktreePath);
230
+ if (worktree.root === canonicalWorktreePath && worktree.commonDir === repository.commonDir && worktree.branch === task.branch) {
231
+ return { ...task, worktreePath: canonicalWorktreePath };
232
+ }
233
+ } catch {
234
+ }
235
+ return task;
236
+ }
237
+ async function verifyTaskWorktree(taskId) {
238
+ await initialize();
239
+ const state = await store.read();
240
+ const task = state.tasks.find((candidate) => candidate.id === taskId);
241
+ if (task === void 0 || task.status !== "ready") {
242
+ throw new WorkspaceOperationError(
243
+ task === void 0 ? "task-not-found" : "task-not-ready",
244
+ "Only a ready task can start Pi.",
245
+ task === void 0 ? 404 : 409
246
+ );
247
+ }
248
+ const expectedWorktreePath = expectedTaskWorktreePath(task.repositoryId, task.id);
249
+ if (task.worktreePath !== expectedWorktreePath) {
250
+ throw new WorkspaceOperationError(
251
+ "task-recovery-required",
252
+ "The task worktree is not recorded at its daemon-owned canonical path.",
253
+ 409
254
+ );
255
+ }
256
+ const repository = state.repositories.find((candidate) => candidate.id === task.repositoryId);
257
+ if (repository === void 0) {
258
+ throw new WorkspaceOperationError(
259
+ "task-recovery-required",
260
+ "The task's registered repository is unavailable.",
261
+ 409
262
+ );
263
+ }
264
+ let canonicalWorktreePath;
265
+ let worktree;
266
+ try {
267
+ canonicalWorktreePath = await realpath(task.worktreePath);
268
+ if (canonicalWorktreePath !== expectedWorktreePath) throw new Error("worktree retargeted");
269
+ worktree = await git.inspectRepository(canonicalWorktreePath);
270
+ if (await realpath(task.worktreePath) !== canonicalWorktreePath) {
271
+ throw new Error("worktree changed while it was being verified");
272
+ }
273
+ } catch (error) {
274
+ throw new WorkspaceOperationError(
275
+ "task-recovery-required",
276
+ "The task worktree is unavailable or no longer at its daemon-owned canonical path.",
277
+ 409,
278
+ { cause: error }
279
+ );
280
+ }
281
+ if (worktree.root !== canonicalWorktreePath || worktree.commonDir !== repository.commonDir || worktree.branch !== task.branch) {
282
+ throw new WorkspaceOperationError(
283
+ "task-recovery-required",
284
+ "The task worktree no longer matches its recorded repository or branch.",
285
+ 409
286
+ );
287
+ }
288
+ return canonicalWorktreePath;
289
+ }
290
+ function initialize() {
291
+ initialization ??= (async () => {
292
+ await store.initialize();
293
+ const current = await store.read();
294
+ const canonicalStateDir = await realpath(stateDir);
295
+ canonicalStateRoot = canonicalStateDir;
296
+ const reconciled = await Promise.all(
297
+ current.tasks.map(async (task) => {
298
+ if (task.status === "provisioning") {
299
+ return reconcileProvisioningTask(task, current, canonicalStateDir);
300
+ }
301
+ return canonicalizeRecordedTaskPath(task, current, canonicalStateDir);
302
+ })
303
+ );
304
+ if (reconciled.every((task, index) => task === current.tasks[index])) return;
305
+ const byId = new Map(reconciled.map((task) => [task.id, task]));
306
+ await store.update((state) => ({
307
+ ...state,
308
+ tasks: state.tasks.map((task) => byId.get(task.id) ?? task)
309
+ }));
310
+ })();
311
+ return initialization;
312
+ }
313
+ async function registerRepository(request) {
314
+ await initialize();
315
+ let repository;
316
+ try {
317
+ repository = await git.inspectRepository(request.path);
318
+ } catch (error) {
319
+ throw gitError(error);
320
+ }
321
+ const next = await store.update((current) => {
322
+ const existing = current.repositories.find(
323
+ (record) => record.commonDir === repository.commonDir
324
+ );
325
+ if (existing) {
326
+ if (existing.rootPath !== repository.root) {
327
+ throw new WorkspaceOperationError(
328
+ "repository-already-registered",
329
+ `This Git repository is already registered through ${existing.rootPath}.`,
330
+ 409
331
+ );
332
+ }
333
+ return {
334
+ ...current,
335
+ repositories: current.repositories.map(
336
+ (record) => record.id === existing.id ? {
337
+ ...record,
338
+ headCommit: repository.headCommit,
339
+ branch: repository.branch
340
+ } : record
341
+ )
342
+ };
343
+ }
344
+ return {
345
+ ...current,
346
+ repositories: [
347
+ ...current.repositories,
348
+ {
349
+ id: createId(),
350
+ rootPath: repository.root,
351
+ commonDir: repository.commonDir,
352
+ displayName: path.basename(repository.root) || repository.root,
353
+ headCommit: repository.headCommit,
354
+ branch: repository.branch,
355
+ createdAtMs: now()
356
+ }
357
+ ]
358
+ };
359
+ });
360
+ return snapshot(next);
361
+ }
362
+ async function provisionTask(request, prepared) {
363
+ await initialize();
364
+ const current = await store.read();
365
+ const repository = current.repositories.find((record) => record.id === request.repositoryId);
366
+ if (!repository) {
367
+ throw new WorkspaceOperationError(
368
+ "repository-not-found",
369
+ "The selected repository is no longer registered.",
370
+ 404
371
+ );
372
+ }
373
+ const taskId = prepared?.taskId ?? createId();
374
+ if (current.tasks.some((task) => task.id === taskId)) {
375
+ throw new WorkspaceOperationError(
376
+ "workspace-unavailable",
377
+ "Webdesk could not reserve a unique task identity.",
378
+ 503
379
+ );
380
+ }
381
+ const branch = `pita/${slugify(request.title)}-${taskId.slice(0, 8)}`;
382
+ const expectedWorktreePath = expectedTaskWorktreePath(repository.id, taskId);
383
+ await prepareTaskWorktreeParent(repository.id);
384
+ const worktreePath = expectedWorktreePath;
385
+ let plan;
386
+ try {
387
+ plan = await git.preflightTaskWorktree({
388
+ repositoryPath: repository.rootPath,
389
+ baseRef: request.baseRef,
390
+ branch,
391
+ worktreePath
392
+ });
393
+ } catch (error) {
394
+ throw gitError(error);
395
+ }
396
+ const provisioning = {
397
+ id: taskId,
398
+ repositoryId: repository.id,
399
+ title: request.title.trim(),
400
+ branch,
401
+ worktreePath,
402
+ baseCommit: plan.baseCommit,
403
+ createdAtMs: now(),
404
+ status: "provisioning"
405
+ };
406
+ await store.update((state) => ({
407
+ ...state,
408
+ tasks: [...state.tasks, provisioning]
409
+ }));
410
+ try {
411
+ const created = await git.createTaskWorktree({
412
+ repositoryPath: repository.rootPath,
413
+ baseRef: plan.baseCommit,
414
+ branch,
415
+ worktreePath
416
+ });
417
+ if (created.worktree.root !== expectedWorktreePath) {
418
+ throw new GitWorktreeIncompleteError(
419
+ "created worktree escaped the daemon-owned canonical state directory; nothing was cleaned up automatically",
420
+ {
421
+ branch,
422
+ worktreePath,
423
+ baseCommit: plan.baseCommit,
424
+ branchExists: true,
425
+ targetRegisteredAsWorktree: true,
426
+ targetExistsOnDisk: true
427
+ }
428
+ );
429
+ }
430
+ provisioning.worktreePath = created.worktree.root;
431
+ } catch (error) {
432
+ try {
433
+ await store.update((state) => ({
434
+ ...state,
435
+ tasks: state.tasks.map((task) => task.id === taskId ? failedTask(task, error) : task)
436
+ }));
437
+ } catch {
438
+ throw gitError(error);
439
+ }
440
+ throw gitError(error);
441
+ }
442
+ let piSessionFile;
443
+ let preparationError = null;
444
+ if (prepared !== void 0) {
445
+ try {
446
+ const result = await prepared.prepare({ ...provisioning, status: "provisioning" });
447
+ if (!path.isAbsolute(result.piSessionFile)) {
448
+ throw new Error("Pi returned a non-absolute child session path");
449
+ }
450
+ piSessionFile = path.normalize(result.piSessionFile);
451
+ } catch (error) {
452
+ preparationError = error;
453
+ }
454
+ }
455
+ let ready;
456
+ try {
457
+ ready = await store.update((state) => ({
458
+ ...state,
459
+ tasks: state.tasks.map(
460
+ (task) => task.id === taskId ? {
461
+ ...provisioning,
462
+ status: "ready",
463
+ ...piSessionFile === void 0 ? {} : { piSessionFile }
464
+ } : task
465
+ )
466
+ }));
467
+ } catch (error) {
468
+ throw new WorkspaceOperationError(
469
+ "workspace-unavailable",
470
+ "The worktree was created and verified, but Webdesk could not record it as ready. Restart the daemon to reconcile the task safely.",
471
+ 503,
472
+ { cause: error }
473
+ );
474
+ }
475
+ return {
476
+ snapshot: snapshot(workspaceStateSchema.parse(ready)),
477
+ taskId,
478
+ preparationError
479
+ };
480
+ }
481
+ async function createTask(request) {
482
+ return (await provisionTask(request)).snapshot;
483
+ }
484
+ async function createPreparedTask(request, options2) {
485
+ return provisionTask(request, options2);
486
+ }
487
+ async function archiveTask(taskId) {
488
+ await initialize();
489
+ const next = await store.update((state) => {
490
+ const taskIndex = state.tasks.findIndex((candidate) => candidate.id === taskId);
491
+ const task = state.tasks[taskIndex];
492
+ if (task === void 0) {
493
+ throw new WorkspaceOperationError(
494
+ "task-not-found",
495
+ "The selected task no longer exists.",
496
+ 404
497
+ );
498
+ }
499
+ if (task.status === "archived") return state;
500
+ if (task.status !== "ready") {
501
+ throw new WorkspaceOperationError(
502
+ "task-not-ready",
503
+ `Only a ready task can be archived; this task is ${task.status}.`,
504
+ 409
505
+ );
506
+ }
507
+ if (task.runtimeProcess !== void 0) {
508
+ throw new WorkspaceOperationError(
509
+ "task-recovery-required",
510
+ "Webdesk could not prove that the task runtime stopped cleanly, so the task was not archived.",
511
+ 409
512
+ );
513
+ }
514
+ const archivedAtMs = now();
515
+ const { status: _status, runtimeProcess: _runtimeProcess, ...preserved } = task;
516
+ const archived = {
517
+ ...preserved,
518
+ status: "archived",
519
+ archivedAtMs
520
+ };
521
+ return {
522
+ ...state,
523
+ tasks: state.tasks.map((candidate, index) => index === taskIndex ? archived : candidate)
524
+ };
525
+ });
526
+ return snapshot(next);
527
+ }
528
+ async function restoreTask(taskId, archivedAtMs) {
529
+ await initialize();
530
+ const next = await store.update((state) => {
531
+ const taskIndex = state.tasks.findIndex((candidate) => candidate.id === taskId);
532
+ const task = state.tasks[taskIndex];
533
+ if (task === void 0) {
534
+ throw new WorkspaceOperationError(
535
+ "task-not-found",
536
+ "The selected task no longer exists.",
537
+ 404
538
+ );
539
+ }
540
+ if (task.status === "ready") return state;
541
+ if (task.status !== "archived") {
542
+ throw new WorkspaceOperationError(
543
+ "task-not-ready",
544
+ `Only an archived task can be restored; this task is ${task.status}.`,
545
+ 409
546
+ );
547
+ }
548
+ if (task.archivedAtMs !== archivedAtMs) {
549
+ throw new WorkspaceOperationError(
550
+ "task-busy",
551
+ "The archived task changed while restore was being verified. Retry from the refreshed workspace.",
552
+ 409
553
+ );
554
+ }
555
+ const { status: _status, archivedAtMs: _archivedAtMs, ...preserved } = task;
556
+ const restored = { ...preserved, status: "ready" };
557
+ return {
558
+ ...state,
559
+ tasks: state.tasks.map((candidate, index) => index === taskIndex ? restored : candidate)
560
+ };
561
+ });
562
+ return snapshot(next);
563
+ }
564
+ async function recordTaskSession(taskId, sessionFile) {
565
+ await initialize();
566
+ if (!path.isAbsolute(sessionFile)) {
567
+ throw new WorkspaceOperationError(
568
+ "workspace-unavailable",
569
+ "Pi reported a non-absolute session path, so Webdesk did not associate it with the task.",
570
+ 503
571
+ );
572
+ }
573
+ const normalizedSessionFile = path.normalize(sessionFile);
574
+ await store.update((state) => {
575
+ const task = state.tasks.find((candidate) => candidate.id === taskId);
576
+ if (task === void 0) {
577
+ throw new WorkspaceOperationError(
578
+ "workspace-unavailable",
579
+ "The task disappeared before its Pi session could be recorded.",
580
+ 503
581
+ );
582
+ }
583
+ if (task.status !== "ready") {
584
+ throw new WorkspaceOperationError(
585
+ "workspace-unavailable",
586
+ "Only a ready task can own a Pi session.",
587
+ 503
588
+ );
589
+ }
590
+ if (task.piSessionFile !== void 0 && task.piSessionFile !== normalizedSessionFile) {
591
+ throw new WorkspaceOperationError(
592
+ "workspace-unavailable",
593
+ "Pi reported a different session file for a task that already owns one.",
594
+ 503
595
+ );
596
+ }
597
+ if (task.piSessionFile === normalizedSessionFile) return state;
598
+ return {
599
+ ...state,
600
+ tasks: state.tasks.map(
601
+ (candidate) => candidate.id === taskId ? { ...candidate, piSessionFile: normalizedSessionFile } : candidate
602
+ )
603
+ };
604
+ });
605
+ }
606
+ async function claimTaskRuntimeGeneration(taskId) {
607
+ await initialize();
608
+ let claimedGeneration = null;
609
+ await store.update((state) => {
610
+ const task = state.tasks.find((candidate) => candidate.id === taskId);
611
+ if (task === void 0) {
612
+ throw new WorkspaceOperationError(
613
+ "workspace-unavailable",
614
+ "The task disappeared before its runtime generation could be reserved.",
615
+ 503
616
+ );
617
+ }
618
+ if (task.status !== "ready") {
619
+ throw new WorkspaceOperationError(
620
+ "workspace-unavailable",
621
+ "Only a ready task can reserve a runtime generation.",
622
+ 503
623
+ );
624
+ }
625
+ if (task.runtimeProcess !== void 0) {
626
+ throw new WorkspaceOperationError(
627
+ "workspace-unavailable",
628
+ "A previously supervised Pi process must be recovered before starting another runtime.",
629
+ 503
630
+ );
631
+ }
632
+ const nextGeneration = (task.runtimeGeneration ?? 0) + 1;
633
+ if (!Number.isSafeInteger(nextGeneration)) {
634
+ throw new WorkspaceOperationError(
635
+ "workspace-unavailable",
636
+ "The task runtime generation counter is exhausted.",
637
+ 503
638
+ );
639
+ }
640
+ claimedGeneration = nextGeneration;
641
+ return {
642
+ ...state,
643
+ tasks: state.tasks.map(
644
+ (candidate) => candidate.id === taskId ? { ...candidate, runtimeGeneration: nextGeneration } : candidate
645
+ )
646
+ };
647
+ });
648
+ if (claimedGeneration === null) {
649
+ throw new WorkspaceOperationError(
650
+ "workspace-unavailable",
651
+ "The runtime generation reservation did not complete.",
652
+ 503
653
+ );
654
+ }
655
+ return claimedGeneration;
656
+ }
657
+ async function recordTaskRuntimeProcess(taskId, process) {
658
+ await initialize();
659
+ await store.update((state) => {
660
+ const task = state.tasks.find((candidate) => candidate.id === taskId);
661
+ if (task === void 0 || task.status !== "ready") {
662
+ throw new WorkspaceOperationError(
663
+ "workspace-unavailable",
664
+ "Only a ready task can own a supervised Pi process.",
665
+ 503
666
+ );
667
+ }
668
+ if (task.runtimeGeneration !== process.runtimeGeneration) {
669
+ throw new WorkspaceOperationError(
670
+ "workspace-unavailable",
671
+ "The Pi process belongs to a stale runtime generation.",
672
+ 503
673
+ );
674
+ }
675
+ if (task.runtimeProcess !== void 0) {
676
+ if (task.runtimeProcess.runtimeGeneration === process.runtimeGeneration && task.runtimeProcess.pid === process.pid && task.runtimeProcess.startFingerprint === process.startFingerprint) {
677
+ return state;
678
+ }
679
+ throw new WorkspaceOperationError(
680
+ "workspace-unavailable",
681
+ "Another supervised Pi process is still recorded for this task.",
682
+ 503
683
+ );
684
+ }
685
+ return {
686
+ ...state,
687
+ tasks: state.tasks.map(
688
+ (candidate) => candidate.id === taskId ? { ...candidate, runtimeProcess: process } : candidate
689
+ )
690
+ };
691
+ });
692
+ }
693
+ async function clearTaskRuntimeProcess(taskId, generation, pid) {
694
+ await initialize();
695
+ await store.update((state) => {
696
+ const task = state.tasks.find((candidate) => candidate.id === taskId);
697
+ if (task === void 0 || task.status !== "ready" || task.runtimeProcess === void 0 || task.runtimeProcess.runtimeGeneration !== generation || task.runtimeProcess.pid !== pid) {
698
+ return state;
699
+ }
700
+ const { runtimeProcess: _removed, ...withoutProcess } = task;
701
+ return {
702
+ ...state,
703
+ tasks: state.tasks.map(
704
+ (candidate) => candidate.id === taskId ? withoutProcess : candidate
705
+ )
706
+ };
707
+ });
708
+ }
709
+ async function recordTaskValidation(taskId, result) {
710
+ await initialize();
711
+ await store.update((state) => {
712
+ const task = state.tasks.find((candidate) => candidate.id === taskId);
713
+ if (task === void 0 || task.status !== "ready") {
714
+ throw new WorkspaceOperationError(
715
+ "workspace-unavailable",
716
+ "Only a ready task can record a validation result.",
717
+ 503
718
+ );
719
+ }
720
+ return {
721
+ ...state,
722
+ tasks: state.tasks.map(
723
+ (candidate) => candidate.id === taskId ? { ...candidate, validation: result } : candidate
724
+ )
725
+ };
726
+ });
727
+ }
728
+ async function recordPendingMerge(repositoryId, record) {
729
+ await initialize();
730
+ await store.update((state) => {
731
+ const repository = state.repositories.find((candidate) => candidate.id === repositoryId);
732
+ if (repository === void 0) {
733
+ throw new WorkspaceOperationError(
734
+ "repository-not-found",
735
+ "The repository disappeared before its merge intent could be recorded.",
736
+ 409
737
+ );
738
+ }
739
+ if (repository.pendingMerge !== void 0 && repository.pendingMerge.preflightId !== record.preflightId) {
740
+ throw new WorkspaceOperationError(
741
+ "workspace-unavailable",
742
+ "Another interrupted merge is still pending recovery for this repository.",
743
+ 409
744
+ );
745
+ }
746
+ return {
747
+ ...state,
748
+ repositories: state.repositories.map(
749
+ (candidate) => candidate.id === repositoryId ? { ...candidate, pendingMerge: record } : candidate
750
+ )
751
+ };
752
+ });
753
+ }
754
+ async function clearPendingMerge(repositoryId, preflightId, resolvedHead) {
755
+ await initialize();
756
+ await store.update((state) => {
757
+ const repository = state.repositories.find((candidate) => candidate.id === repositoryId);
758
+ if (repository === void 0 || repository.pendingMerge === void 0 || repository.pendingMerge.preflightId !== preflightId) {
759
+ return state;
760
+ }
761
+ return {
762
+ ...state,
763
+ repositories: state.repositories.map((candidate) => {
764
+ if (candidate.id !== repositoryId) return candidate;
765
+ const { pendingMerge: _removed, ...withoutPending } = candidate;
766
+ return resolvedHead === void 0 ? withoutPending : { ...withoutPending, headCommit: resolvedHead };
767
+ })
768
+ };
769
+ });
770
+ }
771
+ return {
772
+ initialize,
773
+ async snapshot() {
774
+ await initialize();
775
+ return snapshot(await store.read());
776
+ },
777
+ registerRepository,
778
+ createTask,
779
+ createPreparedTask,
780
+ verifyTaskWorktree,
781
+ archiveTask,
782
+ restoreTask,
783
+ recordTaskSession,
784
+ claimTaskRuntimeGeneration,
785
+ recordTaskRuntimeProcess,
786
+ clearTaskRuntimeProcess,
787
+ recordTaskValidation,
788
+ recordPendingMerge,
789
+ clearPendingMerge
790
+ };
791
+ }
792
+ export {
793
+ WorkspaceOperationError,
794
+ createWorkspaceService
795
+ };