@aefree/pi-plastic 0.8.4 → 0.8.5
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/CHANGELOG.md +10 -0
- package/docs/2026-10-06-merging.md +11 -11
- package/docs/2026-10-06-status.md +3 -2
- package/docs/2026-10-06-workspace-operations.md +2 -2
- package/docs/2026-10-21-observed-workflow-profiles.md +54 -0
- package/docs/README.md +1 -0
- package/package.json +2 -2
- package/src/domain/checkin-contract.ts +5 -4
- package/src/domain/copied-merge.ts +1 -1
- package/src/domain/merge-output.ts +22 -7
- package/src/domain/merged-workspace.ts +63 -27
- package/src/domain/pending.ts +48 -0
- package/src/domain/server-merge-contract.ts +1 -1
- package/src/domain/status-xml.ts +20 -6
- package/src/operations/checkin-receipt.ts +61 -2
- package/src/operations/consolidated-diff.ts +31 -7
- package/src/operations/patch-receipt.ts +9 -2
- package/src/operations/server-merge.ts +62 -11
- package/src/pi/checkin-output.ts +5 -2
- package/src/pi/closeout-output.ts +1 -1
- package/src/pi/diff-output.ts +2 -2
- package/src/pi/server-merge-output.ts +11 -3
- package/src/pi/status-output.ts +38 -4
- package/src/pi/workspace-merge-output.ts +1 -1
package/src/domain/pending.ts
CHANGED
|
@@ -14,6 +14,8 @@ export type PendingItem = {
|
|
|
14
14
|
revisionId?: string;
|
|
15
15
|
sourceWorkspacePath?: string;
|
|
16
16
|
baseRepository?: string;
|
|
17
|
+
/** Observation-only fraction in [0, 1]; producer precision is transport-specific. */
|
|
18
|
+
similarity?: number;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type PendingItemSummary = {
|
|
@@ -35,6 +37,8 @@ export type MachineReadableStatusItem = {
|
|
|
35
37
|
isDirectory: boolean;
|
|
36
38
|
revisionId?: string;
|
|
37
39
|
sourcePath?: string;
|
|
40
|
+
/** Observation-only fraction in [0, 1], never rename authorization. */
|
|
41
|
+
similarity?: number;
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
export type MachineReadableStatusSummary = Omit<PendingItemSummary, "privatePaths">;
|
|
@@ -185,11 +189,29 @@ export const parseMachineReadablePendingItems = (output: string, cwd: string): P
|
|
|
185
189
|
export const hasStatusPathDecodingLoss = (path: string, platform: NodeJS.Platform = process.platform): boolean =>
|
|
186
190
|
path.includes("\uFFFD") || (platform === "win32" && path.includes("?"));
|
|
187
191
|
|
|
192
|
+
// Observation-only lexical identities. Never use these diagnostics as mutation admission.
|
|
193
|
+
export const statusObservationPathKey = (value: string): string | null => {
|
|
194
|
+
if (!value || value.length > 4096 || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value)) return null;
|
|
195
|
+
const path = value.replace(/\\/g, "/");
|
|
196
|
+
if (!/^(?:\/|[A-Za-z]:\/)/.test(path) || path.split("/").some(part => part === "." || part === "..")) return null;
|
|
197
|
+
const windows = /^[A-Za-z]:\//.test(path) || path.startsWith("//");
|
|
198
|
+
const tail = path.slice(path.startsWith("//") ? 2 : windows ? 3 : 1);
|
|
199
|
+
if (tail.includes("//") || (windows && (/[<>:"|?*]/.test(tail) || tail.split("/").some(part => /[. ]$/.test(part))))) return null;
|
|
200
|
+
if (path.startsWith("//") && !/^\/\/[^/]+\/[^/]+\//.test(path)) return null;
|
|
201
|
+
const key = windows ? path.toLowerCase() : path;
|
|
202
|
+
return key.length > (windows ? 3 : 1) ? key.replace(/\/$/, "") : key;
|
|
203
|
+
};
|
|
204
|
+
export const statusObservationPathRoot = (value: string): string => {
|
|
205
|
+
const path = value.replace(/\\/g, "/").toLowerCase();
|
|
206
|
+
return path.match(/^[a-z]:/)?.[0] ?? (path.startsWith("//") ? path.split("/").slice(0, 4).join("/") : "/");
|
|
207
|
+
};
|
|
208
|
+
|
|
188
209
|
// Diagnostics deliberately leave the legacy selection parser unchanged for mutation/diff consumers.
|
|
189
210
|
export const diagnoseMachineReadablePendingItems = (output: string, cwd: string, platform: NodeJS.Platform = process.platform) =>
|
|
190
211
|
{
|
|
191
212
|
const diagnostics = { valid: 0, blank: 0, header: 0, unsupported: 0, malformed: 0, ambiguousLegacyMove: 0 };
|
|
192
213
|
const pendingItems: PendingItem[] = [];
|
|
214
|
+
const seen = new Set<string>();
|
|
193
215
|
for (const rawLine of output.split(/\r?\n/))
|
|
194
216
|
{
|
|
195
217
|
const line = rawLine.trim();
|
|
@@ -198,7 +220,32 @@ export const diagnoseMachineReadablePendingItems = (output: string, cwd: string,
|
|
|
198
220
|
if (fields[0] === "STATUS" && fields.length === 4 && /^\d+$/.test(fields[1]) && fields[2] && fields[3]) { diagnostics.header++; continue; }
|
|
199
221
|
if (/^STATUS \d+ \S+ \S+$/.test(line)) { diagnostics.header++; continue; }
|
|
200
222
|
if (fields[0] === "STATUS" || line.startsWith("STATUS ")) { diagnostics.malformed++; continue; }
|
|
223
|
+
// Inferred moves are observed separately; legacy mutation/diff selection is unchanged.
|
|
224
|
+
if (fields[0] === "LM") {
|
|
225
|
+
const [, percent, source, destination, directory, revision, metadata] = fields;
|
|
226
|
+
const sourceKey = statusObservationPathKey(source ?? "");
|
|
227
|
+
const destinationKey = statusObservationPathKey(destination ?? "");
|
|
228
|
+
const validPercent = (percent?.length ?? 0) <= 64 && /^(?:100(?:\.0+)?|[0-9]{1,2}(?:\.[0-9]+)?)%$/.test(percent ?? "");
|
|
229
|
+
if (fields.length !== 7 || !validPercent || !sourceKey || !destinationKey || sourceKey === destinationKey ||
|
|
230
|
+
statusObservationPathRoot(source) !== statusObservationPathRoot(destination) ||
|
|
231
|
+
seen.has(sourceKey) || seen.has(destinationKey) || (directory !== "True" && directory !== "False") ||
|
|
232
|
+
!/^[1-9][0-9]{0,19}$/.test(revision ?? "") || metadata !== "NO_MERGES" ||
|
|
233
|
+
[source, destination].some(path => hasStatusPathDecodingLoss(path, platform))) {
|
|
234
|
+
diagnostics.malformed++; continue;
|
|
235
|
+
}
|
|
236
|
+
seen.add(sourceKey); seen.add(destinationKey);
|
|
237
|
+
pendingItems.push({ statusCode: "LM", kind: "moved", workspacePath: destination,
|
|
238
|
+
normalizedPath: toNormalizedAbsolutePath(destination, cwd), comparisonKey: toPathComparisonKey(destination, cwd),
|
|
239
|
+
isDirectory: directory === "True", revisionId: revision, sourceWorkspacePath: source, similarity: Number(percent.slice(0, -1)) / 100 });
|
|
240
|
+
diagnostics.valid++; continue;
|
|
241
|
+
}
|
|
201
242
|
const items = parseMachineReadablePendingItems(line, cwd);
|
|
243
|
+
for (const item of items) {
|
|
244
|
+
for (const path of [item.workspacePath, item.sourceWorkspacePath].filter((path): path is string => path !== undefined)) {
|
|
245
|
+
const key = statusObservationPathKey(path);
|
|
246
|
+
if (key) { if (seen.has(key)) diagnostics.malformed++; seen.add(key); }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
202
249
|
pendingItems.push(...items);
|
|
203
250
|
if (items.length) {
|
|
204
251
|
diagnostics.valid++;
|
|
@@ -239,6 +286,7 @@ export const toMachineReadableStatusItems = (pendingItems: PendingItem[]): Machi
|
|
|
239
286
|
isDirectory: item.isDirectory,
|
|
240
287
|
...(item.revisionId ? { revisionId: item.revisionId } : {}),
|
|
241
288
|
...(item.sourceWorkspacePath ? { sourcePath: item.sourceWorkspacePath } : {}),
|
|
289
|
+
...(item.similarity !== undefined ? { similarity: item.similarity } : {}),
|
|
242
290
|
}));
|
|
243
291
|
|
|
244
292
|
export const toMachineReadableStatusSummary = (pendingItems: PendingItem[], cwd: string): MachineReadableStatusSummary =>
|
|
@@ -13,7 +13,7 @@ export type MergeReceiptData = {
|
|
|
13
13
|
capability: { state: "not-checked" | "advertised" | "missing" | "unavailable"; missingTokens: string[] };
|
|
14
14
|
attempt: MergeAttempt;
|
|
15
15
|
capture: { help: MergeCapture | null; merge: MergeCapture | null };
|
|
16
|
-
parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number; warningsObserved: number } | null;
|
|
16
|
+
parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number; warningsObserved: number; unknownOperationClasses: string[]; resolvedConflictsVerified: number } | null;
|
|
17
17
|
observedChangesets: ServerChangeset[];
|
|
18
18
|
conflictPaths: string[];
|
|
19
19
|
counts: { changesetsReturned: number; changesetsOmitted: number; conflictsReturned: number; conflictsOmitted: number };
|
package/src/domain/status-xml.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DOMParser, type Element } from "@xmldom/xmldom";
|
|
2
|
-
import type
|
|
2
|
+
import { statusObservationPathKey, statusObservationPathRoot, type MachineReadableStatusItem, type MachineReadableStatusSummary } from "./pending";
|
|
3
3
|
|
|
4
4
|
import { STATUS_XML_LIMITS } from "../execution/status-xml-limits";
|
|
5
5
|
export { STATUS_XML_LIMITS } from "../execution/status-xml-limits";
|
|
@@ -13,11 +13,12 @@ export class StatusXmlError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
function invalid(): never { throw new StatusXmlError(); }
|
|
15
15
|
function limit(): never { throw new StatusXmlError("output_overflow"); }
|
|
16
|
-
const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" } as const;
|
|
16
|
+
const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", LM: "moved", PR: "private" } as const;
|
|
17
17
|
const fields = new Set(["Type", "TypeVerbose", "Path", "OldPath", "PrintableMovedPath", "MergesInfo", "SimilarityPerUnit", "Similarity", "Size", "PrintableSize", "PrintableLastModified", "RevisionType", "LastModified"]);
|
|
18
18
|
const isAbsolute = (path: string): boolean => path.startsWith("/") || /^[A-Za-z]:\\/.test(path) || /^\\\\[^\\]+\\[^\\]+\\/.test(path);
|
|
19
19
|
const identity = (value: string): string => {
|
|
20
20
|
if (!value || value.length > STATUS_XML_LIMITS.path || !isAbsolute(value) || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) || (process.platform === "win32" && value.includes("?"))) invalid();
|
|
21
|
+
if (!statusObservationPathKey(value)) invalid();
|
|
21
22
|
return value;
|
|
22
23
|
};
|
|
23
24
|
|
|
@@ -107,13 +108,26 @@ export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlS
|
|
|
107
108
|
if (!/^en[A-Za-z0-9]+$/.test(revisionType)) invalid();
|
|
108
109
|
const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile" || revisionType === "enBinaryFile";
|
|
109
110
|
if (supportedStatus && (kind === "moved") !== Boolean(oldPath)) invalid();
|
|
110
|
-
if (oldPath
|
|
111
|
-
|
|
112
|
-
seen.
|
|
111
|
+
if (oldPath) identity(oldPath);
|
|
112
|
+
const keys = [path, ...(oldPath ? [oldPath] : [])].map(value => statusObservationPathKey(value)!);
|
|
113
|
+
if (new Set(keys).size !== keys.length || keys.some(key => seen.has(key))) invalid();
|
|
114
|
+
for (const key of keys) seen.add(key);
|
|
115
|
+
let similarity: number | undefined;
|
|
116
|
+
if (statusCode === "LM") {
|
|
117
|
+
if (statusObservationPathRoot(path) !== statusObservationPathRoot(oldPath)) invalid();
|
|
118
|
+
const perUnit = leaf(row.get("SimilarityPerUnit"));
|
|
119
|
+
if (perUnit.length > 64 || !/^(?:0(?:\.[0-9]+)?|1(?:\.0+)?)$/.test(perUnit)) invalid();
|
|
120
|
+
similarity = Number(perUnit);
|
|
121
|
+
// Rounded display is neither the identity nor an exact equality check on the fraction.
|
|
122
|
+
if (row.has("Similarity")) {
|
|
123
|
+
const display = leaf(row.get("Similarity"));
|
|
124
|
+
if (display.length > 64 || !/^(?:100(?:\.0+)?|[0-9]{1,2}(?:\.[0-9]+)?)%$/.test(display)) invalid();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
113
127
|
// Validate the entire document before reporting unsupported profiles: a later unsafe
|
|
114
128
|
// identity/structure must not be masked by an earlier unadmitted record.
|
|
115
129
|
if (!supportedStatus || !supportedType) { unsupported = true; continue; }
|
|
116
|
-
items.push({ statusCode: statusCode as keyof typeof kinds, kind: kind!, path, isDirectory: revisionType === "enDirectory", ...(oldPath ? { sourcePath: oldPath } : {}) });
|
|
130
|
+
items.push({ statusCode: statusCode as keyof typeof kinds, kind: kind!, path, isDirectory: revisionType === "enDirectory", ...(oldPath ? { sourcePath: oldPath } : {}), ...(similarity !== undefined ? { similarity } : {}) });
|
|
117
131
|
}
|
|
118
132
|
if (unsupported) throw new StatusXmlError("unsupported_source");
|
|
119
133
|
const summary: MachineReadableStatusSummary = { totalPending: items.length, added: 0, changed: 0, moved: 0, deleted: 0, private: 0, other: 0, tracked: 0 };
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { win32 } from "node:path";
|
|
2
|
+
import { admitMergedStandardStatus, correlateMergedCheckin } from "../domain/merged-workspace";
|
|
3
|
+
import { parseSwitchLoadedBranch } from "../domain/switch-contract";
|
|
1
4
|
import { randomBytes } from "node:crypto";
|
|
2
5
|
import { buildFallbackScopePaths } from "../domain/paths";
|
|
3
6
|
import { summarizePendingItems, resolveCheckinPaths, filterPendingItemsByScope, selectPrivatePathsForAutoAdd, type PendingItem } from "../domain/pending";
|
|
@@ -7,6 +10,34 @@ const pendingArgv = ["status", "--machinereadable", "--includeRevId", "--fieldse
|
|
|
7
10
|
const summary = (items: PendingItem[], cwd: string): CheckinSummary => { const { privatePaths: _, ...s } = summarizePendingItems(items, cwd); return s; };
|
|
8
11
|
const normal = (o: CheckinCommandObservation) => o.capture.complete && !o.failed && o.attempt.state === "started" && o.attempt.terminal === "observed" && o.attempt.exitCode !== null && !o.attempt.aborted && !o.attempt.timedOut;
|
|
9
12
|
const success = (o: CheckinCommandObservation) => normal(o) && o.attempt.exitCode === 0 && o.capture.stderrBytes === 0;
|
|
13
|
+
// Inspect section/row markers, never words embedded in a branch or filename.
|
|
14
|
+
// Default status can omit unchanged checkouts, so a valid header-only body is
|
|
15
|
+
// allowed; unfamiliar sections/metadata still cannot establish ordinary state.
|
|
16
|
+
function ordinaryStatusBodyAdmitted(text: string): boolean {
|
|
17
|
+
const shapes = {
|
|
18
|
+
Changed: { header: /^ Status +Size +Last Modified +Path *$/, label: "(?:Changed|Checked-out \\((?:changed|unchanged)\\))" },
|
|
19
|
+
Added: { header: /^ Status +Size +Last Modified +Path *$/, label: "(?:Added|Private)" },
|
|
20
|
+
Deleted: { header: /^ Status +Size +Path *$/, label: "(?:Deleted|Locally deleted|Removed)" },
|
|
21
|
+
Moved: { header: /^ Status +Size +Similarity +Path *$/, label: "(?:Moved|Moved locally)" },
|
|
22
|
+
};
|
|
23
|
+
let section: keyof typeof shapes | null = null, needsHeader = false;
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
for (const line of text.split(/\r?\n/).slice(1)) {
|
|
26
|
+
if (!line.trim()) continue;
|
|
27
|
+
if (line.length > 4096 || /[\u0000-\u001f\u007f-\u009f]/.test(line)) return false;
|
|
28
|
+
if (Object.hasOwn(shapes, line)) {
|
|
29
|
+
if (needsHeader || seen.has(line)) return false;
|
|
30
|
+
seen.add(line); section = line as keyof typeof shapes; needsHeader = true; continue;
|
|
31
|
+
}
|
|
32
|
+
if (!section) return false;
|
|
33
|
+
const shape = shapes[section];
|
|
34
|
+
if (needsHeader) { if (!shape.header.test(line)) return false; needsHeader = false; continue; }
|
|
35
|
+
// A status-label annotation is metadata, unlike a parenthesis in the
|
|
36
|
+
// later path cell. Unknown annotations (including Merge from) fail.
|
|
37
|
+
if (new RegExp(`^ ${shape.label} +\\(`).test(line) || !new RegExp(`^ ${shape.label} {2,}.+$`).test(line)) return false;
|
|
38
|
+
}
|
|
39
|
+
return !needsHeader;
|
|
40
|
+
}
|
|
10
41
|
function validateRequest(input: unknown): asserts input is CheckinRequest {
|
|
11
42
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw Error("Invalid bounded checkin input.");
|
|
12
43
|
const r = input as Record<string, unknown>;
|
|
@@ -66,6 +97,21 @@ export async function assembleCheckinReceipt(input: unknown): Promise<CheckinRec
|
|
|
66
97
|
const before = await read("pending-before");
|
|
67
98
|
if (!before) return finish("unsupported", "pending_source_unadmitted", "pending", "Pending selection could not be admitted; no mutation was dispatched.");
|
|
68
99
|
data.pendingBefore = summary(before.items, cwd);
|
|
100
|
+
const merged = before.merged;
|
|
101
|
+
let loaded: ReturnType<typeof parseSwitchLoadedBranch> = null;
|
|
102
|
+
// CO/-1/NO_MERGES also occurs during an unresolved merge. Read the
|
|
103
|
+
// native link state before any checkin, including standalone/preflight.
|
|
104
|
+
if (merged || before.items.some(i => i.statusCode === "CO")) {
|
|
105
|
+
const { o, s } = await record("merge-readiness", ["status"]);
|
|
106
|
+
loaded = success(o) ? parseSwitchLoadedBranch(o.stdout ?? "") : null;
|
|
107
|
+
const source = /^ Merge from cs:[1-9][0-9]{0,19} at (.+)\r?$/m.exec(o.stdout ?? "")?.[1];
|
|
108
|
+
const ready = loaded && loaded.repository === before.repository && loaded.server === before.server && (merged
|
|
109
|
+
? !!source && admitMergedStandardStatus(o.stdout!, merged, source, cwd)
|
|
110
|
+
: ordinaryStatusBodyAdmitted(o.stdout ?? ""));
|
|
111
|
+
if (!ready) return finish("unsupported", "merge_readiness_unadmitted", "pending", "Merge source/resolution metadata could not be admitted; no checkin was dispatched.");
|
|
112
|
+
s.evidence = "admitted";
|
|
113
|
+
}
|
|
114
|
+
if (merged && input.includePrivate) return finish("unsupported", "merged_private_scope", "pending", "Merged checkin does not admit private inclusion; leave unrelated private work excluded.");
|
|
69
115
|
let includedAbsolute: string[] = [], scoped = before.items, apply = input.applyChanged ?? false;
|
|
70
116
|
if (input.paths?.length) {
|
|
71
117
|
const r = resolveCheckinPaths(input.paths, before.items, cwd);
|
|
@@ -74,20 +120,33 @@ export async function assembleCheckinReceipt(input: unknown): Promise<CheckinRec
|
|
|
74
120
|
scoped = filterPendingItemsByScope(before.items, includedAbsolute);
|
|
75
121
|
if (!data.includedPaths.length) return finish("failed", "NO_PENDING_PATHS", "pending", "None of the provided checkin paths have admitted pending changes.");
|
|
76
122
|
}
|
|
123
|
+
if (merged) {
|
|
124
|
+
data.excludedPaths.push(...before.items.filter(i => i.kind === "private").map(i => ({ path: i.workspacePath, reason: "Unrelated private work excluded from merged checkin (includePrivate=false)." })));
|
|
125
|
+
scoped = scoped.filter(i => i.kind !== "private");
|
|
126
|
+
if (merged.privateRows.length) {
|
|
127
|
+
// Do not rely on workspace-wide --all selection in the presence
|
|
128
|
+
// of private work. These directories are proven disjoint above.
|
|
129
|
+
data.includedPaths = merged.rows.map(r => win32.relative(cwd, r.path));
|
|
130
|
+
}
|
|
131
|
+
// All merged controlled rows must remain selected. Partial merge
|
|
132
|
+
// checkins and directory scopes overlapping private rows are unproven.
|
|
133
|
+
if (scoped.length !== merged.rows.length) return finish("unsupported", "merged_partial_scope", "pending", "All correlated merged controlled paths must be selected.");
|
|
134
|
+
}
|
|
77
135
|
const nonce = randomBytes(16).toString("hex"), seps = { start: `__PI_CI_START_${nonce}__`, end: `__PI_CI_END_${nonce}__`, field: `__PI_CI_FIELD_${nonce}__` };
|
|
78
136
|
const command = (paths: string[], force: boolean) => ["checkin", `-c=${input.message}`, ...(input.includeAll ? ["--all"] : []), ...(force ? ["--applychanged"] : []), ...(input.includePrivate ? ["--private"] : []), ...paths, "--machinereadable", `--startlineseparator=${seps.start}`, `--endlineseparator=${seps.end}`, `--fieldseparator=${seps.field}`];
|
|
79
137
|
const initial = command(data.includedPaths, apply); data.command = ["cm", ...initial]; data.wouldRun = true;
|
|
80
138
|
if (input.preflight) return finish("preflight");
|
|
81
139
|
const attempt = async (name: "initial-checkin" | "private-retry" | "fallback-checkin", argv: string[]) => {
|
|
82
140
|
const { o, s } = await record(name, argv), p = parseCheckinEvidence(o.stdout ?? "", seps); s.records = p.records;
|
|
83
|
-
const
|
|
141
|
+
const mergedEvents = !merged || correlateMergedCheckin(p.events, merged, cwd);
|
|
142
|
+
const admitted = p.admitted && mergedEvents && p.changesets.every(c => c.repository === before.repository && c.server === before.server && (!loaded || c.branch === loaded.branch));
|
|
84
143
|
if (admitted && normal(o)) {
|
|
85
144
|
s.evidence = "admitted";
|
|
86
145
|
data.itemEvents.push(...p.events); data.observedChangesets.push(...p.changesets);
|
|
87
146
|
}
|
|
88
147
|
if (success(o) && admitted && p.changesets.length === 1) { created = p.changesets[0]; s.changeset = created; s.effect = "changeset-created"; }
|
|
89
148
|
// Only genuine complete native failure without a created record may drive the existing recovery policy.
|
|
90
|
-
const retryEligible = normal(o) && admitted && o.attempt.exitCode !== 0 && p.changesets.length === 0;
|
|
149
|
+
const retryEligible = !merged && normal(o) && admitted && o.attempt.exitCode !== 0 && p.changesets.length === 0;
|
|
91
150
|
return { completed: !!s.changeset, retryEligible, message: retryEligible ? o.stderr ?? "" : "" };
|
|
92
151
|
};
|
|
93
152
|
stage = "checkin";
|
|
@@ -17,10 +17,23 @@ export type DiffRequest = { workdir?:string;format?:"text"|"json";maxChars?:numb
|
|
|
17
17
|
{mode:"workspace";paths:string[];allPending?:never;includePrivate?:never;maxFiles?:number} |
|
|
18
18
|
{mode:"workspace";allPending:true;paths?:never;includePrivate?:boolean;maxFiles?:number}
|
|
19
19
|
);
|
|
20
|
+
const baseMessages = {
|
|
21
|
+
"fileinfo-command": "Loaded-base fileinfo observation failed. No baseline was selected; inspect command/capture state before another attempt.",
|
|
22
|
+
"fileinfo-profile": "Loaded-base fileinfo XML is unsupported or malformed. Retain sanitized producer evidence; no baseline was guessed.",
|
|
23
|
+
"fileinfo-path": "Loaded-base fileinfo path does not match the requested workspace path. No baseline was selected.",
|
|
24
|
+
"fileinfo-type": "Loaded-base fileinfo item type is unsupported. No baseline was selected.",
|
|
25
|
+
"deletion-state": "Loaded deletion metadata conflicts with pending status. No empty baseline was substituted.",
|
|
26
|
+
"ls-command": "Loaded-base ls observation failed. No baseline was selected; inspect command/capture state before another attempt.",
|
|
27
|
+
"ls-profile": "Loaded-base ls XML is unsupported or malformed. Retain sanitized producer evidence; no baseline was guessed.",
|
|
28
|
+
"ls-path": "Loaded-base tree path does not match the inspected path. No baseline was selected.",
|
|
29
|
+
"revision-mismatch": "Loaded-base changeset/hash observations disagree. No baseline was selected; workspace changes may have displaced the observation.",
|
|
30
|
+
"ownership-mismatch": "Loaded-base repository/server identity is inconsistent. Alias equivalence is unverified. No baseline was selected.",
|
|
31
|
+
} as const;
|
|
32
|
+
type BaseReason = keyof typeof baseMessages;
|
|
20
33
|
export class ConsolidatedDiffError extends Error {
|
|
21
|
-
constructor(readonly code:string,readonly stage:string) { super("The requested diff could not be verified."); }
|
|
34
|
+
constructor(readonly code:string,readonly stage:string,readonly reason?:BaseReason) { super("The requested diff could not be verified."); }
|
|
22
35
|
}
|
|
23
|
-
const fail=(code:string,stage:string):never=>{throw new ConsolidatedDiffError(code,stage);};
|
|
36
|
+
const fail=(code:string,stage:string,reason?:BaseReason):never=>{throw new ConsolidatedDiffError(code,stage,reason);};
|
|
24
37
|
const aborted=()=>{if(getActiveAbortSignal()?.aborted)fail("aborted","comparison");};
|
|
25
38
|
const safe=(s:unknown,max:number):s is string=>typeof s==="string"&&s.length>0&&s.length<=max&&s.trim()===s&&!/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
26
39
|
export function validateDiffRequest(input:unknown):asserts input is DiffRequest {
|
|
@@ -76,21 +89,32 @@ function comparison(observation:DiffRevisionsObservation,left:DiffSide,right:Dif
|
|
|
76
89
|
const side=(origin:DiffSide["origin"],bytes:Buffer,path:string|null,selector:string|null,identity:LoadedIdentity|null=null):DiffSide=>({origin,path,selector,identity,bytes:bytes.length,binary:isBinaryContent(bytes)});
|
|
77
90
|
async function pending(cwd:string):Promise<DiffPendingSnapshot>{try{return parseDiffPending(await xmlCommand(["status","--xml","--encoding=utf-8","--fullpaths","--iscochanged"],cwd,"status"));}catch(e){if(e instanceof ConsolidatedDiffError)throw e;return fail("malformed_output","status");}}
|
|
78
91
|
async function base(path:string,cwd:string,snapshot:DiffPendingSnapshot,item:DiffPendingItem|null):Promise<{selector:string;identity:LoadedIdentity;hash:string;algorithm:"md5"|"sha1"}>{
|
|
92
|
+
let phase:"fileinfo-profile"|"ls-profile"="fileinfo-profile";
|
|
79
93
|
try {
|
|
80
94
|
const info=parseLoadedFileInfo(await xmlCommand(["fileinfo",path,"--fields=ClientPath,ServerPath,RevisionChangeset,Hash,RepSpec,Status,Type","--xml","--encoding=utf-8"],cwd,"base"));
|
|
81
|
-
if(key(info.clientPath)!==key(path)
|
|
95
|
+
if(key(info.clientPath)!==key(path))return fail("base_unavailable","base","fileinfo-path");
|
|
96
|
+
if(!["txt","bin","file"].includes(info.type.toLowerCase()))return fail("base_unavailable","base","fileinfo-type");
|
|
82
97
|
// DE removes live-tree membership/RepSpec. Deliberately inspect the
|
|
83
98
|
// frozen loaded root tree, never retry a failed live-tree lookup or
|
|
84
99
|
// guess a parent owning repository. Require loaded changeset/hash match.
|
|
85
100
|
const deleted=item?.code==="DE";
|
|
86
|
-
if(deleted&&info.status!=="deleted")return fail("base_changed","base");
|
|
101
|
+
if(deleted&&info.status!=="deleted")return fail("base_changed","base","deletion-state");
|
|
87
102
|
const query=deleted?["ls",info.serverPath,"--tree=cs:"+snapshot.changeset+"@"+snapshot.repository+"@"+snapshot.server,"--xml","--encoding=utf-8"]:["ls",path,"--xml","--encoding=utf-8"];
|
|
103
|
+
phase="ls-profile";
|
|
88
104
|
const row=parseLoadedLs(await xmlCommand(query,cwd,"base"));
|
|
89
|
-
if(
|
|
105
|
+
if(deleted?row.path!==info.serverPath:key(row.path)!==key(path))return fail("base_changed","base","ls-path");
|
|
106
|
+
if(row.changeset!==info.changeset||row.hash!==info.hash)return fail("base_changed","base","revision-mismatch");
|
|
107
|
+
if(!deleted&&info.repSpec!==row.repository+"@"+row.server)return fail("base_changed","base","ownership-mismatch");
|
|
90
108
|
const selector="revid:"+row.revisionId+"@rep:"+row.repository+"@repserver:"+row.server;
|
|
91
109
|
if(!validDiffRevisionsSelector(selector))return fail("invalid_selector","base");
|
|
92
110
|
return {selector,identity:{revisionId:row.revisionId,changeset:row.changeset,repository:row.repository,server:row.server},hash:row.hash,algorithm:row.algorithm};
|
|
93
|
-
}catch(e){
|
|
111
|
+
}catch(e){
|
|
112
|
+
if(e instanceof ConsolidatedDiffError){
|
|
113
|
+
if(e.code==="command_failed")return fail(e.code,e.stage,phase==="fileinfo-profile"?"fileinfo-command":"ls-command");
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
return fail("base_unavailable","base",phase);
|
|
117
|
+
}
|
|
94
118
|
}
|
|
95
119
|
async function fileComparison(args:DiffRequest&{mode:"file"},cwd:string,root:string,snapshot:DiffPendingSnapshot|null,item:DiffPendingItem|null):Promise<Comparison>{
|
|
96
120
|
const path=resolve(cwd,args.path);if(!contains(root,path))return fail("outside_workspace","selection");
|
|
@@ -111,7 +135,7 @@ async function fileComparison(args:DiffRequest&{mode:"file"},cwd:string,root:str
|
|
|
111
135
|
} finally {await fs.rm(temp,{recursive:true,force:true}).catch(()=>fail("cleanup_failed","cleanup"));}
|
|
112
136
|
}
|
|
113
137
|
export type DiffOutcome={path:string;sourcePath:string|null;kind:string;status:"compared";comparison:Comparison}|{path:string;sourcePath:string|null;kind:string;status:"skipped-directory"}|{path:string;sourcePath:string|null;kind:string;status:"unavailable";error:{code:string;stage:string;message:string}};
|
|
114
|
-
export function diffError(e:unknown){const r=e as {code?:unknown;stage?:unknown};const codes=new Set(["invalid_request","invalid_selector","workspace_unavailable","outside_workspace","base_unavailable","base_changed","malformed_output","command_failed","aborted","cleanup_failed","materialization_failed","materialization_limit","invalid_utf8","capture_incomplete","output_overflow","missing_file","unsupported_file","file_limit","file_changed","read_failed"]);return {code:typeof r?.code==="string"&&codes.has(r.code)?r.code:"comparison_failed",stage:typeof r?.stage==="string"&&["input","selection","status","base","left","right","comparison","cleanup","producer"].includes(r.stage)?r.stage:"comparison",message:"The requested diff could not be verified."};}
|
|
138
|
+
export function diffError(e:unknown){const r=e as {code?:unknown;stage?:unknown};const codes=new Set(["invalid_request","invalid_selector","workspace_unavailable","outside_workspace","base_unavailable","base_changed","malformed_output","command_failed","aborted","cleanup_failed","materialization_failed","materialization_limit","invalid_utf8","capture_incomplete","output_overflow","missing_file","unsupported_file","file_limit","file_changed","read_failed"]);return {code:typeof r?.code==="string"&&codes.has(r.code)?r.code:"comparison_failed",stage:typeof r?.stage==="string"&&["input","selection","status","base","left","right","comparison","cleanup","producer"].includes(r.stage)?r.stage:"comparison",message:e instanceof ConsolidatedDiffError&&e.reason?baseMessages[e.reason]:r?.code==="materialization_limit"?"Historical file exceeds the 4 MiB materialization limit. maxChars controls output only; no empty baseline was substituted.":"The requested diff could not be verified."};}
|
|
115
139
|
export async function assembleConsolidatedDiff(input:unknown){
|
|
116
140
|
validateDiffRequest(input);const args=input;aborted();
|
|
117
141
|
const header={schemaVersion:1 as const,action:"diff" as const,mode:args.mode,provenance:{source:"plastic" as const,producer:"@aefree/pi-plastic" as const,contentTrust:"external" as const}};
|
|
@@ -39,7 +39,7 @@ export function patchFailure(d: PatchData, code: Extract<PatchReceipt,{ok:false}
|
|
|
39
39
|
return {...header(d),ok:false,outcome:["started","unknown"].includes(d.attempt.state)&&!d.artifact?"uncertain":"failed",error:{code,message}};
|
|
40
40
|
}
|
|
41
41
|
export async function assemblePatchReceipt(args: unknown): Promise<PatchReceipt> {
|
|
42
|
-
const d=emptyPatchData();let stage: string|null=null;let entered=false;let failure: {code:Extract<PatchReceipt,{ok:false}>["error"]["code"];message:string}|null=null;
|
|
42
|
+
const d=emptyPatchData();let stage: string|null=null;let setupStage:"backend"|"selector"|"output"="backend";let entered=false;let failure: {code:Extract<PatchReceipt,{ok:false}>["error"]["code"];message:string}|null=null;
|
|
43
43
|
try {
|
|
44
44
|
if (!args||typeof args!=="object"||Array.isArray(args)||Object.keys(args).some(k=>!["source","destination","output","toolPath","clean","integration","workdir","preflight","format"].includes(k)))
|
|
45
45
|
return patchFailure(d,"invalid_request","Use declared bounded patch controls only.");
|
|
@@ -49,11 +49,13 @@ export async function assemblePatchReceipt(args: unknown): Promise<PatchReceipt>
|
|
|
49
49
|
d.requested=r;d.publication=r.output===null?"not-requested":"not-published";
|
|
50
50
|
const toolPath=resolvePatchToolPath(r.toolPath??undefined);
|
|
51
51
|
if (!validPatchText(toolPath)) throw new Error("Invalid patch backend.");
|
|
52
|
+
setupStage="selector";
|
|
52
53
|
const branch=await resolvePatchBranchSpecs({source:r.source,...(r.destination===null?{}:{destination:r.destination})},r.workdir);
|
|
53
54
|
const v={source:branch.source,destination:branch.destination??null,toolPath,output:r.output===null?null:isAbsolute(r.output)?r.output:resolve(r.workdir,r.output)};
|
|
54
55
|
if ([v.source,v.destination,v.toolPath,v.output].some(s=>s!==null&&(s.length>8192||/[\u0000-\u001f\u007f-\u009f]/.test(s)))) throw new Error("Resolved patch controls exceed limits.");
|
|
55
56
|
d.resolved=v;d.intendedArgv=patchReceiptArgv(r,v);
|
|
56
57
|
if (r.preflight) return {...header(d),ok:true,outcome:"preflight"};
|
|
58
|
+
setupStage="output";
|
|
57
59
|
if (v.output!==null) await ensurePatchOutputDoesNotExist(v.output);
|
|
58
60
|
stage=await fs.mkdtemp(join(v.output===null?tmpdir():resolve(v.output,".."),".pi-plastic-patch-"));
|
|
59
61
|
const staged=join(stage,"patch-output");
|
|
@@ -77,7 +79,12 @@ export async function assemblePatchReceipt(args: unknown): Promise<PatchReceipt>
|
|
|
77
79
|
}
|
|
78
80
|
} catch {
|
|
79
81
|
if (entered&&d.capture===null) {d.attempt={...emptyWorkspaceMergeAttempt(),state:"unknown"};d.effect="unverified";}
|
|
80
|
-
|
|
82
|
+
const setupMessages={
|
|
83
|
+
backend:"Patch backend unavailable. Set PI_PLASTIC_PATCH_EXECUTABLE to a verified non-GUI GNU-compatible executable, or pass toolPath. No command was dispatched.",
|
|
84
|
+
selector:"Patch selector repository could not be verified. Use an exact qualified br:/branch@repository@server selector. No command was dispatched.",
|
|
85
|
+
output:"Patch output is unavailable or already exists. Use a new filename in an existing writable parent directory. No command was dispatched.",
|
|
86
|
+
};
|
|
87
|
+
failure={code:entered?"producer_failed":"setup_failed",message:entered?"Patch observation failed; earlier observed effects remain recorded, no automatic retry.":setupMessages[setupStage]};
|
|
81
88
|
} finally {
|
|
82
89
|
if (stage!==null) {
|
|
83
90
|
if (entered&&(d.attempt.state==="unknown"||d.attempt.state==="started")&&(d.attempt.terminal!=="observed"||d.attempt.aborted||d.attempt.timedOut)) {d.cleanup="retained";d.retainedStagingDirectory=stage;}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
4
|
import { assertAbsoluteResolutionPath, inspectResolutionFile } from "../domain/server-merge-resolutions";
|
|
3
5
|
import { getActiveAbortSignal } from "../execution/context";
|
|
4
6
|
import { captureServerMergeCommand } from "../execution/server-merge-command";
|
|
5
7
|
import { tool } from "../tool-definition";
|
|
6
8
|
import { outputFormatArg, formatServerMergeResult } from "../presentation/results";
|
|
7
|
-
import { parseServerMergeOutput } from "../domain/merge-output";
|
|
9
|
+
import { isSafeServerMergePath, parseServerMergeOutput } from "../domain/merge-output";
|
|
8
10
|
import { emptyMergeAttempt, splitServerBranch, type MergeReceipt, type MergeReceiptData, type ServerMergeRequest } from "../domain/server-merge-contract";
|
|
9
11
|
export { splitServerBranch };
|
|
10
12
|
export type { MergeReceipt } from "../domain/server-merge-contract";
|
|
@@ -14,7 +16,7 @@ export const SERVER_MERGE_OUTPUT_LIMIT = 32768, SERVER_MERGE_HELP_TIMEOUT_MS = 3
|
|
|
14
16
|
export const serverMergeControlPattern = /[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
15
17
|
export const serverMergeCapabilityTokens = ["--to", "--merge", "--nointeractiveresolution", "--machinereadable", "--startlineseparator", "--endlineseparator", "--fieldseparator"];
|
|
16
18
|
export function assertSafeServerMergeValue(name: string, value: string): string {
|
|
17
|
-
if (typeof value !== "string" || !value.trim() || value.length > 4096 || serverMergeControlPattern.test(value)) throw new ServerMergeInputError(`${name} must be non-empty, bounded and free of
|
|
19
|
+
if (typeof value !== "string" || !value.trim() || value.length > 4096 || (name === "message" ? /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) : serverMergeControlPattern.test(value))) throw new ServerMergeInputError(`${name} must be non-empty, bounded and free of unsupported controls or malformed Unicode; only comments may contain CR/LF.`);
|
|
18
20
|
return value.trim();
|
|
19
21
|
}
|
|
20
22
|
export function parseQualifiedServerBranch(name: string, value: string): QualifiedServerBranch {
|
|
@@ -40,6 +42,41 @@ export function validateServerMergeRequest(input: unknown): asserts input is Ser
|
|
|
40
42
|
if (s.repository !== t.repository || s.server !== t.server) throw new ServerMergeInputError("source and target must identify the same exact repository and server.");
|
|
41
43
|
if (s.branch === t.branch) throw new ServerMergeInputError("source and target must identify different branches.");
|
|
42
44
|
}
|
|
45
|
+
// Only the observed resultFile-only candidate closeout uses this bounded snapshot.
|
|
46
|
+
// It verifies caller inputs, not the bytes stored by the server; callers still own
|
|
47
|
+
// immutability during dispatch and point-in-time races remain possible.
|
|
48
|
+
type ResolutionSnapshot = { digest: string; paths: string[] };
|
|
49
|
+
async function snapshotResolutionInputs(path: string): Promise<ResolutionSnapshot> {
|
|
50
|
+
const bounded = async (path: string, cap: number): Promise<Buffer> => {
|
|
51
|
+
const st = await lstat(path);
|
|
52
|
+
const lexical = resolve(path), physical = await realpath(path);
|
|
53
|
+
const key = (s: string) => process.platform === "win32" ? s.toLowerCase() : s;
|
|
54
|
+
if (!st.isFile() || st.isSymbolicLink() || key(lexical) !== key(physical) || st.size > cap) throw Error("invalid");
|
|
55
|
+
const handle = await open(path, "r");
|
|
56
|
+
try {
|
|
57
|
+
const before = await handle.stat();
|
|
58
|
+
if (!before.isFile() || before.dev !== st.dev || before.ino !== st.ino || before.size !== st.size || before.mtimeMs !== st.mtimeMs || before.ctimeMs !== st.ctimeMs) throw Error("unstable");
|
|
59
|
+
const bytes = Buffer.alloc(cap + 1); let length = 0;
|
|
60
|
+
while (length < bytes.length) { const r = await handle.read(bytes, length, bytes.length - length, null); if (!r.bytesRead) break; length += r.bytesRead; }
|
|
61
|
+
const after = await handle.stat(), final = await lstat(path);
|
|
62
|
+
if (length > cap || length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || final.isSymbolicLink() || final.ino !== before.ino || final.dev !== before.dev || final.size !== before.size || final.mtimeMs !== before.mtimeMs || final.ctimeMs !== before.ctimeMs) throw Error("unstable");
|
|
63
|
+
return bytes.subarray(0, length);
|
|
64
|
+
} finally { await handle.close(); }
|
|
65
|
+
};
|
|
66
|
+
const bytes = await bounded(path, 1024 * 1024);
|
|
67
|
+
const json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
68
|
+
if (!Array.isArray(json.resolutions) || json.resolutions.length > 500) throw Error("invalid");
|
|
69
|
+
const hash = createHash("sha256").update(bytes), paths: string[] = [], keys = new Set<string>();
|
|
70
|
+
let remaining = 4 * 1024 * 1024;
|
|
71
|
+
for (const entry of json.resolutions) {
|
|
72
|
+
if (typeof entry.path !== "string" || !isSafeServerMergePath(entry.path) || keys.has(entry.path.toLowerCase()) || typeof entry.resultFile !== "string" || Object.keys(entry).some(k => !["path", "resultFile"].includes(k))) throw Error("invalid");
|
|
73
|
+
assertAbsoluteResolutionPath(entry.resultFile);
|
|
74
|
+
keys.add(entry.path.toLowerCase()); paths.push(entry.path);
|
|
75
|
+
const result = await bounded(entry.resultFile, remaining); remaining -= result.length;
|
|
76
|
+
hash.update(createHash("sha256").update(result).digest());
|
|
77
|
+
}
|
|
78
|
+
return { digest: hash.digest("hex"), paths };
|
|
79
|
+
}
|
|
43
80
|
export async function assembleServerMergeReceipt(input: unknown): Promise<MergeReceipt> {
|
|
44
81
|
const data: MergeReceiptData = { mode: "apply", resolutionsFile: null, unlistedConflictPolicy: null, requestedIdentity: null, command: null, capability: { state: "not-checked", missingTokens: [] }, attempt: emptyMergeAttempt(), capture: { help: null, merge: null }, parse: null, observedChangesets: [], conflictPaths: [], counts: { changesetsReturned: 0, changesetsOmitted: 0, conflictsReturned: 0, conflictsOmitted: 0 }, createdChangeset: null, effect: "not-attempted", serverAliasEquivalence: "unverified", mergeLinkIdentity: "unverified", xlinkEffects: "unverified", wouldRun: false, remoteAnalysis: "not-performed" };
|
|
45
82
|
const completeness: MergeReceipt["completeness"] = { capture: "unknown", projection: true };
|
|
@@ -54,6 +91,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
54
91
|
}
|
|
55
92
|
return dto;
|
|
56
93
|
};
|
|
94
|
+
let resolutionSnapshot: ResolutionSnapshot | null = null;
|
|
57
95
|
let stage: "input" | "help" | "merge" = "input";
|
|
58
96
|
try {
|
|
59
97
|
validateServerMergeRequest(input);
|
|
@@ -64,6 +102,10 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
64
102
|
try { data.resolutionsFile = await inspectResolutionFile(input.fileConflictsResolutionsFile); }
|
|
65
103
|
catch (error) { throw new ServerMergeInputError(error instanceof Error && /^(Invalid resolution entry|Resolutions|Resolution)/.test(error.message) ? error.message : "Resolutions file or a result file is unavailable or invalid."); }
|
|
66
104
|
}
|
|
105
|
+
if (data.resolutionsFile && data.resolutionsFile.resultFiles === data.resolutionsFile.entries && !data.unlistedConflictPolicy) {
|
|
106
|
+
try { resolutionSnapshot = await snapshotResolutionInputs(data.resolutionsFile.path); }
|
|
107
|
+
catch { throw new ServerMergeInputError("Resolution result inputs must be stable regular nonsymlink files with canonical unique conflict paths and at most 4 MiB aggregate result bytes."); }
|
|
108
|
+
}
|
|
67
109
|
const seps = createServerMergeSeparators();
|
|
68
110
|
const command = ["merge", source.raw, `--to=${target.raw}`, ...(data.mode === "apply" ? ["--merge", `-c=${assertSafeServerMergeValue("message", input.message!)}`] : []), ...(data.resolutionsFile ? [`--fileconflictsresolutionsfile=${data.resolutionsFile.path}`] : []), ...(data.unlistedConflictPolicy ? [data.unlistedConflictPolicy === "source" ? "--keepsource" : "--keepdestination"] : []), "--nointeractiveresolution", "--machinereadable", `--startlineseparator=${seps.start}`, `--endlineseparator=${seps.end}`, `--fieldseparator=${seps.field}`];
|
|
69
111
|
data.requestedIdentity = { source: source.raw, target: target.raw, branch: target.branch, repository: target.repository, server: target.server }; data.command = ["cm", ...command]; data.wouldRun = true;
|
|
@@ -81,7 +123,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
81
123
|
if (data.resolutionsFile) {
|
|
82
124
|
try {
|
|
83
125
|
const current = await inspectResolutionFile(data.resolutionsFile.path);
|
|
84
|
-
if (current.sha256 !== data.resolutionsFile.sha256) throw new Error("changed");
|
|
126
|
+
if (current.sha256 !== data.resolutionsFile.sha256 || resolutionSnapshot && (await snapshotResolutionInputs(data.resolutionsFile.path)).digest !== resolutionSnapshot.digest) throw new Error("changed");
|
|
85
127
|
} catch { return finish("failed", "input", "invalid_request", "Resolution inputs changed or became unavailable before merge dispatch."); }
|
|
86
128
|
}
|
|
87
129
|
stage = "merge";
|
|
@@ -89,16 +131,24 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
89
131
|
completeness.capture = obs.capture.complete ? "complete" : "incomplete";
|
|
90
132
|
if (obs.attempt.state === "not-attempted" || obs.attempt.state === "not-started") return finish("failed", "merge", obs.attempt.aborted ? "aborted" : "launch_failed");
|
|
91
133
|
const parsed = parseServerMergeOutput(obs.stdout ?? "", seps, data.mode);
|
|
92
|
-
const paths = parsed.records.filter(r => r.operation === "FILE_CONFLICT" && r.fields.length === 6 &&
|
|
93
|
-
data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length, warningsObserved: parsed.warningsObserved };
|
|
134
|
+
const paths = parsed.records.filter(r => r.operation === "FILE_CONFLICT" && r.fields.length === 6 && isSafeServerMergePath(r.fields[1]!)).map(r => r.fields[1]!);
|
|
135
|
+
data.parse = { records: parsed.records.length, malformed: parsed.malformed, unknownOperations: parsed.unknownOperations.length, changesetsObserved: parsed.changesets.length, conflictsObserved: paths.length, warningsObserved: parsed.warningsObserved, unknownOperationClasses: parsed.unknownOperations, resolvedConflictsVerified: 0 };
|
|
94
136
|
// One aggregate evidence budget, not one budget per collection.
|
|
95
137
|
data.observedChangesets = parsed.changesets.slice(0, 100); data.conflictPaths = paths.slice(0, 100 - data.observedChangesets.length);
|
|
96
138
|
data.counts = { changesetsReturned: data.observedChangesets.length, changesetsOmitted: parsed.changesets.length - data.observedChangesets.length, conflictsReturned: data.conflictPaths.length, conflictsOmitted: paths.length - data.conflictPaths.length };
|
|
97
139
|
if (data.counts.changesetsOmitted || data.counts.conflictsOmitted) completeness.projection = false;
|
|
98
|
-
const matching = parsed.changesets.filter(c => c.branch === target.branch && c.repository === target.repository && c.mount === "/");
|
|
140
|
+
const matching = parsed.changesets.filter(c => c.branch === target.branch && c.repository === target.repository && c.server === target.server && c.mount === "/");
|
|
141
|
+
let resolvedCandidates = false;
|
|
142
|
+
if (resolutionSnapshot && parsed.hasConflict && data.resolutionsFile) {
|
|
143
|
+
try {
|
|
144
|
+
const current = await snapshotResolutionInputs(data.resolutionsFile.path);
|
|
145
|
+
resolvedCandidates = current.digest === resolutionSnapshot.digest && paths.length === resolutionSnapshot.paths.length && new Set(paths).size === paths.length && paths.every(p => resolutionSnapshot!.paths.includes(p));
|
|
146
|
+
} catch { /* Changed/unavailable inputs retain effect uncertainty. */ }
|
|
147
|
+
}
|
|
148
|
+
if (resolvedCandidates) data.parse.resolvedConflictsVerified = paths.length;
|
|
99
149
|
// Emitted server aliases are kept verbatim. Matching uses dispatch context,
|
|
100
150
|
// not an invented equivalence between requested and emitted server names.
|
|
101
|
-
const ambiguous = !obs.capture.complete || obs.failed || obs.attempt.aborted || obs.attempt.timedOut || obs.attempt.terminal !== "observed" || obs.attempt.exitCode === null || parsed.malformed || parsed.unknownOperations.length > 0 || parsed.changesets.length > 1 || parsed.changesets.length > 0 && matching.length !== 1 || parsed.hasConflict && parsed.changesets.length > 0 || parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict);
|
|
151
|
+
const ambiguous = !obs.capture.complete || obs.failed || obs.attempt.aborted || obs.attempt.timedOut || obs.attempt.terminal !== "observed" || obs.attempt.exitCode === null || parsed.malformed || parsed.unknownOperations.length > 0 || parsed.changesets.length > 1 || parsed.changesets.length > 0 && matching.length !== 1 || parsed.hasConflict && parsed.changesets.length > 0 && !resolvedCandidates || parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict);
|
|
102
152
|
if (data.mode === "analyze") {
|
|
103
153
|
if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && !parsed.changesets.length && parsed.records.length > 0) {
|
|
104
154
|
data.remoteAnalysis = "performed";
|
|
@@ -107,7 +157,7 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
107
157
|
const reason = parsed.malformed ? "Malformed server analysis framing or fields." : parsed.unknownOperations.length ? "Unsupported server analysis operation or subtype." : "Server conflict analysis could not be verified.";
|
|
108
158
|
return finish("uncertain", "merge", "uncertain", reason + " No applying command was requested.");
|
|
109
159
|
}
|
|
110
|
-
if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && !parsed.hasConflict && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
|
|
160
|
+
if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && matching.length === 1 && (!parsed.hasConflict || resolvedCandidates) && !parsed.isAlreadyConnected) { data.createdChangeset = matching[0]!; return finish("completed", "merge"); }
|
|
111
161
|
if (!ambiguous && obs.attempt.exitCode === 0 && !obs.capture.stderrBytes && parsed.isAlreadyConnected && !parsed.changesets.length && !parsed.hasConflict) return finish("no-op", "merge");
|
|
112
162
|
if (!ambiguous && obs.attempt.exitCode !== 0 && parsed.hasConflict && !parsed.changesets.length) return finish("conflict", "merge", "conflict");
|
|
113
163
|
return finish("uncertain", "merge", "uncertain");
|
|
@@ -121,11 +171,12 @@ export async function assembleServerMergeReceipt(input: unknown): Promise<MergeR
|
|
|
121
171
|
export async function presentServerMergeReceipt(dto: MergeReceipt, format: "text" | "json" = "text"): Promise<string> {
|
|
122
172
|
const title = dto.outcome === "no-op" ? "No-op" : dto.outcome[0]!.toUpperCase() + dto.outcome.slice(1);
|
|
123
173
|
const text = dto.outcome === "preflight" ? `## Server Merge Preflight\n\n- Would run: yes\n- Remote analysis: not performed\n- Command argv: ${JSON.stringify(dto.data.command)}` : dto.outcome === "analyzed" ? `## Server Merge Analysis\n\n- Read-only: no applying merge requested\n- File conflicts observed: ${dto.data.parse?.conflictsObserved ?? "unknown"}\n${dto.data.conflictPaths.map(path => "- " + path).join("\n")}\n- Conflicts omitted: ${dto.data.counts.conflictsOmitted}\n- Discard CHG warnings observed: ${dto.data.parse?.warningsObserved ?? "unknown"} (content equality unverified)\n- Point-in-time evidence only; no contributor content retrieved.` : `## Server Merge ${title}\n\n${dto.data.createdChangeset ? "- Created target changeset: cs:" + dto.data.createdChangeset.id + "\n" : ""}- Effect: ${dto.data.effect}\n- Server alias equivalence, merge-link identity and Xlink effects: unverified\n${dto.ok ? "" : "- " + dto.error.message + "\n- Do not retry automatically; inspect server state if an attempt may have started."}`;
|
|
124
|
-
const
|
|
174
|
+
const diagnostics = `\n- Unsupported operation classes: ${dto.data.parse?.unknownOperationClasses.join(", ") || "none"}\n- Conflict candidates with verified result inputs: ${dto.data.parse?.resolvedConflictsVerified ?? 0} (not independent server-byte verification)`;
|
|
175
|
+
const rendered = await formatServerMergeResult(format, dto.outcome, text + diagnostics, dto.data);
|
|
125
176
|
return rendered.length <= 24000 ? rendered : JSON.stringify({ action: dto.action, outcome: dto.outcome, ok: dto.ok, note: "Presentation omitted; use the registered tool structuredContent for the bounded receipt." });
|
|
126
177
|
}
|
|
127
178
|
export const mergeBranches = tool({
|
|
128
179
|
description: "Inspect server merge conflicts with mode=analyze, then apply reviewed per-file JSON resolutions without a workspace or UI. Server aliases, merge links and Xlink effects remain unverified.",
|
|
129
|
-
args: { source: tool.schema.string().min(1).describe("Fully qualified source branch; branch/repository may not contain @; full server tail retained."), target: tool.schema.string().min(1).describe("Fully qualified distinct target in the same exact requested repository/server."), message: tool.schema.string().min(1).optional().describe("Required for apply; omit in analyze mode. Bounded nonempty comment,
|
|
180
|
+
args: { source: tool.schema.string().min(1).describe("Fully qualified source branch; branch/repository may not contain @; full server tail retained."), target: tool.schema.string().min(1).describe("Fully qualified distinct target in the same exact requested repository/server."), message: tool.schema.string().min(1).optional().describe("Required for apply; omit in analyze mode. Bounded nonempty comment; CR/LF allowed, other controls rejected. No editor fallback."), mode: tool.schema.enum(["apply", "analyze"]).optional().describe("Default apply creates a merge changeset. Analyze performs read-only remote conflict discovery without --merge; not command-only preflight."), fileConflictsResolutionsFile: tool.schema.string().min(1).optional().describe("Apply only: absolute existing UTF-8 JSON file with resolutions entries (exact conflict path plus keep or absolute readable resultFile). Inputs must remain immutable until completion."), unlistedConflictPolicy: tool.schema.enum(["source", "destination"]).optional().describe("Apply only: explicitly keep this contributor for conflicts absent from the JSON. Omit to retain noninteractive default behavior; never selected automatically."), preflight: tool.schema.boolean().optional().describe("Render intent only, zero commands; does not analyze remote conflicts."), format: outputFormatArg },
|
|
130
181
|
async execute(args) { const dto = await assembleServerMergeReceipt(args); if (!dto.ok && dto.error.stage === "input") throw new Error(dto.error.message); return presentServerMergeReceipt(dto, args.format); },
|
|
131
182
|
});
|
package/src/pi/checkin-output.ts
CHANGED
|
@@ -10,7 +10,7 @@ const cs = object({ id: Type.String({ pattern: "^(0|[1-9][0-9]{0,19})$" }), bran
|
|
|
10
10
|
const summary = object({ totalPending: uint(499), tracked: uint(499), private: uint(499), added: uint(499), changed: uint(499), moved: uint(499), deleted: uint(499), other: uint(499) });
|
|
11
11
|
const attempt = object({ state: enumOf(["not-attempted", "not-started", "started", "unknown"]), terminal: enumOf(["not-observed", "observed"]), exitCode: Type.Union([uint(2147483647), Type.Null()]), aborted: Type.Boolean(), timedOut: Type.Boolean() });
|
|
12
12
|
const capture = object({ stdoutBytes: uint(), stderrBytes: uint(), stdoutRetainedBytes: uint(65536), stderrRetainedBytes: uint(16384), truncated: Type.Boolean(), complete: Type.Boolean(), validUtf8: Type.Boolean() });
|
|
13
|
-
const step = object({ name: enumOf(["pending-before", "initial-checkin", "private-add", "private-retry", "pending-recovery", "fallback-checkin", "pending-after", "merge-diagnostic"]), operation: enumOf(["status", "checkin", "add"]), attempt, capture, effect: enumOf(["not-attempted", "uncertain", "command-completed", "changeset-created", "read-observed"]), evidence: enumOf(["unadmitted", "admitted"]), records: uint(501), summary: nullable(summary), changeset: nullable(cs) });
|
|
13
|
+
const step = object({ name: enumOf(["pending-before", "initial-checkin", "private-add", "private-retry", "pending-recovery", "fallback-checkin", "pending-after", "merge-diagnostic", "merge-readiness"]), operation: enumOf(["status", "checkin", "add"]), attempt, capture, effect: enumOf(["not-attempted", "uncertain", "command-completed", "changeset-created", "read-observed"]), evidence: enumOf(["unadmitted", "admitted"]), records: uint(501), summary: nullable(summary), changeset: nullable(cs) });
|
|
14
14
|
const paths = Type.Array(str(), { maxItems: 100 });
|
|
15
15
|
const reasons = Type.Array(object({ path: str(), reason: str(256) }), { maxItems: 100 });
|
|
16
16
|
const data = { sourceAdmission: Type.Literal("windows-cm11.0.16.10371-observed"), requestedPaths: paths, includedPaths: paths, fallbackPaths: paths, excludedPaths: reasons, privateAddPaths: paths, blockedPrivatePaths: reasons, itemEvents: Type.Array(Type.Union([object({ operation: enumOf(["CO", "AD", "DE", "CP", "RP"]), path: str() }), object({ operation: Type.Literal("MV"), sourcePath: str(), path: str() })]), { maxItems: 100 }), observedChangesets: Type.Array(cs, { maxItems: 100 }), command: nullable(Type.Array(str(8192), { maxItems: 120 })), steps: Type.Array(step, { maxItems: 8 }), autoEnabledApplyChanged: Type.Boolean(), usedFallbackRetry: Type.Boolean(), usedPrivateAutoAddRecovery: Type.Boolean(), pendingBefore: nullable(summary), pendingAfter: nullable(summary), wouldRun: Type.Boolean(), omittedReferences: uint(10000), scopeExhaustion: Type.Literal("unverified"), branchHead: Type.Literal("unverified"), serverAliasEquivalence: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified") };
|
|
@@ -38,6 +38,7 @@ export function validateCheckinOutput(value: unknown): value is CheckinReceipt {
|
|
|
38
38
|
for (const s of d.steps) {
|
|
39
39
|
const a = s.attempt, c = s.capture;
|
|
40
40
|
if (a.exitCode !== null && a.terminal !== "observed" || c.stdoutRetainedBytes > c.stdoutBytes || c.stderrRetainedBytes > c.stderrBytes || c.complete && (!c.validUtf8 || c.truncated || a.terminal !== "observed" || a.aborted || a.timedOut)) return false;
|
|
41
|
+
if (s.name === "merge-readiness" && (s.operation !== "status" || s.effect !== "not-attempted" || s.summary || s.evidence === "admitted" && (!normal(s) || a.exitCode !== 0 || c.stderrBytes))) return false;
|
|
41
42
|
if (s.summary && (!summaryValid(s.summary) || s.operation !== "status" || s.effect !== "read-observed")) return false;
|
|
42
43
|
if (s.operation === "status" && !["not-attempted", "read-observed"].includes(s.effect)) return false;
|
|
43
44
|
if (s.operation !== "status" && s.summary) return false;
|
|
@@ -47,11 +48,13 @@ export function validateCheckinOutput(value: unknown): value is CheckinReceipt {
|
|
|
47
48
|
if (s.changeset && s.effect !== "changeset-created") return false;
|
|
48
49
|
if (s.operation !== "status" && s.effect === "not-attempted" && !["not-attempted", "not-started"].includes(a.state)) return false;
|
|
49
50
|
}
|
|
51
|
+
const readiness = d.steps.filter(s => s.name === "merge-readiness");
|
|
52
|
+
if (readiness.length > 1 || readiness.length && (d.steps[0]?.name !== "pending-before" || d.steps[1] !== readiness[0] || (dto.ok || d.steps.length > 2) && readiness[0].evidence !== "admitted")) return false;
|
|
50
53
|
const mutations = d.steps.filter(s => s.operation !== "status"), created = mutations.filter(s => s.effect === "changeset-created");
|
|
51
54
|
const expected = created.length ? "changeset-created" : mutations.some(s => s.effect === "uncertain") ? "uncertain" : mutations.some(s => s.effect === "command-completed") ? "command-completed" : "not-attempted";
|
|
52
55
|
if (d.effect !== expected || created.length > 1 || !!d.createdChangeset !== !!created.length || d.createdChangeset && !same(d.createdChangeset, created[0].changeset)) return false;
|
|
53
56
|
if (dto.outcome === "completed" && (!d.createdChangeset || !d.pendingBefore || !d.wouldRun)) return false;
|
|
54
|
-
if (dto.outcome === "preflight" && (mutations.length || !d.pendingBefore || !d.wouldRun || d.steps.
|
|
57
|
+
if (dto.outcome === "preflight" && (mutations.length || !d.pendingBefore || !d.wouldRun || d.steps.some(s => !["pending-before", "merge-readiness"].includes(s.name)))) return false;
|
|
55
58
|
if ((dto.outcome === "failed" || dto.outcome === "unsupported") && mutations.length) return false;
|
|
56
59
|
if (d.usedFallbackRetry !== d.steps.some(s => s.name === "fallback-checkin")) return false;
|
|
57
60
|
if (d.usedPrivateAutoAddRecovery !== d.steps.some(s => s.name === "private-add" && s.effect === "command-completed")) return false;
|
|
@@ -67,7 +67,7 @@ export function validateCloseoutOutput(value:unknown):value is CloseoutReceipt {
|
|
|
67
67
|
if(s.kind==="switch"){if(s.name!=="switch"||!validateSwitchOutput(s.result)||s.result.data.requestedTarget!==d.targetBranch||s.result.data.pendingPolicy!=="cancel"||s.result.data.preflight||s.result.data.workdir!==resolve(r.workdir))return false;}
|
|
68
68
|
else if(s.kind==="update"){if(s.name!=="update"||!validateUpdateOutput(s.result)||s.result.data.workingDirectory!==r.workdir)return false;}
|
|
69
69
|
else if(s.kind==="merge"){if(s.name!=="merge"||!validateWorkspaceMergeOutput(s.result)||s.result.action!=="merge"||s.result.data.requestedSource!==d.sourceBranch||s.result.data.strategy!==r.strategy||s.result.data.workingDirectory!==r.workdir||s.result.data.previewRequested!==false||s.result.data.cherrypicking||s.result.data.forced)return false;}
|
|
70
|
-
else{if(!["checkin-preflight","checkin"].includes(s.name)||!validateCheckinOutput(s.result)||s.name==="checkin-preflight"&&s.result.ok&&s.result.outcome!=="preflight"||s.name==="checkin"&&s.result.ok&&s.result.outcome!=="completed")return false;const cmd=s.result.data.command,expected=["cm","checkin",`-c=${d.checkinMessage}`,"--all",...(r.includePrivate?["--private"]:[])];if(cmd&&(!same(cmd.slice(0,expected.length),expected)||cmd[expected.length]!=="--machinereadable"))return false;}
|
|
70
|
+
else{if(!["checkin-preflight","checkin"].includes(s.name)||!validateCheckinOutput(s.result)||s.name==="checkin-preflight"&&s.result.ok&&s.result.outcome!=="preflight"||s.name==="checkin"&&s.result.ok&&s.result.outcome!=="completed")return false;const cmd=s.result.data.command,expected=["cm","checkin",`-c=${d.checkinMessage}`,"--all",...(r.includePrivate?["--private"]:[]),...s.result.data.includedPaths];if(cmd&&(!same(cmd.slice(0,expected.length),expected)||cmd[expected.length]!=="--machinereadable"))return false;}
|
|
71
71
|
if(index<stages.length-1&&!s.result.ok)return false;
|
|
72
72
|
}
|
|
73
73
|
}
|