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,912 @@
1
+ // apps/daemon/src/task-merge.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import {
5
+ GitMergeConflictError,
6
+ assertMergeTargetPathsUnoccupied,
7
+ captureWorktreeState,
8
+ ensureMergeCapableGit,
9
+ isMergeCommitAncestor,
10
+ isGitServiceError,
11
+ prepareMergeTree,
12
+ publishMerge,
13
+ syncTargetCheckout
14
+ } from "../../../packages/git/src/index.js";
15
+ import { sanitizeApprovalDisplayText } from "../../../packages/pi-bridge/src/index.js";
16
+ import {
17
+ PROTOCOL_VERSION,
18
+ TASK_MERGE_MAX_COMMIT_SUBJECTS,
19
+ TASK_MERGE_MAX_COMMIT_SUBJECT_CHARS,
20
+ TASK_MERGE_MAX_CONFLICT_PATHS,
21
+ TASK_MERGE_MAX_FILE_DIFF_CHARS,
22
+ TASK_MERGE_MAX_TOTAL_DIFF_CHARS,
23
+ repositoryMergeSyncResultSchema,
24
+ taskMergePreflightSchema,
25
+ taskMergeResultSchema
26
+ } from "../../../packages/protocol/src/index.js";
27
+ var MERGE_PREFLIGHT_TTL_MS = 10 * 60 * 1e3;
28
+ var TaskMergeOperationError = class extends Error {
29
+ name = "TaskMergeOperationError";
30
+ code;
31
+ status;
32
+ /** Escaped, bounded conflicted paths; only for merge-conflicted. */
33
+ conflictPaths;
34
+ omittedConflictPaths;
35
+ constructor(code, message, status, options) {
36
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
37
+ this.code = code;
38
+ this.status = status;
39
+ if (options?.conflictPaths !== void 0) {
40
+ this.conflictPaths = options.conflictPaths;
41
+ this.omittedConflictPaths = options.omittedConflictPaths ?? 0;
42
+ }
43
+ }
44
+ };
45
+ var WORKTREE_UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
46
+ "GIT_PATH_NOT_FOUND",
47
+ "GIT_PATH_NOT_DIRECTORY",
48
+ "GIT_NOT_A_REPOSITORY",
49
+ "GIT_BARE_REPOSITORY",
50
+ "GIT_NOT_A_WORK_TREE",
51
+ "GIT_HEAD_UNRESOLVED"
52
+ ]);
53
+ var TASK_UNAVAILABLE_MESSAGE = "The task worktree is missing or is no longer a healthy Git worktree. Recover it before merging.";
54
+ var TASK_MISMATCH_MESSAGE = "The recorded worktree no longer matches the task's repository. Recover it before merging.";
55
+ var TARGET_UNAVAILABLE_MESSAGE = "The registered target checkout is missing or is no longer a healthy Git worktree. Re-register the repository before merging.";
56
+ var TARGET_MISMATCH_MESSAGE = "The registered target checkout is not on its recorded branch. Switch it back or re-register the repository before merging.";
57
+ var MAX_PATH_DISPLAY_CHARS = 4096;
58
+ function boundedDisplay(text, maxChars) {
59
+ const clippedInput = text.length > maxChars;
60
+ const safe = sanitizeApprovalDisplayText(text.slice(0, maxChars));
61
+ if (safe.length <= maxChars) return { text: safe, clipped: clippedInput };
62
+ return { text: safe.slice(0, maxChars), clipped: true };
63
+ }
64
+ function boundedPathDisplay(text) {
65
+ const bounded = boundedDisplay(text, MAX_PATH_DISPLAY_CHARS);
66
+ if (!bounded.clipped) return bounded.text;
67
+ return `${bounded.text.slice(0, MAX_PATH_DISPLAY_CHARS - 1)}\u2026`;
68
+ }
69
+ function boundedSubjectDisplay(subject) {
70
+ const bounded = boundedDisplay(subject, TASK_MERGE_MAX_COMMIT_SUBJECT_CHARS);
71
+ const text = bounded.clipped ? `${bounded.text.slice(0, TASK_MERGE_MAX_COMMIT_SUBJECT_CHARS - 1)}\u2026` : bounded.text;
72
+ return text.length === 0 ? "(no subject)" : text;
73
+ }
74
+ function createTaskMergeService(options) {
75
+ const capture = options.captureWorktreeState ?? captureWorktreeState;
76
+ const prepare = options.prepareMergeTree ?? prepareMergeTree;
77
+ const publish = options.publishMerge ?? publishMerge;
78
+ const sync = options.syncTargetCheckout ?? syncTargetCheckout;
79
+ const guardAddedPaths = options.assertMergeTargetPathsUnoccupied ?? assertMergeTargetPathsUnoccupied;
80
+ const ensureGitVersion = options.ensureMergeCapableGit ?? ensureMergeCapableGit;
81
+ const isAncestor = options.isMergeCommitAncestor ?? isMergeCommitAncestor;
82
+ const preflightTtlMs = options.preflightTtlMs ?? MERGE_PREFLIGHT_TTL_MS;
83
+ const now = options.now ?? Date.now;
84
+ const createId = options.createId ?? randomUUID;
85
+ const activePhases = /* @__PURE__ */ new Map();
86
+ const activeRepositories = /* @__PURE__ */ new Set();
87
+ const pendingPreflights = /* @__PURE__ */ new Map();
88
+ const inspectedRecoveryHeads = /* @__PURE__ */ new Map();
89
+ async function resolveTask(taskId) {
90
+ let task;
91
+ try {
92
+ task = await options.getTask(taskId);
93
+ } catch (error) {
94
+ throw new TaskMergeOperationError(
95
+ "merge-failed",
96
+ "The task could not be resolved from the local workspace.",
97
+ 503,
98
+ { cause: error }
99
+ );
100
+ }
101
+ if (task === null) {
102
+ throw new TaskMergeOperationError(
103
+ "task-not-found",
104
+ "The selected task no longer exists.",
105
+ 404
106
+ );
107
+ }
108
+ if (task.status !== "ready") {
109
+ throw new TaskMergeOperationError(
110
+ "task-not-ready",
111
+ `Only a ready task can be merged; this task is ${task.status}.`,
112
+ 409
113
+ );
114
+ }
115
+ return task;
116
+ }
117
+ async function resolveRepository(repositoryId) {
118
+ let repository;
119
+ try {
120
+ repository = await options.getRepository(repositoryId);
121
+ } catch (error) {
122
+ throw new TaskMergeOperationError(
123
+ "merge-failed",
124
+ "The repository could not be resolved from the local workspace.",
125
+ 503,
126
+ { cause: error }
127
+ );
128
+ }
129
+ if (repository === null) {
130
+ throw new TaskMergeOperationError(
131
+ "repository-not-found",
132
+ "The registered repository no longer exists.",
133
+ 404
134
+ );
135
+ }
136
+ return repository;
137
+ }
138
+ function claimTaskPhase(taskId, phase) {
139
+ if (activePhases.has(taskId)) {
140
+ throw new TaskMergeOperationError(
141
+ "merge-in-progress",
142
+ "A merge preflight or execution is already in progress for this task.",
143
+ 409
144
+ );
145
+ }
146
+ activePhases.set(taskId, phase);
147
+ }
148
+ function claimRepository(repositoryId) {
149
+ if (activeRepositories.has(repositoryId)) {
150
+ throw new TaskMergeOperationError(
151
+ "merge-in-progress",
152
+ "Another merge or merge recovery is already in progress for this repository.",
153
+ 409
154
+ );
155
+ }
156
+ activeRepositories.add(repositoryId);
157
+ }
158
+ function refuseBusyStates(taskId) {
159
+ if (options.isValidationRunning(taskId)) {
160
+ throw new TaskMergeOperationError(
161
+ "validation-running",
162
+ "A validation is running for this task. Wait for it to finish before merging.",
163
+ 409
164
+ );
165
+ }
166
+ if (options.isCommitActive(taskId)) {
167
+ throw new TaskMergeOperationError(
168
+ "commit-in-progress",
169
+ "A commit preflight or execution is active for this task. Wait for it to finish before merging.",
170
+ 409
171
+ );
172
+ }
173
+ }
174
+ async function refuseWorkingAgent(taskId) {
175
+ let working;
176
+ try {
177
+ working = await options.isAgentWorking(taskId);
178
+ } catch (error) {
179
+ throw new TaskMergeOperationError(
180
+ "merge-failed",
181
+ "The task's runtime state could not be verified. Check the daemon output for details.",
182
+ 503,
183
+ { cause: error }
184
+ );
185
+ }
186
+ if (working) {
187
+ throw new TaskMergeOperationError(
188
+ "agent-working",
189
+ "The task's agent is still working. Wait for the turn to settle before merging.",
190
+ 409
191
+ );
192
+ }
193
+ }
194
+ function refusePendingSync(repository) {
195
+ if (repository.pendingMerge !== void 0) {
196
+ throw new TaskMergeOperationError(
197
+ "sync-pending",
198
+ "An earlier merge requires reconciliation; publication or checkout sync may be incomplete. Reconcile it from the repository card first.",
199
+ 409
200
+ );
201
+ }
202
+ }
203
+ function mapGitError(error, side) {
204
+ if (error instanceof GitMergeConflictError) {
205
+ const retained = error.conflictPaths.slice(0, TASK_MERGE_MAX_CONFLICT_PATHS);
206
+ return new TaskMergeOperationError("merge-conflicted", error.message, 409, {
207
+ cause: error,
208
+ conflictPaths: retained.map(boundedPathDisplay),
209
+ omittedConflictPaths: error.omittedConflictPaths + (error.conflictPaths.length - retained.length)
210
+ });
211
+ }
212
+ if (isGitServiceError(error)) {
213
+ if (WORKTREE_UNAVAILABLE_CODES.has(error.code)) {
214
+ return side === "task" ? new TaskMergeOperationError("task-not-ready", TASK_UNAVAILABLE_MESSAGE, 409, {
215
+ cause: error
216
+ }) : new TaskMergeOperationError("target-unavailable", TARGET_UNAVAILABLE_MESSAGE, 409, {
217
+ cause: error
218
+ });
219
+ }
220
+ switch (error.code) {
221
+ case "GIT_MERGE_ALREADY_MERGED":
222
+ return new TaskMergeOperationError(
223
+ "already-merged",
224
+ "The task branch is already merged into the target branch. Archive the task instead.",
225
+ 409,
226
+ { cause: error }
227
+ );
228
+ case "GIT_MERGE_UNRELATED_HISTORIES":
229
+ return new TaskMergeOperationError(
230
+ "unrelated-histories",
231
+ "The task and target branches share no history; Webdesk refuses to merge unrelated histories.",
232
+ 409,
233
+ { cause: error }
234
+ );
235
+ case "GIT_MERGE_NOTHING_TO_MERGE":
236
+ return new TaskMergeOperationError(
237
+ "nothing-to-merge",
238
+ "The merge would not change the target branch's content. Archive the task instead.",
239
+ 409,
240
+ { cause: error }
241
+ );
242
+ case "GIT_MERGE_STATE_UNSUPPORTED":
243
+ return new TaskMergeOperationError(
244
+ "state-unsupported",
245
+ "The merge changes a submodule pointer, which Webdesk cannot publish and sync safely yet.",
246
+ 422,
247
+ { cause: error }
248
+ );
249
+ case "GIT_STATE_UNFINGERPRINTABLE":
250
+ case "GIT_STATE_TOO_LARGE":
251
+ return new TaskMergeOperationError(
252
+ "state-unsupported",
253
+ "A worktree has state that is too large or cannot be fingerprinted safely yet.",
254
+ 422,
255
+ { cause: error }
256
+ );
257
+ case "GIT_MERGE_TARGET_PATH_OCCUPIED": {
258
+ let occupied = "";
259
+ if (typeof error.details.path === "string") {
260
+ const display = boundedDisplay(error.details.path, 160);
261
+ occupied = ` (${display.clipped ? `${display.text.slice(0, 159)}\u2026` : display.text})`;
262
+ }
263
+ return new TaskMergeOperationError(
264
+ "target-path-occupied",
265
+ `A file already exists in the target checkout where the merge would create a tracked file${occupied}. It may be ignored by Git; move it away and preflight again.`,
266
+ 409,
267
+ { cause: error }
268
+ );
269
+ }
270
+ case "GIT_COMMIT_IDENTITY_MISSING":
271
+ return new TaskMergeOperationError(
272
+ "identity-missing",
273
+ "Git identity is not configured for the target checkout. Set user.name and user.email, then try again.",
274
+ 409,
275
+ { cause: error }
276
+ );
277
+ case "GIT_MERGE_VERSION_UNSUPPORTED":
278
+ return new TaskMergeOperationError(
279
+ "git-version-unsupported",
280
+ "Webdesk merges require Git 2.38 or newer for exact checkout-free merge computation. Upgrade Git to merge.",
281
+ 422,
282
+ { cause: error }
283
+ );
284
+ case "GIT_MERGE_WRONG_BASE":
285
+ return new TaskMergeOperationError(
286
+ "wrong-base",
287
+ "The task's recorded base is no longer an ancestor of both the task and target branches. Recreate or repair the task before merging.",
288
+ 409,
289
+ { cause: error }
290
+ );
291
+ case "GIT_MERGE_SIGNING_FAILED":
292
+ return new TaskMergeOperationError(
293
+ "signing-failed",
294
+ "Merge-commit signing failed or timed out; nothing was published. Check your signing setup.",
295
+ 502,
296
+ { cause: error }
297
+ );
298
+ case "GIT_MERGE_REF_CAS_FAILED":
299
+ return new TaskMergeOperationError(
300
+ "ref-cas-failed",
301
+ "The ref transaction was refused (a branch moved, or a repository hook aborted it); nothing was published. Preflight again.",
302
+ 409,
303
+ { cause: error }
304
+ );
305
+ case "GIT_MERGE_SOURCE_MOVED":
306
+ return new TaskMergeOperationError(
307
+ "ref-cas-failed",
308
+ "The task branch changed underneath the approved merge; nothing was published. Preflight again.",
309
+ 409,
310
+ { cause: error }
311
+ );
312
+ case "GIT_MERGE_TARGET_MOVED":
313
+ return new TaskMergeOperationError(
314
+ "ref-cas-failed",
315
+ "The target branch moved before publication; nothing was published. Preflight again.",
316
+ 409,
317
+ { cause: error }
318
+ );
319
+ case "GIT_MERGE_PUBLICATION_UNKNOWN": {
320
+ const commit = typeof error.details.commit === "string" ? error.details.commit.slice(0, 12) : "unknown";
321
+ return new TaskMergeOperationError(
322
+ "publication-unknown",
323
+ `Git stopped before Webdesk could determine whether merge commit ${commit} was published. Inspect the target branch, then use the repository's merge-sync action.`,
324
+ 502,
325
+ { cause: error }
326
+ );
327
+ }
328
+ case "GIT_MERGE_TREE_MISMATCH":
329
+ return new TaskMergeOperationError(
330
+ "merge-failed",
331
+ "The created merge does not carry the approved tree; nothing was published. Preflight again.",
332
+ 502,
333
+ { cause: error }
334
+ );
335
+ case "GIT_MERGE_TARGET_MISMATCHED":
336
+ return new TaskMergeOperationError("target-mismatched", TARGET_MISMATCH_MESSAGE, 409, {
337
+ cause: error
338
+ });
339
+ case "GIT_MERGE_SYNC_REFUSED":
340
+ return new TaskMergeOperationError(
341
+ "target-dirty",
342
+ "Git refused to update the target checkout (local edits, an untracked file, or a held index lock). Nothing was modified; resolve the local state and retry the merge sync.",
343
+ 409,
344
+ { cause: error }
345
+ );
346
+ case "GIT_REPOSITORY_CHANGED_DURING_CAPTURE":
347
+ case "GIT_STATE_CHANGED_DURING_CAPTURE":
348
+ case "GIT_STATE_DEADLINE_EXCEEDED":
349
+ return new TaskMergeOperationError(
350
+ "merge-failed",
351
+ "A worktree changed or could not be captured in time. Try again.",
352
+ 409,
353
+ { cause: error }
354
+ );
355
+ default:
356
+ break;
357
+ }
358
+ }
359
+ return new TaskMergeOperationError(
360
+ "merge-failed",
361
+ "Git could not complete the merge operation. Check the daemon output for details.",
362
+ 502,
363
+ { cause: error }
364
+ );
365
+ }
366
+ async function captureVerifiedTaskState(task, repository) {
367
+ let canonicalWorktree;
368
+ try {
369
+ canonicalWorktree = await realpath(task.worktreePath);
370
+ } catch (error) {
371
+ throw new TaskMergeOperationError("task-not-ready", TASK_UNAVAILABLE_MESSAGE, 409, {
372
+ cause: error
373
+ });
374
+ }
375
+ let state;
376
+ try {
377
+ state = await capture({ worktreePath: canonicalWorktree });
378
+ } catch (error) {
379
+ throw mapGitError(error, "task");
380
+ }
381
+ if (state.worktree.root !== canonicalWorktree || state.worktree.commonDir !== repository.commonDir || state.worktree.branch !== task.branch) {
382
+ throw new TaskMergeOperationError("task-not-ready", TASK_MISMATCH_MESSAGE, 409);
383
+ }
384
+ if (state.statusEntryCount !== 0) {
385
+ throw new TaskMergeOperationError(
386
+ "task-dirty",
387
+ "The task worktree has uncommitted changes. Commit the snapshot first; only committed content can be merged.",
388
+ 409
389
+ );
390
+ }
391
+ return state;
392
+ }
393
+ async function captureVerifiedTargetState(task, repository) {
394
+ let canonicalRoot;
395
+ try {
396
+ canonicalRoot = await realpath(repository.rootPath);
397
+ } catch (error) {
398
+ throw new TaskMergeOperationError("target-unavailable", TARGET_UNAVAILABLE_MESSAGE, 409, {
399
+ cause: error
400
+ });
401
+ }
402
+ if (canonicalRoot !== repository.rootPath) {
403
+ throw new TaskMergeOperationError("target-mismatched", TARGET_MISMATCH_MESSAGE, 409);
404
+ }
405
+ let state;
406
+ try {
407
+ state = await capture({ worktreePath: canonicalRoot });
408
+ } catch (error) {
409
+ throw mapGitError(error, "target");
410
+ }
411
+ if (state.worktree.root !== canonicalRoot || state.worktree.commonDir !== repository.commonDir) {
412
+ throw new TaskMergeOperationError("target-mismatched", TARGET_MISMATCH_MESSAGE, 409);
413
+ }
414
+ if (state.worktree.branch === null || repository.branch === null || state.worktree.branch !== repository.branch) {
415
+ throw new TaskMergeOperationError("target-mismatched", TARGET_MISMATCH_MESSAGE, 409);
416
+ }
417
+ if (state.worktree.branch === task.branch) {
418
+ throw new TaskMergeOperationError(
419
+ "target-mismatched",
420
+ "The registered target checkout is on the task's own branch; a task can never merge into itself.",
421
+ 409
422
+ );
423
+ }
424
+ if (state.statusEntryCount !== 0) {
425
+ throw new TaskMergeOperationError(
426
+ "target-dirty",
427
+ "The registered target checkout has uncommitted changes. Commit or stash them before merging.",
428
+ 409
429
+ );
430
+ }
431
+ return { canonicalRoot, state };
432
+ }
433
+ function validationVerdict(task, taskFingerprint) {
434
+ const result = task.validation;
435
+ if (result === void 0) return "none";
436
+ if (result.stateFingerprint !== taskFingerprint) return "stale";
437
+ return result.outcome === "passed" ? "current-passed" : "current-failed";
438
+ }
439
+ function defaultMergeMessage(taskBranch, targetBranch) {
440
+ return `Merge branch '${taskBranch}' into ${targetBranch}`;
441
+ }
442
+ function assertApprovalSafeBranch(branch) {
443
+ if ([...branch].some(
444
+ (character) => /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}\p{Default_Ignorable_Code_Point}]/u.test(character) || new RegExp("\\p{Zs}", "u").test(character) && character !== " " || /[\u115F\u1160\u2800\u3164\uFFA0]/u.test(character)
445
+ )) {
446
+ throw new TaskMergeOperationError(
447
+ "state-unsupported",
448
+ "A task or target branch contains invisible formatting characters and cannot be shown safely for merge approval. Rename the branch before merging.",
449
+ 422
450
+ );
451
+ }
452
+ }
453
+ function preflightPayload(taskId, pending, prepared, createdAtMs) {
454
+ let remainingChars = TASK_MERGE_MAX_TOTAL_DIFF_CHARS;
455
+ let totalDiffTruncated = prepared.totalDiffTruncated;
456
+ const files = prepared.files.map((file) => {
457
+ let diff = null;
458
+ let diffTruncated = file.diffTruncated;
459
+ let diffOmittedReason = file.diffOmittedReason;
460
+ if (file.diff !== null) {
461
+ if (remainingChars <= 0) {
462
+ diffOmittedReason = "total-budget";
463
+ diffTruncated = false;
464
+ totalDiffTruncated = true;
465
+ } else {
466
+ const perFileLimit = Math.min(TASK_MERGE_MAX_FILE_DIFF_CHARS, remainingChars);
467
+ const bounded = boundedDisplay(file.diff, perFileLimit);
468
+ diff = bounded.text;
469
+ if (bounded.clipped) {
470
+ diffTruncated = true;
471
+ if (perFileLimit < TASK_MERGE_MAX_FILE_DIFF_CHARS) totalDiffTruncated = true;
472
+ }
473
+ remainingChars -= diff.length;
474
+ }
475
+ }
476
+ return {
477
+ entryId: file.entryId,
478
+ path: boundedPathDisplay(file.path),
479
+ previousPath: file.previousPath === null ? null : boundedPathDisplay(file.previousPath),
480
+ kind: file.kind,
481
+ binary: file.binary,
482
+ diff,
483
+ diffTruncated,
484
+ diffOmittedReason
485
+ };
486
+ });
487
+ return taskMergePreflightSchema.parse({
488
+ protocol: PROTOCOL_VERSION,
489
+ type: "task-merge-preflight",
490
+ taskId,
491
+ preflightId: pending.preflightId,
492
+ mode: pending.mode,
493
+ taskBranch: pending.taskBranch,
494
+ targetBranch: pending.targetBranch,
495
+ taskHead: pending.taskHead,
496
+ targetHead: pending.targetHead,
497
+ mergedTree: pending.mergedTree,
498
+ mergeMessage: pending.mergeMessage,
499
+ files,
500
+ totalFiles: prepared.totalFiles,
501
+ omittedFiles: prepared.omittedFiles,
502
+ totalDiffTruncated,
503
+ commitCount: prepared.commitCount,
504
+ commitSubjects: prepared.commitSubjects.slice(0, TASK_MERGE_MAX_COMMIT_SUBJECTS).map(boundedSubjectDisplay),
505
+ validationVerdict: pending.validationVerdict,
506
+ validationCoversResult: pending.validationCoversResult,
507
+ acknowledgementRequired: pending.acknowledgementRequired,
508
+ hooksBypassed: true,
509
+ createdAtMs,
510
+ expiresAtMs: pending.expiresAtMs
511
+ });
512
+ }
513
+ async function preflight(taskId, request) {
514
+ claimTaskPhase(taskId, "preflight");
515
+ pendingPreflights.delete(taskId);
516
+ let repositoryClaimed = null;
517
+ try {
518
+ if (options.isArchiveActive?.(taskId) === true) {
519
+ throw new TaskMergeOperationError(
520
+ "archive-in-progress",
521
+ "This task is being archived or restored. Wait for that operation to finish.",
522
+ 409
523
+ );
524
+ }
525
+ refuseBusyStates(taskId);
526
+ const task = await resolveTask(taskId);
527
+ claimRepository(task.repositoryId);
528
+ repositoryClaimed = task.repositoryId;
529
+ const repository = await resolveRepository(task.repositoryId);
530
+ refusePendingSync(repository);
531
+ await refuseWorkingAgent(taskId);
532
+ const taskBefore = await captureVerifiedTaskState(task, repository);
533
+ const { canonicalRoot, state: targetBefore } = await captureVerifiedTargetState(
534
+ task,
535
+ repository
536
+ );
537
+ assertApprovalSafeBranch(task.branch);
538
+ assertApprovalSafeBranch(targetBefore.worktree.branch);
539
+ try {
540
+ await ensureGitVersion(void 0, canonicalRoot);
541
+ } catch (error) {
542
+ throw mapGitError(error, "target");
543
+ }
544
+ let prepared;
545
+ try {
546
+ prepared = await prepare({
547
+ targetRootPath: canonicalRoot,
548
+ targetHead: targetBefore.worktree.headCommit,
549
+ taskHead: taskBefore.worktree.headCommit,
550
+ baseCommit: task.baseCommit
551
+ });
552
+ } catch (error) {
553
+ throw mapGitError(error, "target");
554
+ }
555
+ try {
556
+ await guardAddedPaths(canonicalRoot, prepared.addedPaths);
557
+ } catch (error) {
558
+ throw mapGitError(error, "target");
559
+ }
560
+ const verdict = validationVerdict(task, taskBefore.stateFingerprint);
561
+ const validationCoversResult = prepared.mode === "fast-forward";
562
+ const mergeMessage = prepared.mode === "fast-forward" ? null : request.message ?? defaultMergeMessage(task.branch, targetBefore.worktree.branch);
563
+ const taskAfter = await captureVerifiedTaskState(task, repository);
564
+ const { state: targetAfter } = await captureVerifiedTargetState(task, repository);
565
+ if (taskAfter.stateFingerprint !== taskBefore.stateFingerprint || targetAfter.stateFingerprint !== targetBefore.stateFingerprint) {
566
+ throw new TaskMergeOperationError(
567
+ "merge-failed",
568
+ "A worktree changed while the merge was being prepared. Review the new state and try again.",
569
+ 409
570
+ );
571
+ }
572
+ const createdAtMs = now();
573
+ const pending = {
574
+ preflightId: createId(),
575
+ repositoryId: repository.id,
576
+ mode: prepared.mode,
577
+ taskBranch: task.branch,
578
+ targetBranch: targetBefore.worktree.branch,
579
+ taskHead: taskBefore.worktree.headCommit,
580
+ targetHead: targetBefore.worktree.headCommit,
581
+ mergedTree: prepared.mergedTree,
582
+ mergeMessage,
583
+ addedPaths: prepared.addedPaths,
584
+ taskFingerprint: taskBefore.stateFingerprint,
585
+ targetFingerprint: targetBefore.stateFingerprint,
586
+ validationVerdict: verdict,
587
+ validationCoversResult,
588
+ acknowledgementRequired: verdict !== "current-passed" || prepared.mode === "merge-commit",
589
+ expiresAtMs: createdAtMs + preflightTtlMs
590
+ };
591
+ const payload = preflightPayload(taskId, pending, prepared, createdAtMs);
592
+ pendingPreflights.set(taskId, pending);
593
+ return payload;
594
+ } finally {
595
+ activePhases.delete(taskId);
596
+ if (repositoryClaimed !== null) activeRepositories.delete(repositoryClaimed);
597
+ }
598
+ }
599
+ async function execute(taskId, request) {
600
+ claimTaskPhase(taskId, "execute");
601
+ let repositoryClaimed = null;
602
+ try {
603
+ if (options.isArchiveActive?.(taskId) === true) {
604
+ throw new TaskMergeOperationError(
605
+ "archive-in-progress",
606
+ "This task is being archived or restored. Wait for that operation to finish.",
607
+ 409
608
+ );
609
+ }
610
+ refuseBusyStates(taskId);
611
+ const task = await resolveTask(taskId);
612
+ claimRepository(task.repositoryId);
613
+ repositoryClaimed = task.repositoryId;
614
+ const repository = await resolveRepository(task.repositoryId);
615
+ refusePendingSync(repository);
616
+ await refuseWorkingAgent(taskId);
617
+ const pending = pendingPreflights.get(taskId);
618
+ if (pending === void 0 || pending.preflightId !== request.preflightId || pending.repositoryId !== repository.id) {
619
+ throw new TaskMergeOperationError(
620
+ "preflight-not-found",
621
+ "This merge preflight is no longer valid. Review the current state and preflight again.",
622
+ 409
623
+ );
624
+ }
625
+ if (now() >= pending.expiresAtMs) {
626
+ pendingPreflights.delete(taskId);
627
+ throw new TaskMergeOperationError(
628
+ "preflight-not-found",
629
+ "This merge preflight expired. Review the current state and preflight again.",
630
+ 409
631
+ );
632
+ }
633
+ const currentVerdict = validationVerdict(task, pending.taskFingerprint);
634
+ if (currentVerdict !== pending.validationVerdict) {
635
+ pendingPreflights.delete(taskId);
636
+ throw new TaskMergeOperationError(
637
+ "preflight-outdated",
638
+ "The validation verdict changed after this preflight. Review the current result and preflight again.",
639
+ 409
640
+ );
641
+ }
642
+ if (pending.acknowledgementRequired && request.acknowledgeValidation !== true) {
643
+ throw new TaskMergeOperationError(
644
+ "acknowledgement-required",
645
+ "Acknowledge the validation coverage explicitly before merging.",
646
+ 409
647
+ );
648
+ }
649
+ let canonicalRoot;
650
+ try {
651
+ const taskCurrent = await captureVerifiedTaskState(task, repository);
652
+ const target = await captureVerifiedTargetState(task, repository);
653
+ canonicalRoot = target.canonicalRoot;
654
+ if (taskCurrent.stateFingerprint !== pending.taskFingerprint || target.state.stateFingerprint !== pending.targetFingerprint) {
655
+ throw new TaskMergeOperationError(
656
+ "preflight-outdated",
657
+ "A worktree changed after this preflight was approved. Review the new state and preflight again.",
658
+ 409
659
+ );
660
+ }
661
+ try {
662
+ await guardAddedPaths(canonicalRoot, pending.addedPaths);
663
+ } catch (error) {
664
+ throw mapGitError(error, "target");
665
+ }
666
+ } catch (error) {
667
+ pendingPreflights.delete(taskId);
668
+ throw error;
669
+ }
670
+ pendingPreflights.delete(taskId);
671
+ let publication;
672
+ try {
673
+ publication = await publish({
674
+ targetRootPath: canonicalRoot,
675
+ mode: pending.mode,
676
+ taskBranch: pending.taskBranch,
677
+ targetBranch: pending.targetBranch,
678
+ taskHead: pending.taskHead,
679
+ targetHead: pending.targetHead,
680
+ mergedTree: pending.mergedTree,
681
+ message: pending.mergeMessage,
682
+ reflogReason: `pita-merge: task ${taskId} preflight ${pending.preflightId}`,
683
+ // The durable intent is written after the publishable commit exists
684
+ // and immediately before the ref transaction.
685
+ recordIntent: (newCommit) => options.recordPendingMerge(repository.id, {
686
+ taskId,
687
+ preflightId: pending.preflightId,
688
+ mode: pending.mode,
689
+ targetBranch: pending.targetBranch,
690
+ previousTargetHead: pending.targetHead,
691
+ newTargetHead: newCommit,
692
+ recordedAtMs: now()
693
+ })
694
+ });
695
+ } catch (error) {
696
+ if (isGitServiceError(error, "GIT_MERGE_PUBLICATION_UNKNOWN")) {
697
+ throw mapGitError(error, "target");
698
+ }
699
+ await options.clearPendingMerge(repository.id, pending.preflightId).catch(() => void 0);
700
+ if (error instanceof TaskMergeOperationError) throw error;
701
+ throw mapGitError(error, "target");
702
+ }
703
+ let targetSynced = false;
704
+ let targetHeadVerified = false;
705
+ try {
706
+ const facts = await sync({
707
+ targetRootPath: canonicalRoot,
708
+ targetBranch: pending.targetBranch,
709
+ previousTargetHead: pending.targetHead,
710
+ newTargetHead: publication.newCommit,
711
+ addedPaths: pending.addedPaths
712
+ });
713
+ targetHeadVerified = facts.headVerified;
714
+ targetSynced = facts.headVerified && facts.statusClean;
715
+ } catch (error) {
716
+ targetSynced = false;
717
+ targetHeadVerified = false;
718
+ }
719
+ if (targetSynced) {
720
+ await options.clearPendingMerge(repository.id, pending.preflightId, publication.newCommit).catch(() => void 0);
721
+ }
722
+ return taskMergeResultSchema.parse({
723
+ protocol: PROTOCOL_VERSION,
724
+ type: "task-merge-result",
725
+ taskId,
726
+ mode: publication.mode,
727
+ taskBranch: pending.taskBranch,
728
+ targetBranch: pending.targetBranch,
729
+ taskHead: publication.taskHead,
730
+ previousTargetHead: publication.previousTargetHead,
731
+ newTargetHead: publication.newCommit,
732
+ mergeCommit: publication.mode === "merge-commit" ? publication.newCommit : null,
733
+ treeOid: publication.treeOid,
734
+ signed: publication.signed,
735
+ targetSynced,
736
+ targetHeadVerified,
737
+ capturedAtMs: now()
738
+ });
739
+ } finally {
740
+ activePhases.delete(taskId);
741
+ if (repositoryClaimed !== null) activeRepositories.delete(repositoryClaimed);
742
+ }
743
+ }
744
+ async function syncRepository(repositoryId, request = {
745
+ acknowledgeRecoveryDismissal: false
746
+ }) {
747
+ claimRepository(repositoryId);
748
+ try {
749
+ let result2 = function(outcome, currentTargetHead) {
750
+ return repositoryMergeSyncResultSchema.parse({
751
+ protocol: PROTOCOL_VERSION,
752
+ type: "repository-merge-sync-result",
753
+ repositoryId,
754
+ outcome,
755
+ taskId: pending.taskId,
756
+ mode: pending.mode,
757
+ targetBranch: pending.targetBranch,
758
+ previousTargetHead: pending.previousTargetHead,
759
+ newTargetHead: pending.newTargetHead,
760
+ currentTargetHead,
761
+ pendingMergeCleared: !["publication-unconfirmed", "target-moved"].includes(outcome),
762
+ capturedAtMs: now()
763
+ });
764
+ };
765
+ var result = result2;
766
+ const repository = await resolveRepository(repositoryId);
767
+ const pending = repository.pendingMerge;
768
+ if (pending === void 0) {
769
+ inspectedRecoveryHeads.delete(repositoryId);
770
+ throw new TaskMergeOperationError(
771
+ "sync-not-pending",
772
+ "No interrupted merge is pending for this repository.",
773
+ 409
774
+ );
775
+ }
776
+ if (options.isArchiveActive?.(pending.taskId) === true) {
777
+ throw new TaskMergeOperationError(
778
+ "archive-in-progress",
779
+ "This task is being archived or restored. Wait for that operation to finish before reconciling its merge.",
780
+ 409
781
+ );
782
+ }
783
+ let canonicalRoot;
784
+ try {
785
+ canonicalRoot = await realpath(repository.rootPath);
786
+ } catch (error) {
787
+ throw new TaskMergeOperationError("target-unavailable", TARGET_UNAVAILABLE_MESSAGE, 409, {
788
+ cause: error
789
+ });
790
+ }
791
+ if (canonicalRoot !== repository.rootPath) {
792
+ throw new TaskMergeOperationError("target-mismatched", TARGET_MISMATCH_MESSAGE, 409);
793
+ }
794
+ let state;
795
+ try {
796
+ state = await capture({ worktreePath: canonicalRoot });
797
+ } catch (error) {
798
+ throw mapGitError(error, "target");
799
+ }
800
+ if (state.worktree.root !== canonicalRoot || state.worktree.commonDir !== repository.commonDir) {
801
+ throw new TaskMergeOperationError("target-mismatched", TARGET_MISMATCH_MESSAGE, 409);
802
+ }
803
+ if (state.worktree.branch !== pending.targetBranch) {
804
+ throw new TaskMergeOperationError(
805
+ "target-mismatched",
806
+ "The target checkout was switched away from the branch of the interrupted merge. Switch it back, then retry the merge sync.",
807
+ 409
808
+ );
809
+ }
810
+ const currentHead = state.worktree.headCommit;
811
+ if (currentHead === pending.previousTargetHead) {
812
+ const inspected = inspectedRecoveryHeads.get(repositoryId);
813
+ if (request.acknowledgeRecoveryDismissal === true && inspected?.preflightId === pending.preflightId && inspected.currentTargetHead === currentHead) {
814
+ await clearPending(repositoryId, pending.preflightId, pending.previousTargetHead);
815
+ inspectedRecoveryHeads.delete(repositoryId);
816
+ return result2("publication-unconfirmed-cleared", currentHead);
817
+ }
818
+ inspectedRecoveryHeads.set(repositoryId, {
819
+ preflightId: pending.preflightId,
820
+ currentTargetHead: currentHead
821
+ });
822
+ return result2("publication-unconfirmed", currentHead);
823
+ }
824
+ if (currentHead !== pending.newTargetHead) {
825
+ if (state.statusEntryCount !== 0) {
826
+ throw new TaskMergeOperationError(
827
+ "target-dirty",
828
+ "The target branch moved and its checkout is not clean. Inspect and settle the checkout before reconciling the merge.",
829
+ 409
830
+ );
831
+ }
832
+ let integrated;
833
+ try {
834
+ integrated = await isAncestor(canonicalRoot, pending.newTargetHead, currentHead);
835
+ } catch (error) {
836
+ throw mapGitError(error, "target");
837
+ }
838
+ if (integrated) {
839
+ await clearPending(repositoryId, pending.preflightId, currentHead);
840
+ inspectedRecoveryHeads.delete(repositoryId);
841
+ return result2("advanced-integrated", currentHead);
842
+ }
843
+ const inspected = inspectedRecoveryHeads.get(repositoryId);
844
+ if (request.acknowledgeRecoveryDismissal === true && inspected?.preflightId === pending.preflightId && inspected.currentTargetHead === currentHead) {
845
+ await clearPending(repositoryId, pending.preflightId, currentHead);
846
+ inspectedRecoveryHeads.delete(repositoryId);
847
+ return result2("target-moved-cleared", currentHead);
848
+ }
849
+ inspectedRecoveryHeads.set(repositoryId, {
850
+ preflightId: pending.preflightId,
851
+ currentTargetHead: currentHead
852
+ });
853
+ return result2("target-moved", currentHead);
854
+ }
855
+ if (state.statusEntryCount === 0) {
856
+ await clearPending(repositoryId, pending.preflightId, pending.newTargetHead);
857
+ inspectedRecoveryHeads.delete(repositoryId);
858
+ return result2("synced", null);
859
+ }
860
+ let facts;
861
+ try {
862
+ facts = await sync({
863
+ targetRootPath: canonicalRoot,
864
+ targetBranch: pending.targetBranch,
865
+ previousTargetHead: pending.previousTargetHead,
866
+ newTargetHead: pending.newTargetHead
867
+ });
868
+ } catch (error) {
869
+ throw mapGitError(error, "target");
870
+ }
871
+ if (!facts.statusClean || !facts.headVerified) {
872
+ throw new TaskMergeOperationError(
873
+ facts.headVerified ? "target-dirty" : "target-mismatched",
874
+ facts.headVerified ? "Git updated the target checkout, but it is not clean. Resolve the remaining local state and retry merge sync." : "Git updated the target checkout, but Webdesk could not verify the published target head. Inspect the repository and retry merge sync.",
875
+ 409
876
+ );
877
+ }
878
+ await clearPending(repositoryId, pending.preflightId, pending.newTargetHead);
879
+ inspectedRecoveryHeads.delete(repositoryId);
880
+ return result2("synced", null);
881
+ } finally {
882
+ activeRepositories.delete(repositoryId);
883
+ }
884
+ }
885
+ async function clearPending(repositoryId, preflightId, resolvedHead) {
886
+ try {
887
+ await options.clearPendingMerge(repositoryId, preflightId, resolvedHead);
888
+ } catch (error) {
889
+ throw new TaskMergeOperationError(
890
+ "merge-failed",
891
+ "The merge sync completed but the pending record could not be cleared. Retry the merge sync.",
892
+ 503,
893
+ { cause: error }
894
+ );
895
+ }
896
+ }
897
+ return {
898
+ preflight,
899
+ execute,
900
+ syncRepository,
901
+ isBusy: (taskId) => activePhases.has(taskId),
902
+ isRepositoryBusy: (repositoryId) => activeRepositories.has(repositoryId),
903
+ invalidate(taskId) {
904
+ pendingPreflights.delete(taskId);
905
+ }
906
+ };
907
+ }
908
+ export {
909
+ MERGE_PREFLIGHT_TTL_MS,
910
+ TaskMergeOperationError,
911
+ createTaskMergeService
912
+ };