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,503 @@
|
|
|
1
|
+
// apps/daemon/src/task-commit.ts
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
captureWorktreeState,
|
|
6
|
+
commitPreparedTree,
|
|
7
|
+
isGitServiceError,
|
|
8
|
+
prepareCommitTree
|
|
9
|
+
} from "../../../packages/git/src/index.js";
|
|
10
|
+
import { sanitizeApprovalDisplayText } from "../../../packages/pi-bridge/src/index.js";
|
|
11
|
+
import {
|
|
12
|
+
PROTOCOL_VERSION,
|
|
13
|
+
TASK_COMMIT_MAX_FILE_DIFF_CHARS,
|
|
14
|
+
TASK_COMMIT_MAX_TOTAL_DIFF_CHARS,
|
|
15
|
+
taskCommitPreflightSchema,
|
|
16
|
+
taskCommitResultSchema
|
|
17
|
+
} from "../../../packages/protocol/src/index.js";
|
|
18
|
+
var COMMIT_PREFLIGHT_TTL_MS = 10 * 60 * 1e3;
|
|
19
|
+
var TaskCommitOperationError = class extends Error {
|
|
20
|
+
name = "TaskCommitOperationError";
|
|
21
|
+
code;
|
|
22
|
+
status;
|
|
23
|
+
constructor(code, message, status, options) {
|
|
24
|
+
super(message, options);
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.status = status;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var WORKTREE_UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
|
|
30
|
+
"GIT_PATH_NOT_FOUND",
|
|
31
|
+
"GIT_PATH_NOT_DIRECTORY",
|
|
32
|
+
"GIT_NOT_A_REPOSITORY",
|
|
33
|
+
"GIT_BARE_REPOSITORY",
|
|
34
|
+
"GIT_NOT_A_WORK_TREE",
|
|
35
|
+
"GIT_HEAD_UNRESOLVED"
|
|
36
|
+
]);
|
|
37
|
+
var WORKTREE_UNAVAILABLE_MESSAGE = "The task worktree is missing or is no longer a healthy Git worktree. Recover it before committing.";
|
|
38
|
+
var WORKTREE_MISMATCH_MESSAGE = "The recorded worktree no longer matches the task's repository. Recover it before committing.";
|
|
39
|
+
var MAX_PATH_DISPLAY_CHARS = 4096;
|
|
40
|
+
function boundedDisplay(text, maxChars) {
|
|
41
|
+
const clippedInput = text.length > maxChars;
|
|
42
|
+
const safe = sanitizeApprovalDisplayText(text.slice(0, maxChars));
|
|
43
|
+
if (safe.length <= maxChars) return { text: safe, clipped: clippedInput };
|
|
44
|
+
return { text: safe.slice(0, maxChars), clipped: true };
|
|
45
|
+
}
|
|
46
|
+
function boundedPathDisplay(text) {
|
|
47
|
+
const bounded = boundedDisplay(text, MAX_PATH_DISPLAY_CHARS);
|
|
48
|
+
if (!bounded.clipped) return bounded.text;
|
|
49
|
+
return `${bounded.text.slice(0, MAX_PATH_DISPLAY_CHARS - 1)}\u2026`;
|
|
50
|
+
}
|
|
51
|
+
function createTaskCommitService(options) {
|
|
52
|
+
const capture = options.captureWorktreeState ?? captureWorktreeState;
|
|
53
|
+
const prepare = options.prepareCommitTree ?? prepareCommitTree;
|
|
54
|
+
const publish = options.commitPreparedTree ?? commitPreparedTree;
|
|
55
|
+
const preflightTtlMs = options.preflightTtlMs ?? COMMIT_PREFLIGHT_TTL_MS;
|
|
56
|
+
const now = options.now ?? Date.now;
|
|
57
|
+
const createId = options.createId ?? randomUUID;
|
|
58
|
+
const activePhases = /* @__PURE__ */ new Map();
|
|
59
|
+
const pendingPreflights = /* @__PURE__ */ new Map();
|
|
60
|
+
async function resolveTask(taskId) {
|
|
61
|
+
let task;
|
|
62
|
+
try {
|
|
63
|
+
task = await options.getTask(taskId);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new TaskCommitOperationError(
|
|
66
|
+
"commit-failed",
|
|
67
|
+
"The task could not be resolved from the local workspace.",
|
|
68
|
+
503,
|
|
69
|
+
{ cause: error }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (task === null) {
|
|
73
|
+
throw new TaskCommitOperationError(
|
|
74
|
+
"task-not-found",
|
|
75
|
+
"The selected task no longer exists.",
|
|
76
|
+
404
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (task.status !== "ready") {
|
|
80
|
+
throw new TaskCommitOperationError(
|
|
81
|
+
"task-not-ready",
|
|
82
|
+
`Only a ready task can be committed; this task is ${task.status}.`,
|
|
83
|
+
409
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return task;
|
|
87
|
+
}
|
|
88
|
+
async function resolveRepository(task) {
|
|
89
|
+
let repository;
|
|
90
|
+
try {
|
|
91
|
+
repository = await options.getRepository(task.repositoryId);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new TaskCommitOperationError(
|
|
94
|
+
"commit-failed",
|
|
95
|
+
"The task's repository could not be resolved from the local workspace.",
|
|
96
|
+
503,
|
|
97
|
+
{ cause: error }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (repository === null) {
|
|
101
|
+
throw new TaskCommitOperationError(
|
|
102
|
+
"task-not-ready",
|
|
103
|
+
"The task's repository is no longer registered.",
|
|
104
|
+
409
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return repository;
|
|
108
|
+
}
|
|
109
|
+
function claimPhase(taskId, phase) {
|
|
110
|
+
if (activePhases.has(taskId)) {
|
|
111
|
+
throw new TaskCommitOperationError(
|
|
112
|
+
"commit-in-progress",
|
|
113
|
+
"A commit preflight or execution is already in progress for this task.",
|
|
114
|
+
409
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
activePhases.set(taskId, phase);
|
|
118
|
+
}
|
|
119
|
+
function refuseRunningValidation(taskId) {
|
|
120
|
+
if (options.isValidationRunning(taskId)) {
|
|
121
|
+
throw new TaskCommitOperationError(
|
|
122
|
+
"validation-running",
|
|
123
|
+
"A validation is running for this task. Wait for it to finish before committing.",
|
|
124
|
+
409
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function refuseActiveMerge(taskId) {
|
|
129
|
+
if (options.isMergeActive?.(taskId) === true) {
|
|
130
|
+
throw new TaskCommitOperationError(
|
|
131
|
+
"merge-in-progress",
|
|
132
|
+
"A merge preflight or execution is active for this task. Wait for it to finish before committing.",
|
|
133
|
+
409
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function refuseWorkingAgent(taskId) {
|
|
138
|
+
let working;
|
|
139
|
+
try {
|
|
140
|
+
working = await options.isAgentWorking(taskId);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new TaskCommitOperationError(
|
|
143
|
+
"commit-failed",
|
|
144
|
+
"The task's runtime state could not be verified. Check the daemon output for details.",
|
|
145
|
+
503,
|
|
146
|
+
{ cause: error }
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (working) {
|
|
150
|
+
throw new TaskCommitOperationError(
|
|
151
|
+
"agent-working",
|
|
152
|
+
"The task's agent is still working. Wait for the turn to settle before committing.",
|
|
153
|
+
409
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function captureVerifiedState(task, repository, phase) {
|
|
158
|
+
let canonicalWorktree;
|
|
159
|
+
try {
|
|
160
|
+
canonicalWorktree = await realpath(task.worktreePath);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new TaskCommitOperationError("task-not-ready", WORKTREE_UNAVAILABLE_MESSAGE, 409, {
|
|
163
|
+
cause: error
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
let state;
|
|
167
|
+
try {
|
|
168
|
+
state = await capture({ worktreePath: canonicalWorktree });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw mapGitError(error, phase);
|
|
171
|
+
}
|
|
172
|
+
if (state.worktree.root !== canonicalWorktree || state.worktree.commonDir !== repository.commonDir || state.worktree.branch !== task.branch) {
|
|
173
|
+
throw new TaskCommitOperationError("task-not-ready", WORKTREE_MISMATCH_MESSAGE, 409);
|
|
174
|
+
}
|
|
175
|
+
return { canonicalWorktree, state };
|
|
176
|
+
}
|
|
177
|
+
function mapGitError(error, phase) {
|
|
178
|
+
if (isGitServiceError(error)) {
|
|
179
|
+
if (WORKTREE_UNAVAILABLE_CODES.has(error.code)) {
|
|
180
|
+
return new TaskCommitOperationError("task-not-ready", WORKTREE_UNAVAILABLE_MESSAGE, 409, {
|
|
181
|
+
cause: error
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
switch (error.code) {
|
|
185
|
+
case "GIT_COMMIT_CONFLICTED":
|
|
186
|
+
return new TaskCommitOperationError(
|
|
187
|
+
"commit-conflicted",
|
|
188
|
+
"The worktree has unresolved merge conflicts. Resolve them before committing.",
|
|
189
|
+
409,
|
|
190
|
+
{ cause: error }
|
|
191
|
+
);
|
|
192
|
+
case "GIT_COMMIT_NOTHING_TO_COMMIT":
|
|
193
|
+
return new TaskCommitOperationError(
|
|
194
|
+
"nothing-to-commit",
|
|
195
|
+
"There are no changes to commit for this task.",
|
|
196
|
+
409,
|
|
197
|
+
{ cause: error }
|
|
198
|
+
);
|
|
199
|
+
case "GIT_COMMIT_IDENTITY_MISSING":
|
|
200
|
+
return new TaskCommitOperationError(
|
|
201
|
+
"identity-missing",
|
|
202
|
+
"Git identity is not configured for this worktree. Set user.name and user.email, then try again.",
|
|
203
|
+
409,
|
|
204
|
+
{ cause: error }
|
|
205
|
+
);
|
|
206
|
+
case "GIT_COMMIT_DETACHED_HEAD":
|
|
207
|
+
case "GIT_COMMIT_BRANCH_MISMATCH":
|
|
208
|
+
return new TaskCommitOperationError("task-not-ready", WORKTREE_MISMATCH_MESSAGE, 409, {
|
|
209
|
+
cause: error
|
|
210
|
+
});
|
|
211
|
+
case "GIT_COMMIT_STATE_UNSUPPORTED":
|
|
212
|
+
case "GIT_STATE_UNFINGERPRINTABLE":
|
|
213
|
+
case "GIT_STATE_TOO_LARGE":
|
|
214
|
+
return new TaskCommitOperationError(
|
|
215
|
+
"state-unsupported",
|
|
216
|
+
"The worktree has changes that are too large or cannot be committed safely by Webdesk yet.",
|
|
217
|
+
422,
|
|
218
|
+
{ cause: error }
|
|
219
|
+
);
|
|
220
|
+
case "GIT_COMMIT_PARENT_MOVED":
|
|
221
|
+
case "GIT_COMMIT_REF_CAS_FAILED":
|
|
222
|
+
return new TaskCommitOperationError(
|
|
223
|
+
"branch-moved",
|
|
224
|
+
"The branch moved since the approved preflight. Review the current state and preflight again.",
|
|
225
|
+
409,
|
|
226
|
+
{ cause: error }
|
|
227
|
+
);
|
|
228
|
+
case "GIT_COMMIT_PUBLICATION_UNKNOWN": {
|
|
229
|
+
const commit = typeof error.details.commit === "string" ? error.details.commit.slice(0, 12) : "unknown";
|
|
230
|
+
return new TaskCommitOperationError(
|
|
231
|
+
"commit-failed",
|
|
232
|
+
`Git may have published commit ${commit}, but Webdesk could not verify the branch. Inspect the repository before retrying.`,
|
|
233
|
+
502,
|
|
234
|
+
{ cause: error }
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
case "GIT_COMMIT_SIGNING_FAILED":
|
|
238
|
+
return new TaskCommitOperationError(
|
|
239
|
+
"signing-failed",
|
|
240
|
+
"Commit signing failed or timed out; no commit was published. Check your signing setup.",
|
|
241
|
+
502,
|
|
242
|
+
{ cause: error }
|
|
243
|
+
);
|
|
244
|
+
case "GIT_REPOSITORY_CHANGED_DURING_CAPTURE":
|
|
245
|
+
case "GIT_STATE_CHANGED_DURING_CAPTURE":
|
|
246
|
+
case "GIT_STATE_DEADLINE_EXCEEDED":
|
|
247
|
+
return new TaskCommitOperationError(
|
|
248
|
+
"commit-failed",
|
|
249
|
+
phase === "preflight" ? "The worktree state changed or could not be captured in time. Try again." : "The worktree state changed or could not be re-verified in time. Preflight again.",
|
|
250
|
+
409,
|
|
251
|
+
{ cause: error }
|
|
252
|
+
);
|
|
253
|
+
default:
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return new TaskCommitOperationError(
|
|
258
|
+
"commit-failed",
|
|
259
|
+
"Git could not complete the commit operation. Check the daemon output for details.",
|
|
260
|
+
502,
|
|
261
|
+
{ cause: error }
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
function validationVerdict(task, stateFingerprint) {
|
|
265
|
+
const result = task.validation;
|
|
266
|
+
if (result === void 0) return "none";
|
|
267
|
+
if (result.stateFingerprint !== stateFingerprint) return "stale";
|
|
268
|
+
return result.outcome === "passed" ? "current-passed" : "current-failed";
|
|
269
|
+
}
|
|
270
|
+
function preflightPayload(taskId, pending, prepared, verdict, createdAtMs) {
|
|
271
|
+
let remainingChars = TASK_COMMIT_MAX_TOTAL_DIFF_CHARS;
|
|
272
|
+
let totalDiffTruncated = prepared.totalDiffTruncated;
|
|
273
|
+
const files = prepared.files.map((file) => {
|
|
274
|
+
let diff = null;
|
|
275
|
+
let diffTruncated = file.diffTruncated;
|
|
276
|
+
let diffOmittedReason = file.diffOmittedReason;
|
|
277
|
+
if (file.diff !== null) {
|
|
278
|
+
if (remainingChars <= 0) {
|
|
279
|
+
diffOmittedReason = "total-budget";
|
|
280
|
+
diffTruncated = false;
|
|
281
|
+
totalDiffTruncated = true;
|
|
282
|
+
} else {
|
|
283
|
+
const perFileLimit = Math.min(TASK_COMMIT_MAX_FILE_DIFF_CHARS, remainingChars);
|
|
284
|
+
const bounded = boundedDisplay(file.diff, perFileLimit);
|
|
285
|
+
diff = bounded.text;
|
|
286
|
+
if (bounded.clipped) {
|
|
287
|
+
diffTruncated = true;
|
|
288
|
+
if (perFileLimit < TASK_COMMIT_MAX_FILE_DIFF_CHARS) totalDiffTruncated = true;
|
|
289
|
+
}
|
|
290
|
+
remainingChars -= diff.length;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
entryId: file.entryId,
|
|
295
|
+
path: boundedPathDisplay(file.path),
|
|
296
|
+
previousPath: file.previousPath === null ? null : boundedPathDisplay(file.previousPath),
|
|
297
|
+
kind: file.kind,
|
|
298
|
+
binary: file.binary,
|
|
299
|
+
diff,
|
|
300
|
+
diffTruncated,
|
|
301
|
+
diffOmittedReason
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
return taskCommitPreflightSchema.parse({
|
|
305
|
+
protocol: PROTOCOL_VERSION,
|
|
306
|
+
type: "task-commit-preflight",
|
|
307
|
+
taskId,
|
|
308
|
+
preflightId: pending.preflightId,
|
|
309
|
+
branch: pending.branch,
|
|
310
|
+
parentCommit: pending.parentCommit,
|
|
311
|
+
treeOid: pending.treeOid,
|
|
312
|
+
message: pending.message,
|
|
313
|
+
files,
|
|
314
|
+
totalFiles: prepared.totalFiles,
|
|
315
|
+
omittedFiles: prepared.omittedFiles,
|
|
316
|
+
totalDiffTruncated,
|
|
317
|
+
stagedDivergence: prepared.stagedDivergence,
|
|
318
|
+
validationVerdict: verdict,
|
|
319
|
+
acknowledgementRequired: pending.acknowledgementRequired,
|
|
320
|
+
hooksBypassed: true,
|
|
321
|
+
createdAtMs,
|
|
322
|
+
expiresAtMs: pending.expiresAtMs
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async function preflight(taskId, message) {
|
|
326
|
+
claimPhase(taskId, "preflight");
|
|
327
|
+
pendingPreflights.delete(taskId);
|
|
328
|
+
try {
|
|
329
|
+
if (options.isArchiveActive?.(taskId) === true) {
|
|
330
|
+
throw new TaskCommitOperationError(
|
|
331
|
+
"archive-in-progress",
|
|
332
|
+
"This task is being archived or restored. Wait for that operation to finish.",
|
|
333
|
+
409
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
refuseRunningValidation(taskId);
|
|
337
|
+
refuseActiveMerge(taskId);
|
|
338
|
+
const task = await resolveTask(taskId);
|
|
339
|
+
const repository = await resolveRepository(task);
|
|
340
|
+
await refuseWorkingAgent(taskId);
|
|
341
|
+
const { canonicalWorktree, state: before } = await captureVerifiedState(
|
|
342
|
+
task,
|
|
343
|
+
repository,
|
|
344
|
+
"preflight"
|
|
345
|
+
);
|
|
346
|
+
let prepared;
|
|
347
|
+
try {
|
|
348
|
+
prepared = await prepare({
|
|
349
|
+
worktreePath: canonicalWorktree,
|
|
350
|
+
expectedBranch: task.branch
|
|
351
|
+
});
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw mapGitError(error, "preflight");
|
|
354
|
+
}
|
|
355
|
+
if (prepared.parentCommit !== before.worktree.headCommit) {
|
|
356
|
+
throw new TaskCommitOperationError(
|
|
357
|
+
"commit-failed",
|
|
358
|
+
"The worktree state changed while the commit was being prepared. Try again.",
|
|
359
|
+
409
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const { state: after } = await captureVerifiedState(task, repository, "preflight");
|
|
363
|
+
if (after.stateFingerprint !== before.stateFingerprint) {
|
|
364
|
+
throw new TaskCommitOperationError(
|
|
365
|
+
"commit-failed",
|
|
366
|
+
"The worktree changed while the commit was being prepared. Review the new state and try again.",
|
|
367
|
+
409
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const verdict = validationVerdict(task, before.stateFingerprint);
|
|
371
|
+
const createdAtMs = now();
|
|
372
|
+
const pending = {
|
|
373
|
+
preflightId: createId(),
|
|
374
|
+
message,
|
|
375
|
+
branch: prepared.branch,
|
|
376
|
+
parentCommit: prepared.parentCommit,
|
|
377
|
+
treeOid: prepared.treeOid,
|
|
378
|
+
stagedPaths: prepared.stagedPaths,
|
|
379
|
+
stateFingerprint: before.stateFingerprint,
|
|
380
|
+
validationVerdict: verdict,
|
|
381
|
+
acknowledgementRequired: verdict !== "current-passed",
|
|
382
|
+
expiresAtMs: createdAtMs + preflightTtlMs
|
|
383
|
+
};
|
|
384
|
+
const payload = preflightPayload(taskId, pending, prepared, verdict, createdAtMs);
|
|
385
|
+
pendingPreflights.set(taskId, pending);
|
|
386
|
+
return payload;
|
|
387
|
+
} finally {
|
|
388
|
+
activePhases.delete(taskId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function execute(taskId, request) {
|
|
392
|
+
claimPhase(taskId, "execute");
|
|
393
|
+
try {
|
|
394
|
+
if (options.isArchiveActive?.(taskId) === true) {
|
|
395
|
+
throw new TaskCommitOperationError(
|
|
396
|
+
"archive-in-progress",
|
|
397
|
+
"This task is being archived or restored. Wait for that operation to finish.",
|
|
398
|
+
409
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
refuseRunningValidation(taskId);
|
|
402
|
+
refuseActiveMerge(taskId);
|
|
403
|
+
const task = await resolveTask(taskId);
|
|
404
|
+
const repository = await resolveRepository(task);
|
|
405
|
+
await refuseWorkingAgent(taskId);
|
|
406
|
+
const pending = pendingPreflights.get(taskId);
|
|
407
|
+
if (pending === void 0 || pending.preflightId !== request.preflightId) {
|
|
408
|
+
throw new TaskCommitOperationError(
|
|
409
|
+
"preflight-not-found",
|
|
410
|
+
"This commit preflight is no longer valid. Review the current changes and preflight again.",
|
|
411
|
+
409
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (now() >= pending.expiresAtMs) {
|
|
415
|
+
pendingPreflights.delete(taskId);
|
|
416
|
+
throw new TaskCommitOperationError(
|
|
417
|
+
"preflight-not-found",
|
|
418
|
+
"This commit preflight expired. Review the current changes and preflight again.",
|
|
419
|
+
409
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
const currentVerdict = validationVerdict(task, pending.stateFingerprint);
|
|
423
|
+
if (currentVerdict !== pending.validationVerdict) {
|
|
424
|
+
pendingPreflights.delete(taskId);
|
|
425
|
+
throw new TaskCommitOperationError(
|
|
426
|
+
"preflight-outdated",
|
|
427
|
+
"The validation verdict changed after this preflight. Review the current result and preflight again.",
|
|
428
|
+
409
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (pending.acknowledgementRequired && request.acknowledgeValidation !== true) {
|
|
432
|
+
throw new TaskCommitOperationError(
|
|
433
|
+
"acknowledgement-required",
|
|
434
|
+
"Acknowledge the validation verdict explicitly before committing this snapshot.",
|
|
435
|
+
409
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
let canonicalWorktree;
|
|
439
|
+
let current;
|
|
440
|
+
try {
|
|
441
|
+
({ canonicalWorktree, state: current } = await captureVerifiedState(
|
|
442
|
+
task,
|
|
443
|
+
repository,
|
|
444
|
+
"execute"
|
|
445
|
+
));
|
|
446
|
+
} catch (error) {
|
|
447
|
+
pendingPreflights.delete(taskId);
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
if (current.stateFingerprint !== pending.stateFingerprint) {
|
|
451
|
+
pendingPreflights.delete(taskId);
|
|
452
|
+
throw new TaskCommitOperationError(
|
|
453
|
+
"preflight-outdated",
|
|
454
|
+
"The worktree changed after this preflight was approved. Review the new state and preflight again.",
|
|
455
|
+
409
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
pendingPreflights.delete(taskId);
|
|
459
|
+
let publication;
|
|
460
|
+
try {
|
|
461
|
+
publication = await publish({
|
|
462
|
+
worktreePath: canonicalWorktree,
|
|
463
|
+
branch: pending.branch,
|
|
464
|
+
parentCommit: pending.parentCommit,
|
|
465
|
+
treeOid: pending.treeOid,
|
|
466
|
+
message: pending.message,
|
|
467
|
+
stagedPaths: pending.stagedPaths,
|
|
468
|
+
reflogReason: `pita-commit: task ${taskId} preflight ${pending.preflightId}`
|
|
469
|
+
});
|
|
470
|
+
} catch (error) {
|
|
471
|
+
throw mapGitError(error, "execute");
|
|
472
|
+
}
|
|
473
|
+
return taskCommitResultSchema.parse({
|
|
474
|
+
protocol: PROTOCOL_VERSION,
|
|
475
|
+
type: "task-commit-result",
|
|
476
|
+
taskId,
|
|
477
|
+
branch: publication.branch,
|
|
478
|
+
commit: publication.commit,
|
|
479
|
+
treeOid: publication.treeOid,
|
|
480
|
+
parentCommit: publication.parentCommit,
|
|
481
|
+
signed: publication.signed,
|
|
482
|
+
indexSynced: publication.indexSynced,
|
|
483
|
+
headVerified: publication.headVerified,
|
|
484
|
+
capturedAtMs: now()
|
|
485
|
+
});
|
|
486
|
+
} finally {
|
|
487
|
+
activePhases.delete(taskId);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
preflight,
|
|
492
|
+
execute,
|
|
493
|
+
isBusy: (taskId) => activePhases.has(taskId),
|
|
494
|
+
invalidate(taskId) {
|
|
495
|
+
pendingPreflights.delete(taskId);
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
export {
|
|
500
|
+
COMMIT_PREFLIGHT_TTL_MS,
|
|
501
|
+
TaskCommitOperationError,
|
|
502
|
+
createTaskCommitService
|
|
503
|
+
};
|