dsh-hashline-edit 0.1.0 → 0.2.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 +72 -70
- package/cordis.patch.yml +1 -1
- package/lib/index.js +4494 -4813
- package/lib/types/dsh/contracts.d.ts +69 -6
- package/lib/types/dsh/fs.d.ts +38 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/hide.d.ts +78 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +17 -7
- package/lib/types/dsh/metrics/events.d.ts +8 -1
- package/lib/types/dsh/metrics/instrument.d.ts +20 -14
- package/lib/types/dsh/metrics/observer.d.ts +28 -0
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +17 -8
- package/lib/types/dsh/shell-view.d.ts +65 -3
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/context.d.ts +19 -1
- package/lib/types/dsh/tools/output.d.ts +32 -4
- package/lib/types/dsh/tools/read.d.ts +12 -15
- package/lib/types/dsh/tools/undo.d.ts +5 -0
- package/lib/types/dsh/write-hook.d.ts +8 -22
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/bash-view-banner.d.ts +20 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/detection.d.ts +17 -0
- package/lib/types/src/file-content/index.d.ts +2 -2
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/resolve.d.ts +6 -0
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
- package/lib/types/src/mutation-engine/types.d.ts +50 -58
- package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
- package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
- package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
- package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
- package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
- package/lib/types/src/served-session/serve-render.d.ts +9 -3
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +10 -22
- package/prompts/apply-patch-guidelines.md +10 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +5 -5
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +58 -10
- package/src/edit-undo.ts +379 -25
- package/src/file-content/detection.ts +34 -0
- package/src/file-content/index.ts +2 -2
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/lease-resolve.ts +14 -8
- package/src/hashline/resolve.ts +30 -27
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/batch-span-gate.ts +17 -4
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +535 -63
- package/src/mutation-engine/types.ts +49 -65
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +0 -28
- package/src/read-expansion/ast-provider.ts +4 -0
- package/src/read-expansion/grammar-manifest.ts +21 -2
- package/src/read-expansion/java-decoration.ts +34 -0
- package/src/read-expansion/span-expansion.ts +6 -2
- package/src/read-expansion/span-kind.ts +3 -1
- package/src/read-expansion/vue-decoration.ts +43 -0
- package/src/served-session/serve-render.ts +5 -5
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/encoding-bridge.d.ts +0 -25
- package/lib/types/dsh/encoding.d.ts +0 -144
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -124
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -116
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -173
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -637
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- package/src/write-hook.ts +0 -130
|
@@ -85,12 +85,16 @@ import { saveCutIntent, dropCutIntent } from "../undo-store.js";
|
|
|
85
85
|
import { repairCutIntents } from "../cut-repair.js";
|
|
86
86
|
import { withSortedMutationQueues } from "../mutation-queue.js";
|
|
87
87
|
import { resolveTarget, writeAtomic, readBytes } from "../fs-write.js";
|
|
88
|
+
import {
|
|
89
|
+
missingAncestorDirs,
|
|
90
|
+
pathKind,
|
|
91
|
+
pruneEmptyDirs,
|
|
92
|
+
removeFile,
|
|
93
|
+
renameFile,
|
|
94
|
+
assertPathWithinWorkspace,
|
|
95
|
+
} from "../fs-lifecycle.js";
|
|
88
96
|
import { toCwd } from "../paths.js";
|
|
89
|
-
import type {
|
|
90
|
-
DesiredContent,
|
|
91
|
-
NormalizedEditItem,
|
|
92
|
-
NormalizedEditRequest,
|
|
93
|
-
} from "../payload-contract.js";
|
|
97
|
+
import type { DesiredContent, NormalizedEditItem, NormalizedEditRequest } from "./edit-request.js";
|
|
94
98
|
import {
|
|
95
99
|
batchAbortFor,
|
|
96
100
|
batchAbortForMany,
|
|
@@ -99,7 +103,13 @@ import {
|
|
|
99
103
|
wrapParseFailure,
|
|
100
104
|
} from "../edit-response.js";
|
|
101
105
|
import { DEFERRED_STORE_SYNC_WARNING } from "../constants.js";
|
|
102
|
-
import type {
|
|
106
|
+
import type {
|
|
107
|
+
PipelineOptions,
|
|
108
|
+
ProcessedEditFile,
|
|
109
|
+
ForeignCutRecord,
|
|
110
|
+
MemberLifecycle,
|
|
111
|
+
TransactionRecoveryReport,
|
|
112
|
+
} from "./types.js";
|
|
103
113
|
import {
|
|
104
114
|
loadEditFile,
|
|
105
115
|
loadForeignServedView,
|
|
@@ -365,7 +375,11 @@ function replacementTextForPayload(payload: DesiredContent): string {
|
|
|
365
375
|
*/
|
|
366
376
|
type ParsedItem = { edit: HEdit } | { noopInsert: true };
|
|
367
377
|
|
|
368
|
-
function parseEdits(
|
|
378
|
+
function parseEdits(
|
|
379
|
+
items: NormalizedEditRequest["edits"],
|
|
380
|
+
path: string,
|
|
381
|
+
itemNumbers?: readonly number[],
|
|
382
|
+
): ParsedItem[] {
|
|
369
383
|
const parsed: ParsedItem[] = [];
|
|
370
384
|
// WHY: payload parsing aggregates like the span gate — one malformed item must not mask another,
|
|
371
385
|
// WHY: so the model fixes every malformed item in a single resubmission. Parsing is pure (no
|
|
@@ -373,6 +387,9 @@ function parseEdits(items: NormalizedEditRequest["edits"], path: string): Parsed
|
|
|
373
387
|
const failures: { error: Error; index: number }[] = [];
|
|
374
388
|
for (let index = 0; index < items.length; index++) {
|
|
375
389
|
const item = items[index]!;
|
|
390
|
+
// WHY: the number the CALLER submitted — a per-file plan keeps its items' global positions,
|
|
391
|
+
// WHY: so a multi-file refusal still names the item the model sent, not a per-file 0.
|
|
392
|
+
const number = itemNumbers?.[index] ?? index;
|
|
376
393
|
try {
|
|
377
394
|
const payload = item.payload;
|
|
378
395
|
let text: string;
|
|
@@ -388,7 +405,7 @@ function parseEdits(items: NormalizedEditRequest["edits"], path: string): Parsed
|
|
|
388
405
|
if (payload.text === "") {
|
|
389
406
|
throw new DomainError("E_INTERNAL_INVARIANT", {
|
|
390
407
|
message:
|
|
391
|
-
`Edit request edits[${
|
|
408
|
+
`Edit request edits[${number}] "text" must carry at least one line; ` +
|
|
392
409
|
`"text": "" is the deletion payload. Nothing was written.`,
|
|
393
410
|
});
|
|
394
411
|
}
|
|
@@ -438,7 +455,10 @@ function parseEdits(items: NormalizedEditRequest["edits"], path: string): Parsed
|
|
|
438
455
|
});
|
|
439
456
|
} catch (error) {
|
|
440
457
|
if (items.length === 1) throw error;
|
|
441
|
-
failures.push({
|
|
458
|
+
failures.push({
|
|
459
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
460
|
+
index: number,
|
|
461
|
+
});
|
|
442
462
|
}
|
|
443
463
|
}
|
|
444
464
|
if (failures.length === 1) {
|
|
@@ -716,6 +736,7 @@ async function runMutations(
|
|
|
716
736
|
request: NormalizedEditRequest,
|
|
717
737
|
cwd: string,
|
|
718
738
|
options?: PipelineOptions,
|
|
739
|
+
itemNumbers?: readonly number[],
|
|
719
740
|
): Promise<ProcessedEditFile> {
|
|
720
741
|
// WHY: the file was answered at admission (assertReq); the narrowed type carries it here.
|
|
721
742
|
const path = request.file;
|
|
@@ -748,12 +769,19 @@ async function runMutations(
|
|
|
748
769
|
|
|
749
770
|
const isPreview = options?.noPersist === true;
|
|
750
771
|
|
|
751
|
-
const parsed = parseEdits(items, path);
|
|
772
|
+
const parsed = parseEdits(items, path, itemNumbers);
|
|
752
773
|
// WHY: (ticket-04 §3.6) noop-insert markers mutate nothing, so they are not part of the batch
|
|
753
774
|
// WHY: span gate — the gate compares regions the call will rewrite.
|
|
754
775
|
const realEdits: HEdit[] = [];
|
|
755
|
-
|
|
756
|
-
|
|
776
|
+
// WHY: the gate reports failures by item number, and a noop-insert marker is not one of its items,
|
|
777
|
+
// WHY: so the caller-facing numbers are filtered in lockstep with the edits it does compare.
|
|
778
|
+
const realNumbers: number[] = [];
|
|
779
|
+
for (let index = 0; index < parsed.length; index++) {
|
|
780
|
+
const one = parsed[index]!;
|
|
781
|
+
if ("edit" in one) {
|
|
782
|
+
realEdits.push(one.edit);
|
|
783
|
+
realNumbers.push(itemNumbers?.[index] ?? index);
|
|
784
|
+
}
|
|
757
785
|
}
|
|
758
786
|
|
|
759
787
|
const {
|
|
@@ -788,6 +816,9 @@ async function runMutations(
|
|
|
788
816
|
// WHY: compares is the `s'_k` the apply path would rewrite — one source for both (spec §3.2.1).
|
|
789
817
|
await assertBatchSpansDisjoint(realEdits, {
|
|
790
818
|
served,
|
|
819
|
+
// WHY: the caller's own item numbers, parallel to `realEdits` — a per-file plan keeps its
|
|
820
|
+
// WHY: items' global positions, so a gate refusal names the item the model sent.
|
|
821
|
+
itemNumbers: realNumbers,
|
|
791
822
|
identity: leaseSpanSource({
|
|
792
823
|
store: hashStore,
|
|
793
824
|
sessionKey,
|
|
@@ -864,6 +895,7 @@ async function runMutations(
|
|
|
864
895
|
abortIf(options?.signal);
|
|
865
896
|
const item = items[index]!;
|
|
866
897
|
const parsedItem = parsed[index]!;
|
|
898
|
+
const number = itemNumbers?.[index] ?? index;
|
|
867
899
|
if ("noopInsert" in parsedItem) {
|
|
868
900
|
// WHY: (ticket-04 §3.6) an empty "before"/"after" writes nothing by construction: bytes
|
|
869
901
|
// WHY: unchanged, counted as a noop, model channel warned. It skips `applyEdit` AND the
|
|
@@ -871,7 +903,7 @@ async function runMutations(
|
|
|
871
903
|
noopCount += 1;
|
|
872
904
|
warnings.push(
|
|
873
905
|
formatWarning("W_NOOP_INSERT", {
|
|
874
|
-
ref: `edit[${
|
|
906
|
+
ref: `edit[${number}] (${path})`,
|
|
875
907
|
removeFrom: item.target.anchor_from,
|
|
876
908
|
removeTo: item.target.anchor_to,
|
|
877
909
|
}),
|
|
@@ -899,7 +931,7 @@ async function runMutations(
|
|
|
899
931
|
...(isPreview ? {} : { currentIds }),
|
|
900
932
|
onRejected: async (error) => {
|
|
901
933
|
if (items.length === 1) throw error;
|
|
902
|
-
throw batchAbortFor({ error, index, path });
|
|
934
|
+
throw batchAbortFor({ error, index: number, path });
|
|
903
935
|
},
|
|
904
936
|
});
|
|
905
937
|
|
|
@@ -932,7 +964,7 @@ async function runMutations(
|
|
|
932
964
|
removeFrom: item.target.anchor_from,
|
|
933
965
|
removeTo: item.target.anchor_to,
|
|
934
966
|
replacementText: replacementTextForPayload(item.payload),
|
|
935
|
-
ref: `edit[${
|
|
967
|
+
ref: `edit[${number}] (${path})`,
|
|
936
968
|
batch: items.length > 1,
|
|
937
969
|
range,
|
|
938
970
|
hashes: currentHashes,
|
|
@@ -947,12 +979,12 @@ async function runMutations(
|
|
|
947
979
|
// WHY: calls keep the direct rejection path.
|
|
948
980
|
if (decision.action === "reject") {
|
|
949
981
|
if (items.length === 1) throw decision.error;
|
|
950
|
-
throw batchAbortFor({ error: decision.error, index, path });
|
|
982
|
+
throw batchAbortFor({ error: decision.error, index: number, path });
|
|
951
983
|
}
|
|
952
984
|
if (decision.action === "warn") warnings.push(decision.notice);
|
|
953
985
|
if (items.length > 1) {
|
|
954
986
|
warnings.push(
|
|
955
|
-
`edit[${
|
|
987
|
+
`edit[${number}] (${path}) was a noop: the range already contains the replacement text.`,
|
|
956
988
|
);
|
|
957
989
|
}
|
|
958
990
|
pushNoopWarnings(outcome.anchorWarnings);
|
|
@@ -1260,7 +1292,141 @@ async function runCutTransaction(
|
|
|
1260
1292
|
);
|
|
1261
1293
|
}
|
|
1262
1294
|
|
|
1263
|
-
|
|
1295
|
+
// WHY: the cut path is the correlated transaction with the source plans derived from this file's
|
|
1296
|
+
// WHY: own `foreignCuts`; the multi-file path derives them from the same records across every
|
|
1297
|
+
// WHY: target. One skeleton owns the pre-images, undo rows, intent, ordered renames, rollback,
|
|
1298
|
+
// WHY: store commit and the single response (ADR-0028).
|
|
1299
|
+
return await runCorrelatedTransaction(
|
|
1300
|
+
[file, ...sourcePlans],
|
|
1301
|
+
[file, ...sourcePlans],
|
|
1302
|
+
options,
|
|
1303
|
+
sessionKey,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* The correlated multi-file commit: the one skeleton both the foreign-cut path and a multi-target
|
|
1309
|
+
* edit run.
|
|
1310
|
+
*
|
|
1311
|
+
* Steps, in order: durable pre-images for every written file → correlated undo rows → the intent
|
|
1312
|
+
* record → ordered renames (primary first) → best-effort rollback on a failed write → the intent
|
|
1313
|
+
* dropped → one store commit per written file → ONE response whose sections cover every file.
|
|
1314
|
+
*
|
|
1315
|
+
* @param writePlans - the plans to write, in commit order. `writePlans[0]` is the primary: it is
|
|
1316
|
+
* renamed first and its undo row is the intent's target row.
|
|
1317
|
+
* @param sectionPlans - every plan the response reports, including the ones that applied nothing
|
|
1318
|
+
* (those are reported and never committed).
|
|
1319
|
+
* @returns the batch outcome, anchored on the primary file.
|
|
1320
|
+
*/
|
|
1321
|
+
function memberLifecycleOf(plan: ProcessedEditFile): MemberLifecycle {
|
|
1322
|
+
return plan.lifecycle ?? { kind: "keep" };
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function memberPostBytes(plan: ProcessedEditFile): Buffer {
|
|
1326
|
+
return Buffer.from(plan.bom + restoreEndings(plan.result, plan.originalEnding), "utf-8");
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Publish one already-validated member. This is the only place a lifecycle action reaches the
|
|
1331
|
+
* filesystem: content writes keep using writeAtomic (one serializer); add/delete/move use the
|
|
1332
|
+
* fs-lifecycle primitives that writeAtomic deliberately does not express.
|
|
1333
|
+
*/
|
|
1334
|
+
async function commitMember(
|
|
1335
|
+
plan: ProcessedEditFile,
|
|
1336
|
+
rawPreBytes: ReadonlyMap<string, Buffer>,
|
|
1337
|
+
onPublished: (plan: ProcessedEditFile) => void,
|
|
1338
|
+
workspaceRoot: string | undefined,
|
|
1339
|
+
onAfterMoveRename?: (toAbsolutePath: string) => void | Promise<void>,
|
|
1340
|
+
): Promise<void> {
|
|
1341
|
+
const lifecycle = memberLifecycleOf(plan);
|
|
1342
|
+
switch (lifecycle.kind) {
|
|
1343
|
+
case "keep":
|
|
1344
|
+
case "add":
|
|
1345
|
+
if (workspaceRoot !== undefined) {
|
|
1346
|
+
await assertPathWithinWorkspace(workspaceRoot, plan.absolutePath);
|
|
1347
|
+
}
|
|
1348
|
+
await writeAtomic(plan.absolutePath, memberPostBytes(plan));
|
|
1349
|
+
onPublished(plan);
|
|
1350
|
+
return;
|
|
1351
|
+
case "delete":
|
|
1352
|
+
await removeFile(plan.absolutePath);
|
|
1353
|
+
onPublished(plan);
|
|
1354
|
+
return;
|
|
1355
|
+
case "move": {
|
|
1356
|
+
// WHY: rename first, then (only for an edited move) rewrite the destination. A pure move
|
|
1357
|
+
// WHY: must stay a rename: re-serializing would launder the file's raw line endings.
|
|
1358
|
+
await renameFile(
|
|
1359
|
+
lifecycle.fromAbsolutePath,
|
|
1360
|
+
lifecycle.toAbsolutePath,
|
|
1361
|
+
undefined,
|
|
1362
|
+
workspaceRoot,
|
|
1363
|
+
);
|
|
1364
|
+
// WHY: the rename is already a publication: a later content-write failure must roll this
|
|
1365
|
+
// WHY: member back or the source disappears while the failure claims nothing changed.
|
|
1366
|
+
onPublished(plan);
|
|
1367
|
+
if (onAfterMoveRename !== undefined) await onAfterMoveRename(lifecycle.toAbsolutePath);
|
|
1368
|
+
const post = memberPostBytes(plan);
|
|
1369
|
+
// WHY: a pure rename must not re-serialize: the normalized text equals the baseline text,
|
|
1370
|
+
// WHY: so the destination keeps the exact bytes the OS rename carried.
|
|
1371
|
+
if (
|
|
1372
|
+
plan.originalNormalized !== plan.result &&
|
|
1373
|
+
!post.equals(rawPreBytes.get(plan.absolutePath)!)
|
|
1374
|
+
) {
|
|
1375
|
+
await writeAtomic(lifecycle.toAbsolutePath, post);
|
|
1376
|
+
}
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Restore one member that the commit had already published. A rollback is a BYTE restore, never
|
|
1384
|
+
* a re-serialization: keep/delete write the captured pre-image buffer back; add removes what it
|
|
1385
|
+
* created (and the empty directories it made); move renames back first, then restores the bytes.
|
|
1386
|
+
*/
|
|
1387
|
+
async function rollbackMember(
|
|
1388
|
+
plan: ProcessedEditFile,
|
|
1389
|
+
rawPreBytes: ReadonlyMap<string, Buffer>,
|
|
1390
|
+
workspaceRoot: string | undefined,
|
|
1391
|
+
): Promise<void> {
|
|
1392
|
+
const lifecycle = memberLifecycleOf(plan);
|
|
1393
|
+
const pre = rawPreBytes.get(plan.absolutePath)!;
|
|
1394
|
+
switch (lifecycle.kind) {
|
|
1395
|
+
case "add":
|
|
1396
|
+
if ((await pathKind(plan.absolutePath)) !== "absent") await removeFile(plan.absolutePath);
|
|
1397
|
+
await pruneEmptyDirs(lifecycle.createdDirs);
|
|
1398
|
+
return;
|
|
1399
|
+
case "delete":
|
|
1400
|
+
await writeAtomic(plan.absolutePath, pre);
|
|
1401
|
+
return;
|
|
1402
|
+
case "move": {
|
|
1403
|
+
if ((await pathKind(lifecycle.toAbsolutePath)) !== "absent") {
|
|
1404
|
+
await renameFile(
|
|
1405
|
+
lifecycle.toAbsolutePath,
|
|
1406
|
+
lifecycle.fromAbsolutePath,
|
|
1407
|
+
undefined,
|
|
1408
|
+
workspaceRoot,
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
// WHY: the rename carries the pre bytes for a pure move and for a failed content write; only
|
|
1412
|
+
// WHY: rewrite when the destination really holds different bytes (a completed edit rolled back).
|
|
1413
|
+
if (!(await readBytes(lifecycle.fromAbsolutePath)).equals(pre)) {
|
|
1414
|
+
await writeAtomic(lifecycle.fromAbsolutePath, pre);
|
|
1415
|
+
}
|
|
1416
|
+
await pruneEmptyDirs(lifecycle.createdDirs);
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
case "keep":
|
|
1420
|
+
await writeAtomic(plan.absolutePath, pre);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
async function runCorrelatedTransaction(
|
|
1424
|
+
writePlans: ProcessedEditFile[],
|
|
1425
|
+
sectionPlans: ProcessedEditFile[],
|
|
1426
|
+
options: PipelineOptions | undefined,
|
|
1427
|
+
sessionKey: string,
|
|
1428
|
+
): Promise<BatchOutcome> {
|
|
1429
|
+
const primary = writePlans[0]!;
|
|
1264
1430
|
const txnId = randomUUID();
|
|
1265
1431
|
|
|
1266
1432
|
// WHY: STEP 1 durable pre-images: undo rows for EVERY file (pre bytes + post bytes + the
|
|
@@ -1270,21 +1436,63 @@ async function runCutTransaction(
|
|
|
1270
1436
|
// WHY: first rename. Byte identity is not promised by this read, it is PROVEN upstream: the
|
|
1271
1437
|
// WHY: admission round-trip guard refused any file whose decoded text re-encodes differently,
|
|
1272
1438
|
// WHY: so decode/encode here is lossless by construction, stray line-break spellings included
|
|
1273
|
-
// WHY: (ticket-04b §11: after an abort
|
|
1439
|
+
// WHY: (ticket-04b §11: after an abort EVERY file is byte-identical to its pre bytes).
|
|
1274
1440
|
const rawPreBytes = new Map<string, Buffer>();
|
|
1275
|
-
for (const plan of
|
|
1441
|
+
for (const plan of writePlans) {
|
|
1442
|
+
const lifecycle = plan.lifecycle;
|
|
1443
|
+
if (lifecycle?.kind === "add") {
|
|
1444
|
+
// WHY: an add has no pre-image by construction. A dry buffer keeps every consumer of the
|
|
1445
|
+
// WHY: map total (rollback reads it, undo carries it as NULL through its lifecycle arm).
|
|
1446
|
+
rawPreBytes.set(plan.absolutePath, Buffer.alloc(0));
|
|
1447
|
+
plan.lifecycle = { ...lifecycle, createdDirs: await missingAncestorDirs(plan.absolutePath) };
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
if (lifecycle?.kind === "move") {
|
|
1451
|
+
rawPreBytes.set(plan.absolutePath, await readBytes(lifecycle.fromAbsolutePath));
|
|
1452
|
+
plan.lifecycle = {
|
|
1453
|
+
...lifecycle,
|
|
1454
|
+
createdDirs: await missingAncestorDirs(lifecycle.toAbsolutePath),
|
|
1455
|
+
};
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1276
1458
|
rawPreBytes.set(plan.absolutePath, await readBytes(plan.absolutePath));
|
|
1277
1459
|
}
|
|
1278
1460
|
const saved: { restore: () => Promise<void> }[] = [];
|
|
1279
|
-
for (const plan of
|
|
1461
|
+
for (const plan of writePlans) {
|
|
1462
|
+
const lifecycle = plan.lifecycle;
|
|
1463
|
+
const isAdd = lifecycle?.kind === "add";
|
|
1464
|
+
const isDelete = lifecycle?.kind === "delete";
|
|
1465
|
+
const isMove = lifecycle?.kind === "move";
|
|
1466
|
+
const rawPost = Buffer.from(
|
|
1467
|
+
plan.bom + restoreEndings(plan.result, plan.originalEnding),
|
|
1468
|
+
"utf-8",
|
|
1469
|
+
);
|
|
1470
|
+
// WHY: a pure move carries the raw pre bytes to the destination; recording the canonical
|
|
1471
|
+
// WHY: fold as its post image would make undo's byte oracle reject a legal mixed-ending file.
|
|
1472
|
+
const storedRawPost =
|
|
1473
|
+
isMove && plan.originalNormalized === plan.result
|
|
1474
|
+
? rawPreBytes.get(plan.absolutePath)!
|
|
1475
|
+
: rawPost;
|
|
1280
1476
|
const undo = await saveUndo(plan.absolutePath, {
|
|
1281
|
-
content: plan.originalNormalized,
|
|
1477
|
+
content: isAdd ? "" : plan.originalNormalized,
|
|
1282
1478
|
bom: plan.bom,
|
|
1283
1479
|
originalEnding: plan.originalEnding,
|
|
1284
1480
|
hashes: plan.originalHashes,
|
|
1285
|
-
resultContent: plan.result,
|
|
1481
|
+
resultContent: isDelete ? "" : plan.result,
|
|
1286
1482
|
transactionId: txnId,
|
|
1287
|
-
rawPre: rawPreBytes.get(plan.absolutePath)!.toString("utf-8"),
|
|
1483
|
+
rawPre: isAdd ? null : rawPreBytes.get(plan.absolutePath)!.toString("utf-8"),
|
|
1484
|
+
...(isMove
|
|
1485
|
+
? {
|
|
1486
|
+
lifecycle: "move" as const,
|
|
1487
|
+
fromPath: lifecycle.fromAbsolutePath,
|
|
1488
|
+
toPath: lifecycle.toAbsolutePath,
|
|
1489
|
+
rawPost: storedRawPost.toString("utf-8"),
|
|
1490
|
+
}
|
|
1491
|
+
: isAdd
|
|
1492
|
+
? { lifecycle: "add" as const, createdDirs: lifecycle.createdDirs }
|
|
1493
|
+
: isDelete
|
|
1494
|
+
? { lifecycle: "delete" as const }
|
|
1495
|
+
: { lifecycle: "edit" as const }),
|
|
1288
1496
|
});
|
|
1289
1497
|
if (!undo.persisted) {
|
|
1290
1498
|
for (const prior of saved) await prior.restore();
|
|
@@ -1293,17 +1501,17 @@ async function runCutTransaction(
|
|
|
1293
1501
|
saved.push(undo);
|
|
1294
1502
|
}
|
|
1295
1503
|
try {
|
|
1296
|
-
await saveCutIntent(txnId,
|
|
1504
|
+
await saveCutIntent(txnId, primary.absolutePath);
|
|
1297
1505
|
} catch (error) {
|
|
1298
1506
|
for (const prior of saved) await prior.restore();
|
|
1299
1507
|
throw error;
|
|
1300
1508
|
}
|
|
1301
1509
|
|
|
1302
|
-
// WHY: STEP 2 ordered commit: the
|
|
1303
|
-
// WHY: raw pre-images captured in STEP 1 above. The commit path serializes canonically
|
|
1304
|
-
// WHY: convention, no third path); a ROLLBACK is not a commit and must not smuggle in the
|
|
1305
|
-
// WHY: canonical fold as if it were the restore — it writes the captured BUFFERS (ticket-04b
|
|
1306
|
-
// WHY:
|
|
1510
|
+
// WHY: STEP 2 ordered commit: the primary file first, then every remaining file in plan order,
|
|
1511
|
+
// WHY: using the raw pre-images captured in STEP 1 above. The commit path serializes canonically
|
|
1512
|
+
// WHY: (one convention, no third path); a ROLLBACK is not a commit and must not smuggle in the
|
|
1513
|
+
// WHY: canonical fold as if it were the restore — it writes the captured BUFFERS (ticket-04b §11:
|
|
1514
|
+
// WHY: after an abort EVERY file is byte-identical to the pre-transaction contents).
|
|
1307
1515
|
const written: ProcessedEditFile[] = [];
|
|
1308
1516
|
try {
|
|
1309
1517
|
if (options?.onBeforeFirstCutWrite) {
|
|
@@ -1312,38 +1520,45 @@ async function runCutTransaction(
|
|
|
1312
1520
|
await options.onBeforeFirstCutWrite();
|
|
1313
1521
|
}
|
|
1314
1522
|
abortIf(options?.signal);
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
);
|
|
1319
|
-
written.push(file);
|
|
1320
|
-
for (const plan of sourcePlans) {
|
|
1321
|
-
if (options?.onCutBetweenWrites) {
|
|
1523
|
+
for (let index = 0; index < writePlans.length; index++) {
|
|
1524
|
+
const plan = writePlans[index]!;
|
|
1525
|
+
if (index > 0 && options?.onCutBetweenWrites) {
|
|
1322
1526
|
// WHY: test-only fault-injection seam (ticket-04b §3): fires INSIDE the two-rename
|
|
1323
1527
|
// WHY: window, after the previous file's rename is durable.
|
|
1324
1528
|
await options.onCutBetweenWrites(written[written.length - 1]!.absolutePath);
|
|
1325
1529
|
}
|
|
1326
1530
|
abortIf(options?.signal);
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1531
|
+
const publish = (member: ProcessedEditFile): void => {
|
|
1532
|
+
written.push(member);
|
|
1533
|
+
};
|
|
1534
|
+
await commitMember(
|
|
1535
|
+
plan,
|
|
1536
|
+
rawPreBytes,
|
|
1537
|
+
publish,
|
|
1538
|
+
options?.workspaceRoot,
|
|
1539
|
+
options?.onAfterMoveRename,
|
|
1330
1540
|
);
|
|
1331
|
-
written.push(plan);
|
|
1332
1541
|
}
|
|
1333
1542
|
} catch (error) {
|
|
1334
|
-
|
|
1543
|
+
// WHY: the recovery report is the honest three-way split the contract requires: restored =
|
|
1544
|
+
// WHY: proven back at its pre-image, remaining = rollback did not run / did not hold,
|
|
1545
|
+
// WHY: uncertain = the rollback attempt itself threw (we cannot claim either state).
|
|
1546
|
+
const report: TransactionRecoveryReport = { restored: [], remaining: [], uncertain: [] };
|
|
1335
1547
|
for (const plan of written) {
|
|
1336
1548
|
try {
|
|
1337
|
-
await
|
|
1549
|
+
await rollbackMember(plan, rawPreBytes, options?.workspaceRoot);
|
|
1550
|
+
report.restored.push(plan.path);
|
|
1338
1551
|
} catch (rollbackError) {
|
|
1339
|
-
|
|
1552
|
+
report.remaining.push(plan.path);
|
|
1553
|
+
report.uncertain.push(plan.path);
|
|
1340
1554
|
reportDiagnostic(
|
|
1341
1555
|
"mutation-engine",
|
|
1342
|
-
"Failed to roll back a
|
|
1556
|
+
"Failed to roll back a transaction member:",
|
|
1343
1557
|
rollbackError,
|
|
1344
1558
|
);
|
|
1345
1559
|
}
|
|
1346
1560
|
}
|
|
1561
|
+
const rolledBack = report.remaining.length === 0;
|
|
1347
1562
|
if (rolledBack) {
|
|
1348
1563
|
for (const prior of saved) await prior.restore();
|
|
1349
1564
|
try {
|
|
@@ -1357,8 +1572,8 @@ async function runCutTransaction(
|
|
|
1357
1572
|
}
|
|
1358
1573
|
}
|
|
1359
1574
|
// WHY: a rollback that did not hold LEAVES the intent row and the transaction-tagged undo
|
|
1360
|
-
// WHY: rows: next-run repair resolves to complete-the-
|
|
1361
|
-
|
|
1575
|
+
// WHY: rows: next-run repair resolves to complete-the-transaction or restore-from-rows.
|
|
1576
|
+
(error as Error & { recovery?: TransactionRecoveryReport }).recovery = report;
|
|
1362
1577
|
throw error;
|
|
1363
1578
|
}
|
|
1364
1579
|
|
|
@@ -1371,38 +1586,288 @@ async function runCutTransaction(
|
|
|
1371
1586
|
}
|
|
1372
1587
|
|
|
1373
1588
|
// WHY: STEP 3 store commit per file — the same `finishCommittedFile` the single-file path
|
|
1374
|
-
// WHY: runs, then ONE response whose sections cover
|
|
1589
|
+
// WHY: runs, then ONE response whose sections cover EVERY file (§5). A plan that applied nothing
|
|
1590
|
+
// WHY: is REPORTED but never committed: its bytes did not move, so it owns no store write.
|
|
1375
1591
|
const sections = [];
|
|
1376
|
-
for (const plan of
|
|
1377
|
-
|
|
1592
|
+
for (const plan of sectionPlans) {
|
|
1593
|
+
// WHY: a deleted file has no post-image to snapshot or serve; every other member commits its
|
|
1594
|
+
// WHY: new anchors through the same finishCommittedFile the single-file edit uses.
|
|
1595
|
+
const materializes = plan.appliedCount > 0 && memberLifecycleOf(plan).kind !== "delete";
|
|
1596
|
+
sections.push(
|
|
1597
|
+
toSection(
|
|
1598
|
+
plan,
|
|
1599
|
+
materializes ? await finishCommittedFile(plan, options, sessionKey) : undefined,
|
|
1600
|
+
),
|
|
1601
|
+
);
|
|
1378
1602
|
}
|
|
1379
|
-
|
|
1603
|
+
return batchOutcome(sectionPlans, buildBatchResult(sections));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/** One group of items sharing a target file, keyed by that file's RESOLVED path. */
|
|
1607
|
+
interface TargetGroup {
|
|
1608
|
+
/** Resolved absolute path — the identity two spellings of one file share (§9.4). */
|
|
1609
|
+
readonly key: string;
|
|
1610
|
+
/** The spelling this group's items used, for messages and the response section. */
|
|
1611
|
+
readonly file: string;
|
|
1612
|
+
readonly items: NormalizedEditItem[];
|
|
1613
|
+
/**
|
|
1614
|
+
* The call-relative 0-based number of every item in `items`, parallel to it: a refusal must name
|
|
1615
|
+
* the item the model submitted, not this group's position inside the plan.
|
|
1616
|
+
*/
|
|
1617
|
+
readonly numbers: number[];
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/** What one plan or committed batch reports: the primary file leads, every section rides the result. */
|
|
1621
|
+
interface BatchOutcome {
|
|
1622
|
+
result: string;
|
|
1623
|
+
diff: string;
|
|
1624
|
+
drift: string | undefined;
|
|
1625
|
+
metrics: ReturnType<typeof buildBatchResult>["details"]["metrics"];
|
|
1626
|
+
raw: ProcessedEditFile;
|
|
1627
|
+
toolResult: ReturnType<typeof buildBatchResult>;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** Shape one batch outcome around its primary plan: the first plan leads, every plan's drift joins. */
|
|
1631
|
+
function batchOutcome(
|
|
1632
|
+
plans: ProcessedEditFile[],
|
|
1633
|
+
toolResult: ReturnType<typeof buildBatchResult>,
|
|
1634
|
+
): BatchOutcome {
|
|
1635
|
+
const primary = plans[0]!;
|
|
1380
1636
|
const drift = plans
|
|
1381
|
-
.map((
|
|
1382
|
-
.filter((
|
|
1637
|
+
.map((plan) => plan.driftNotice)
|
|
1638
|
+
.filter((notice): notice is string => notice !== undefined)
|
|
1383
1639
|
.join("\n\n");
|
|
1384
1640
|
return {
|
|
1385
|
-
result:
|
|
1641
|
+
result: primary.result,
|
|
1386
1642
|
diff: toolResult.details.diff ?? "",
|
|
1387
1643
|
drift: drift === "" ? undefined : drift,
|
|
1388
1644
|
metrics: toolResult.details.metrics,
|
|
1389
|
-
raw:
|
|
1645
|
+
raw: primary,
|
|
1390
1646
|
toolResult,
|
|
1391
1647
|
};
|
|
1392
1648
|
}
|
|
1393
1649
|
|
|
1650
|
+
/** True when a request widens its targets past the call's single `file`. */
|
|
1651
|
+
function hasItemFiles(request: NormalizedEditRequest): boolean {
|
|
1652
|
+
return request.edits.some((item) => item.file !== undefined);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** The request one group plans: its items on its own file, under the call's mode. */
|
|
1656
|
+
function groupRequest(group: TargetGroup, request: NormalizedEditRequest): NormalizedEditRequest {
|
|
1657
|
+
return {
|
|
1658
|
+
file: group.file,
|
|
1659
|
+
edits: group.items,
|
|
1660
|
+
...(request.mode !== undefined ? { mode: request.mode } : {}),
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Group one request's items by the file each edits: an item's own `file` when it names one, else the
|
|
1666
|
+
* request's top-level `file`. Groups keep first-appearance order, so the call's FIRST item names the
|
|
1667
|
+
* primary file.
|
|
1668
|
+
*
|
|
1669
|
+
* WHY resolved paths as the key: `./a.ts` and `a.ts` are one file (§9.4), and every per-file
|
|
1670
|
+
* mechanism this plan reuses — lease resolution, the batch-span gate, undo, the served commit —
|
|
1671
|
+
* keys on the resolved identity.
|
|
1672
|
+
*/
|
|
1673
|
+
async function groupItemsByTarget(
|
|
1674
|
+
request: NormalizedEditRequest,
|
|
1675
|
+
cwd: string,
|
|
1676
|
+
): Promise<TargetGroup[]> {
|
|
1677
|
+
const groups: TargetGroup[] = [];
|
|
1678
|
+
const byKey = new Map<string, TargetGroup>();
|
|
1679
|
+
for (let index = 0; index < request.edits.length; index++) {
|
|
1680
|
+
const item = request.edits[index]!;
|
|
1681
|
+
const file = item.file ?? request.file;
|
|
1682
|
+
const key = await resolveTarget(toCwd(file, cwd));
|
|
1683
|
+
let group = byKey.get(key);
|
|
1684
|
+
if (group === undefined) {
|
|
1685
|
+
group = { key, file, items: [], numbers: [] };
|
|
1686
|
+
byKey.set(key, group);
|
|
1687
|
+
groups.push(group);
|
|
1688
|
+
}
|
|
1689
|
+
group.items.push(item);
|
|
1690
|
+
group.numbers.push(index);
|
|
1691
|
+
}
|
|
1692
|
+
return groups;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* Fold every foreign `cut`'s retirement into the group of the file it retires.
|
|
1697
|
+
*
|
|
1698
|
+
* WHY: a cut's source may itself be another target of the same call, and one file must be planned —
|
|
1699
|
+
* and written — exactly once. A source the call does not otherwise edit becomes a group of its own:
|
|
1700
|
+
* exactly the source plan the single-target cut path builds, with its own load, lease verification,
|
|
1701
|
+
* batch-span gate and undo row. The retirement is the ordinary deletion payload over the SUBMITTED
|
|
1702
|
+
* anchors, so it goes through the same parse and the same gates as any other item.
|
|
1703
|
+
*/
|
|
1704
|
+
async function foldCutRetirements(
|
|
1705
|
+
groups: TargetGroup[],
|
|
1706
|
+
request: NormalizedEditRequest,
|
|
1707
|
+
cwd: string,
|
|
1708
|
+
): Promise<TargetGroup[]> {
|
|
1709
|
+
const byKey = new Map(groups.map((group) => [group.key, group] as const));
|
|
1710
|
+
// WHY: snapshot the target groups first — a retirement may ADD a group while this loop runs.
|
|
1711
|
+
const targets = [...groups];
|
|
1712
|
+
for (const group of targets) {
|
|
1713
|
+
for (let local = 0; local < group.items.length; local++) {
|
|
1714
|
+
const item = group.items[local]!;
|
|
1715
|
+
const payload = item.payload;
|
|
1716
|
+
if (payload.kind !== "reference" || payload.mode !== "cut") continue;
|
|
1717
|
+
// WHY: the same predicate and the same item target file the planning pre-pass uses, so the
|
|
1718
|
+
// WHY: two halves cannot disagree about which references are foreign.
|
|
1719
|
+
const refFile = await foreignRefFileOf(item, { cwd, file: group.file });
|
|
1720
|
+
if (refFile === undefined) continue;
|
|
1721
|
+
const key = await resolveTarget(toCwd(refFile, cwd));
|
|
1722
|
+
let source = byKey.get(key);
|
|
1723
|
+
if (source === undefined) {
|
|
1724
|
+
source = { key, file: refFile, items: [], numbers: [] };
|
|
1725
|
+
byKey.set(key, source);
|
|
1726
|
+
groups.push(source);
|
|
1727
|
+
}
|
|
1728
|
+
source.items.push({
|
|
1729
|
+
target: {
|
|
1730
|
+
anchor_from: payload.span.anchor_from,
|
|
1731
|
+
anchor_to: payload.span.anchor_to,
|
|
1732
|
+
},
|
|
1733
|
+
at: "in-place",
|
|
1734
|
+
payload: { kind: "empty" },
|
|
1735
|
+
});
|
|
1736
|
+
source.numbers.push(group.numbers[local]!);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
return groups;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Order groups for planning and writing: the primary (the call's first item's file) first, then the
|
|
1744
|
+
* rest by resolved path.
|
|
1745
|
+
*
|
|
1746
|
+
* WHY the primary leads: it is renamed first and its undo row is the transaction's target row, so
|
|
1747
|
+
* next-run repair reads its POST state to decide "complete the rest" — the cut path's own ordering
|
|
1748
|
+
* rule (ADR-0028), generalized from two files to N.
|
|
1749
|
+
*/
|
|
1750
|
+
function orderGroups(groups: TargetGroup[]): TargetGroup[] {
|
|
1751
|
+
const [primary, ...rest] = groups;
|
|
1752
|
+
if (primary === undefined) return [];
|
|
1753
|
+
rest.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
|
|
1754
|
+
return [primary, ...rest];
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** Plan every ordered group: one per-file pass through the existing load → gate → buffer path. */
|
|
1758
|
+
async function planGroups(
|
|
1759
|
+
groups: TargetGroup[],
|
|
1760
|
+
request: NormalizedEditRequest,
|
|
1761
|
+
cwd: string,
|
|
1762
|
+
options: PipelineOptions | undefined,
|
|
1763
|
+
): Promise<ProcessedEditFile[]> {
|
|
1764
|
+
const plans: ProcessedEditFile[] = [];
|
|
1765
|
+
for (const group of groups) {
|
|
1766
|
+
plans.push(await runMutations(groupRequest(group, request), cwd, options, group.numbers));
|
|
1767
|
+
}
|
|
1768
|
+
return plans;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* Plan a multi-file request without persisting, for the preview seam.
|
|
1773
|
+
* @returns one plan per target file in commit order, or `undefined` when the call names a single
|
|
1774
|
+
* file — the caller then keeps its own single-file preview path.
|
|
1775
|
+
*/
|
|
1776
|
+
export async function planEditTargets(
|
|
1777
|
+
request: NormalizedEditRequest,
|
|
1778
|
+
cwd: string,
|
|
1779
|
+
options?: PipelineOptions,
|
|
1780
|
+
): Promise<ProcessedEditFile[] | undefined> {
|
|
1781
|
+
if (!hasItemFiles(request)) return undefined;
|
|
1782
|
+
const groups = orderGroups(
|
|
1783
|
+
await foldCutRetirements(await groupItemsByTarget(request, cwd), request, cwd),
|
|
1784
|
+
);
|
|
1785
|
+
return await planGroups(groups, request, cwd, { ...options, noPersist: true });
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1394
1788
|
export async function apply(
|
|
1395
1789
|
request: NormalizedEditRequest,
|
|
1396
1790
|
cwd: string,
|
|
1397
1791
|
options?: PipelineOptions,
|
|
1398
|
-
): Promise<{
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1792
|
+
): Promise<BatchOutcome> {
|
|
1793
|
+
// WHY: the fast path — a request without item-level files is the pre-existing single-file edit
|
|
1794
|
+
// WHY: and takes exactly the code it always took: no grouping round-trip, no extra lock, no intent.
|
|
1795
|
+
if (!hasItemFiles(request)) {
|
|
1796
|
+
return await applySingleFile(request, cwd, options);
|
|
1797
|
+
}
|
|
1798
|
+
const groups = orderGroups(
|
|
1799
|
+
await foldCutRetirements(await groupItemsByTarget(request, cwd), request, cwd),
|
|
1800
|
+
);
|
|
1801
|
+
const primaryGroup = groups[0];
|
|
1802
|
+
if (groups.length === 1 && primaryGroup !== undefined) {
|
|
1803
|
+
// WHY: one item-level file for the whole call — the same single-file machinery, run on the file
|
|
1804
|
+
// WHY: the items name. Items that merely repeat the top-level `file` rebuild the same request.
|
|
1805
|
+
return await applySingleFile(groupRequest(primaryGroup, request), cwd, options);
|
|
1806
|
+
}
|
|
1807
|
+
return await applyMultiFile(groups, request, cwd, options);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Plan and commit several target files as ONE correlated transaction: each file gets its own load,
|
|
1812
|
+
* lease verification, batch-span gate and working buffer, and the write set commits with the same
|
|
1813
|
+
* pre-images, undo rows, intent record and rollback the foreign-cut path uses.
|
|
1814
|
+
*
|
|
1815
|
+
* @param groups - every target group in commit order; `groups[0]` is the primary.
|
|
1816
|
+
* @param request - the admitted call, supplying the mode.
|
|
1817
|
+
* @param cwd - the session working directory every request path resolves against.
|
|
1818
|
+
* @param options - the pipeline options; `noPersist` selects the preview half.
|
|
1819
|
+
* @returns the batch outcome, anchored on the primary file.
|
|
1820
|
+
*/
|
|
1821
|
+
async function applyMultiFile(
|
|
1822
|
+
groups: TargetGroup[],
|
|
1823
|
+
request: NormalizedEditRequest,
|
|
1824
|
+
cwd: string,
|
|
1825
|
+
options: PipelineOptions | undefined,
|
|
1826
|
+
): Promise<BatchOutcome> {
|
|
1827
|
+
if (options?.noPersist === true) {
|
|
1828
|
+
// WHY: no queues for a preview: it writes nothing, which is the single-file preview's own rule.
|
|
1829
|
+
const plans = await planGroups(groups, request, cwd, options);
|
|
1830
|
+
return batchOutcome(plans, buildBatchResult(plans.map((plan) => toSection(plan))));
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// WHY: (ticket-04b §2) repair before work, exactly as the single-file path does: a previous run
|
|
1834
|
+
// WHY: that crashed inside a correlated window left a durable intent this call resolves first.
|
|
1835
|
+
await repairCutIntents(options?.store);
|
|
1836
|
+
const sessionKey = requireSessionKey(options?.sessionKey);
|
|
1837
|
+
|
|
1838
|
+
// WHY: (ticket-04b §2) the transaction serializes EVERY file it will write, in deterministic
|
|
1839
|
+
// WHY: sorted order (`withSortedMutationQueues`): two calls whose file sets overlap in different
|
|
1840
|
+
// WHY: orders can never deadlock.
|
|
1841
|
+
return await withSortedMutationQueues(
|
|
1842
|
+
groups.map((group) => group.key),
|
|
1843
|
+
async () => {
|
|
1844
|
+
abortIf(options?.signal);
|
|
1845
|
+
const plans = await planGroups(groups, request, cwd, {
|
|
1846
|
+
...options,
|
|
1847
|
+
sessionKey,
|
|
1848
|
+
accessMode: options?.accessMode ?? constants.R_OK | constants.W_OK,
|
|
1849
|
+
});
|
|
1850
|
+
const writePlans = plans.filter((plan) => plan.appliedCount > 0);
|
|
1851
|
+
if (writePlans.length === 0) {
|
|
1852
|
+
// WHY: an all-noop call writes nothing, so it takes no undo row and no intent — the
|
|
1853
|
+
// WHY: single-file noop contract, over every file the call named.
|
|
1854
|
+
return batchOutcome(plans, buildBatchResult(plans.map((plan) => toSection(plan))));
|
|
1855
|
+
}
|
|
1856
|
+
return await runCorrelatedTransaction(writePlans, plans, options, sessionKey);
|
|
1857
|
+
},
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
/**
|
|
1862
|
+
* The single-file path: one target, the call's top-level `file`. Unchanged from before multi-file
|
|
1863
|
+
* edit existed — a request with item-level files whose items all name ONE file is funnelled here
|
|
1864
|
+
* with that file's own request, so this path owns every single-target semantic in one place.
|
|
1865
|
+
*/
|
|
1866
|
+
async function applySingleFile(
|
|
1867
|
+
request: NormalizedEditRequest,
|
|
1868
|
+
cwd: string,
|
|
1869
|
+
options?: PipelineOptions,
|
|
1870
|
+
): Promise<BatchOutcome> {
|
|
1406
1871
|
const isPreview = options?.noPersist === true;
|
|
1407
1872
|
if (isPreview) {
|
|
1408
1873
|
const file = await runMutations(request, cwd, options);
|
|
@@ -1516,3 +1981,10 @@ export async function execEdits(
|
|
|
1516
1981
|
): Promise<ProcessedEditFile> {
|
|
1517
1982
|
return runMutations(request, cwd, options);
|
|
1518
1983
|
}
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* Visibility-only export of the correlated transaction for apply_patch's commit seam. The
|
|
1987
|
+
* function body is the one the cut path already uses; apply_patch members carry an optional
|
|
1988
|
+
* lifecycle action read by that body. No engine semantics change.
|
|
1989
|
+
*/
|
|
1990
|
+
export { runCorrelatedTransaction as commitTransaction };
|