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,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
|
+
};
|