@aefree/pi-plastic 0.8.3 → 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 +23 -0
- package/docs/2026-10-06-merging.md +12 -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 +4 -4
- package/src/domain/branch-create-contract.ts +2 -1
- package/src/domain/checkin-contract.ts +10 -3
- package/src/domain/copied-merge.ts +1 -1
- package/src/domain/merge-output.ts +22 -7
- package/src/domain/merged-workspace.ts +121 -0
- package/src/domain/pending.ts +48 -0
- package/src/domain/server-merge-contract.ts +1 -1
- package/src/domain/status-xml.ts +22 -7
- package/src/domain/switch-contract.ts +2 -1
- package/src/domain/workspace-merge-contract.ts +6 -3
- package/src/operations/checkin-receipt.ts +61 -2
- package/src/operations/closeout-receipt.ts +9 -4
- 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/operations/workspace-merge-receipt.ts +9 -6
- package/src/pi/checkin-output.ts +6 -3
- package/src/pi/closeout-output.ts +2 -2
- 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 +6 -3
|
@@ -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
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseMergedPending, correlateMergedApply, admitMergedStandardStatus } from "../domain/merged-workspace";
|
|
1
2
|
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { win32 } from "node:path";
|
|
3
4
|
import { parseCopiedPending, admitCopiedStandardStatus, copiedPendingArgv, copiedPathKey } from "../domain/copied-merge";
|
|
@@ -22,21 +23,23 @@ export async function assembleWorkspaceMergeReceipt(action:MergeAction,args:unkn
|
|
|
22
23
|
active="apply";const apply=await captureWorkspaceMergeCommand([...data.intendedArgv],cwd);data.apply={attempt:apply.attempt,capture:apply.capture,admission:"unsupported"};active=null;data.effect=["started","unknown"].includes(apply.attempt.state)?"uncertain":"not-attempted";
|
|
23
24
|
if(!apply.capture.complete||apply.failed||apply.attempt.state!=="started")return workspaceMergeFailure(action,data,apply.attempt.aborted?"aborted":data.effect==="not-attempted"?"launch_failed":"uncertain","Merge capture/start/completion is not established; do not retry automatically.");
|
|
24
25
|
data.commandCompleted=apply.attempt.exitCode===0;
|
|
25
|
-
const parsed=admitWorkspaceMergeOutput(apply.stdout!,token);data.protocol=parsed.protocol;data.pendingReadMode=parsed.protocol==="apply-add-copied"?"copied-machine":"short";data.fileConflictCount=parsed.fileConflictCount;data.apply.admission=parsed.protocol==="unsupported"?"unsupported":"admitted";
|
|
26
|
+
const parsed=admitWorkspaceMergeOutput(apply.stdout!,token);data.protocol=parsed.protocol;data.pendingReadMode=parsed.protocol==="merged-candidate"?"merged-machine":parsed.protocol==="apply-add-copied"?"copied-machine":"short";data.fileConflictCount=parsed.fileConflictCount;data.apply.admission=parsed.protocol==="unsupported"?"unsupported":"admitted";
|
|
26
27
|
if(parsed.protocol==="file-conflict"){data.checkinReadiness="blocked";return workspaceMergeFailure(action,data,"conflict","Observed file-conflict records; checkin blocked. Earlier effects are not disproven.");}
|
|
27
28
|
if(!data.commandCompleted)return workspaceMergeFailure(action,data,"uncertain","Merge exited nonzero; workspace effects may already exist.");
|
|
28
29
|
if(parsed.protocol==="unsupported")return workspaceMergeFailure(action,data,"unsupported_readiness","Merge completed; applying profile unadmitted. Inspect apply.admission/protocol; verify loaded package revision and retain sanitized producer evidence. Readiness unknown; do not reapply automatically or infer rollback.");
|
|
29
|
-
const copied=parsed.protocol==="apply-add-copied";data.pendingReadMode=copied?"copied-machine":"short";
|
|
30
|
-
if(
|
|
31
|
-
|
|
30
|
+
const merged=parsed.protocol==="merged-candidate";const copied=parsed.protocol==="apply-add-copied";data.pendingReadMode=merged?"merged-machine":copied?"copied-machine":"short";
|
|
31
|
+
if(merged&&(strategy!=="auto"||cherry||forced))return workspaceMergeFailure(action,data,"unsupported_readiness","Merged readiness is sourced only for automatic non-forced, non-cherrypicked apply; do not reapply automatically.");
|
|
32
|
+
if((copied||merged)&&process.platform!=="win32")return workspaceMergeFailure(action,data,"unsupported_readiness","Copied readiness is sourced only on the observed Windows client.");
|
|
33
|
+
active="shortStatus";const short=await captureWorkspaceMergeCommand((copied||merged)?[...copiedPendingArgv]:["status","--short"],cwd);data.shortStatus={attempt:short.attempt,capture:short.capture,admission:"unsupported"};active=null;
|
|
32
34
|
if(!short.capture.complete||short.failed||short.attempt.exitCode!==0)return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; pending read failed/incomplete. Checkin readiness unknown.");
|
|
35
|
+
const mergedPending=merged?parseMergedPending(short.stdout!,cwd):null;
|
|
33
36
|
const pending=copied?parseCopiedPending(short.stdout!,cwd):null;
|
|
34
37
|
const copiedKeys=pending?.paths.map(copiedPathKey)??[];
|
|
35
38
|
const coherent=!!pending&&parsed.copiedPaths.length===copiedKeys.length&&new Set(parsed.copiedPaths.map(copiedPathKey)).size===copiedKeys.length&&parsed.copiedPaths.every(p=>copiedKeys.includes(copiedPathKey(p)))&&new Set(parsed.addedPaths.map(p=>copiedPathKey(win32.join(cwd,p)))).size===copiedKeys.length&&parsed.addedPaths.every(p=>p.startsWith("/")&&!p.includes("\\")&&p.slice(1).split("/").every(v=>v!==""&&v!=="."&&v!==".."&&!/[?\uFFFD]/u.test(v))&&copiedKeys.includes(copiedPathKey(win32.join(cwd,p))));
|
|
36
|
-
data.shortStatus.admission=(copied?coherent:short.stdout==="")&&short.stderr===""?"admitted":"unsupported";
|
|
39
|
+
data.shortStatus.admission=(merged?!!mergedPending&&correlateMergedApply(parsed.declarations!,mergedPending,cwd):copied?coherent:short.stdout==="")&&short.stderr===""?"admitted":"unsupported";
|
|
37
40
|
active="fullStatus";const full=await captureWorkspaceMergeCommand(["status"],cwd);data.fullStatus={attempt:full.attempt,capture:full.capture,admission:"unsupported"};active=null;
|
|
38
41
|
if(!full.capture.complete||full.failed||full.attempt.exitCode!==0)return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; merge-state read failed/incomplete. Checkin readiness unknown.");
|
|
39
|
-
data.fullStatus.admission=(copied?!!pending&&admitCopiedStandardStatus(full.stdout!,pending,source,cwd):admitCleanStandardStatus(full.stdout!))&&full.stderr===""?"admitted":"unsupported";
|
|
42
|
+
data.fullStatus.admission=(merged?!!mergedPending&&admitMergedStandardStatus(full.stdout!,mergedPending,source,cwd):copied?!!pending&&admitCopiedStandardStatus(full.stdout!,pending,source,cwd):admitCleanStandardStatus(full.stdout!))&&full.stderr===""?"admitted":"unsupported";
|
|
40
43
|
// No warning/unknown stderr can authorize compound checkin.
|
|
41
44
|
if(apply.stderr!=="")return workspaceMergeFailure(action,data,"unsupported_readiness","Merge command completed; applying stderr is outside the admitted warning profile. Checkin paused; prior effects remain possible. Do not reapply automatically.");
|
|
42
45
|
if(data.shortStatus.admission!=="admitted")return workspaceMergeFailure(action,data,"unsupported_readiness",copied?"Merge command completed; copied pending/ADD/path cardinality correlation is unadmitted (shortStatus). Checkin paused; inspect separately authorized pending evidence without reapplying merge.":"Merge command completed; shortStatus pending/warning evidence is unadmitted. Checkin paused; inspect separately authorized pending evidence without reapplying merge.");
|
package/src/pi/checkin-output.ts
CHANGED
|
@@ -10,10 +10,10 @@ 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
|
-
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"]), 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") };
|
|
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") };
|
|
17
17
|
const header = { schemaVersion: Type.Literal(1), action: Type.Literal("checkin"), provenance: object({ source: Type.Literal("plastic"), producer: Type.Literal("@aefree/pi-plastic"), contentTrust: Type.Literal("external") }), completeness: object({ capture: enumOf(["complete", "incomplete", "unknown"]), projection: Type.Boolean() }) };
|
|
18
18
|
const error = object({ code: str(64), stage: enumOf(["input", "pending", "checkin", "producer"]), message: str(512) });
|
|
19
19
|
export const checkinOutputSchema = Type.Unsafe<CheckinReceipt>(Type.Union([
|
|
@@ -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;
|
|
@@ -58,7 +58,7 @@ export function validateCloseoutOutput(value:unknown):value is CloseoutReceipt {
|
|
|
58
58
|
for(const [index,s] of stages.entries()) {
|
|
59
59
|
if(s.kind==="read") {
|
|
60
60
|
if(!validateRead(s))return false;
|
|
61
|
-
if(s.name==="readiness"){const merge=stages.slice(0,index).find(x=>x.kind==="merge");if(!merge||merge.kind!=="merge"||!same(s.result.argv,merge.result.data.protocol
|
|
61
|
+
if(s.name==="readiness"){const merge=stages.slice(0,index).find(x=>x.kind==="merge");if(!merge||merge.kind!=="merge"||!same(s.result.argv,["apply-add-copied","merged-candidate"].includes(merge.result.data.protocol)?[...switchPendingArgv]:["status"]))return false;}
|
|
62
62
|
if(s.name==="parent"){if(!d.sourceBranch||!same(s.result.argv,buildCloseoutParentArgv(d.sourceBranch)))return false;const src=parseSwitchTarget(d.sourceBranch)!;if(s.result.parentPath&&(s.result.parentPath!==src.branch.slice(0,src.branch.lastIndexOf("/"))||!s.result.capture?.stdoutBytes))return false;if(s.result.emptyParentResult&&s.result.capture?.stdoutBytes!==0)return false;if(index<stages.length-1&&(s.result.emptyParentResult||!s.result.parentPath||stages[index+1].name!=="pending-before"))return false;}
|
|
63
63
|
if(index<stages.length-1&&s.result.admission!=="admitted")return false;
|
|
64
64
|
if(s.name.startsWith("loaded-")&&s.name!=="loaded-before"&&s.result.branch){if(!d.targetBranch)return false;lastVerification=compareSwitchTarget(s.result.branch,d.targetBranch);if(index<stages.length-1&&lastVerification==="unverified")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
|
}
|
package/src/pi/diff-output.ts
CHANGED
|
@@ -58,9 +58,9 @@ export async function executeDiffOutput(args:unknown){
|
|
|
58
58
|
const json=(args as {format?:unknown})?.format==="json";
|
|
59
59
|
let text=JSON.stringify(dto);
|
|
60
60
|
if(!json){
|
|
61
|
-
if("data"in dto)text=dto.mode==="workspace"?"## Workspace Diff\n"+(dto.ok?"":"Partial failure: "+dto.error.code+"\n")+JSON.stringify(dto.data.counts)+"\nPrivate excluded: "+dto.data.excludedPrivate+"; unmatched paths: "+dto.data.unmatched.length+"\n"+dto.data.outcomes.map(o=>o.path+": "+o.status+(o.status==="compared"?"\n"+(o.comparison.excerpt?.text??o.comparison.status):o.status==="unavailable"?" ("+o.error.code+")":"")).join("\n\n"):"## Diff\n"+dto.data.status+"\n"+(dto.data.excerpt?.text??"");
|
|
61
|
+
if("data"in dto)text=dto.mode==="workspace"?"## Workspace Diff\n"+(dto.ok?"":"Partial failure: "+dto.error.code+"\n")+JSON.stringify(dto.data.counts)+"\nPrivate excluded: "+dto.data.excludedPrivate+"; unmatched paths: "+dto.data.unmatched.length+"\n"+dto.data.outcomes.map(o=>o.path+": "+o.status+(o.status==="compared"?"\n"+(o.comparison.excerpt?.text??o.comparison.status):o.status==="unavailable"?" ("+o.error.code+"/"+o.error.stage+"): "+o.error.message:"")).join("\n\n"):"## Diff\n"+dto.data.status+"\n"+(dto.data.excerpt?.text??"");
|
|
62
62
|
else text=dto.error.message;
|
|
63
63
|
}
|
|
64
|
-
if(text.length>24000)text=json?JSON.stringify({action:"diff",mode:dto.mode,ok:dto.ok,note:"Presentation omitted; use structuredContent for the bounded producer observation."}):text.slice(0,23920)+"\n[Presentation truncated; inspect the structured projection/completeness fields.]";
|
|
64
|
+
if(text.length>24000)text=json?JSON.stringify({action:"diff",mode:dto.mode,ok:dto.ok,...(!dto.ok?{error:dto.error}:{}),...(dto.mode==="workspace"&&"data"in dto?{counts:dto.data.counts,failures:dto.data.outcomes.flatMap((o,index)=>o.status==="unavailable"?[{index,error:o.error}]:[])}:{}),note:"Presentation omitted; use structuredContent for the bounded producer observation. Failure indices refer only to returned outcomes."}):text.slice(0,23920)+"\n[Presentation truncated; inspect the structured projection/completeness fields.]";
|
|
65
65
|
return {content:[{type:"text" as const,text}],details:{},structuredContent:dto,isError:!dto.ok};
|
|
66
66
|
}
|
|
@@ -18,7 +18,7 @@ const data = {
|
|
|
18
18
|
command: nullable(Type.Array(str(8192), { maxItems: 16 })),
|
|
19
19
|
capability: object({ state: enumOf(["not-checked", "advertised", "missing", "unavailable"]), missingTokens: Type.Array(str(64), { maxItems: 9 }) }),
|
|
20
20
|
attempt, capture: object({ help: nullable(capture), merge: nullable(capture) }),
|
|
21
|
-
parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: uint(500) })),
|
|
21
|
+
parse: nullable(object({ records: uint(500), malformed: Type.Boolean(), unknownOperations: uint(500), changesetsObserved: uint(500), conflictsObserved: uint(500), warningsObserved: uint(500), unknownOperationClasses: Type.Array(enumOf(["unsupported-operation", "APPLY/unsupported-subtype", "DIS_OP_WARN/unsupported-subtype"]), { maxItems: 3, uniqueItems: true }), resolvedConflictsVerified: uint(500) })),
|
|
22
22
|
observedChangesets: Type.Array(changeset, { maxItems: 100 }), conflictPaths: Type.Array(str(), { maxItems: 100 }),
|
|
23
23
|
counts: object({ changesetsReturned: uint(100), changesetsOmitted: uint(500), conflictsReturned: uint(100), conflictsOmitted: uint(500) }),
|
|
24
24
|
serverAliasEquivalence: Type.Literal("unverified"), mergeLinkIdentity: Type.Literal("unverified"), xlinkEffects: Type.Literal("unverified"), wouldRun: Type.Boolean(), remoteAnalysis: enumOf(["not-performed", "performed"]),
|
|
@@ -38,7 +38,13 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
|
|
|
38
38
|
const dto = value as MergeReceipt, d = dto.data, a = d.attempt, p = d.parse;
|
|
39
39
|
const safeText = (s: string) => !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
40
40
|
for (const cs of [...d.observedChangesets, ...(d.createdChangeset ? [d.createdChangeset] : [])]) if (!splitServerBranch("br:" + cs.branch + "@" + cs.repository + "@" + cs.server) || ![cs.branch, cs.repository, cs.server, cs.mount].every(safeText)) return false;
|
|
41
|
-
|
|
41
|
+
const canonicalPath = (s: string) => s.length > 1 && s.startsWith("/") && safeText(s) && !s.includes(String.fromCharCode(92)) && s.split("/").slice(1).every(part => !!part && part !== "." && part !== ".." && !/[. ]$/.test(part));
|
|
42
|
+
if (!d.conflictPaths.every(canonicalPath)) return false;
|
|
43
|
+
const safeComment = (s: string) => s.length <= 4096 && !!s.trim() && safeText(s.replace(/[\r\n]/g, ""));
|
|
44
|
+
if (d.command?.some(s => s.startsWith("-c=") ? !safeComment(s.slice(3)) : !safeText(s))) return false;
|
|
45
|
+
if (d.requestedIdentity && !Object.values(d.requestedIdentity).every(safeText)) return false;
|
|
46
|
+
if (p && (p.conflictsObserved + p.changesetsObserved > p.records || p.resolvedConflictsVerified > 0 && new Set(d.conflictPaths.map(s => s.toLowerCase())).size !== d.conflictPaths.length)) return false;
|
|
47
|
+
if (p && (p.unknownOperationClasses.length !== p.unknownOperations || p.resolvedConflictsVerified > p.conflictsObserved || p.resolvedConflictsVerified > 0 && (d.mode !== "apply" || !d.resolutionsFile || d.unlistedConflictPolicy !== null || d.resolutionsFile.keepSource !== 0 || d.resolutionsFile.keepDestination !== 0 || d.resolutionsFile.resultFiles !== p.resolvedConflictsVerified || d.resolutionsFile.entries !== p.resolvedConflictsVerified))) return false;
|
|
42
48
|
if (p && (p.warningsObserved > p.records || d.mode === "apply" && p.warningsObserved > 0 && ["completed", "no-op", "conflict"].includes(dto.outcome))) return false;
|
|
43
49
|
if (!p && (d.observedChangesets.length || d.conflictPaths.length || Object.values(d.counts).some(Boolean))) return false;
|
|
44
50
|
if (dto.completeness.capture === "complete" && !(d.capture.merge?.complete ?? d.capture.help?.complete)) return false;
|
|
@@ -60,7 +66,9 @@ export function validateServerMergeOutput(value: unknown): value is MergeReceipt
|
|
|
60
66
|
if (c.changesetsReturned + c.conflictsReturned > 100) return false;
|
|
61
67
|
if (c.changesetsReturned !== d.observedChangesets.length || c.conflictsReturned !== d.conflictPaths.length || p && (c.changesetsReturned + c.changesetsOmitted !== p.changesetsObserved || c.conflictsReturned + c.conflictsOmitted !== p.conflictsObserved)) return false;
|
|
62
68
|
if ((c.changesetsOmitted || c.conflictsOmitted) && dto.completeness.projection) return false;
|
|
63
|
-
if (dto.outcome === "completed" && (a.exitCode !== 0 || !d.requestedIdentity || !d.createdChangeset || d.createdChangeset.branch !== d.requestedIdentity.branch || d.createdChangeset.repository !== d.requestedIdentity.repository || d.createdChangeset.mount !== "/" || p?.changesetsObserved !== 1 || p.conflictsObserved !==
|
|
69
|
+
if (dto.outcome === "completed" && (a.exitCode !== 0 || !d.requestedIdentity || !d.createdChangeset || d.createdChangeset.branch !== d.requestedIdentity.branch || d.createdChangeset.repository !== d.requestedIdentity.repository || d.createdChangeset.server !== d.requestedIdentity.server || d.createdChangeset.mount !== "/" || p?.changesetsObserved !== 1 || p.conflictsObserved !== p.resolvedConflictsVerified)) return false;
|
|
70
|
+
if (["completed", "no-op", "analyzed"].includes(dto.outcome) && d.capture.merge?.stderrBytes) return false;
|
|
71
|
+
if (dto.outcome === "completed" && (!d.command || d.command[0] !== "cm" || d.command[1] !== "merge" || d.command[2] !== d.requestedIdentity?.source || !d.command.includes("--to=" + d.requestedIdentity?.target) || !d.command.includes("--merge") || !d.command.includes("--nointeractiveresolution") || !d.command.some(a => a.startsWith("-c=") && a.slice(3).trim()) || !d.wouldRun || d.capability.state !== "advertised" || d.capability.missingTokens.length > 0 || !d.capture.help?.complete || d.capture.help.stderrBytes || dto.completeness.capture !== "complete" || d.resolutionsFile && !d.command.includes("--fileconflictsresolutionsfile=" + d.resolutionsFile.path) || p?.resolvedConflictsVerified && d.command.some(a => a === "--keepsource" || a === "--keepdestination") || !d.observedChangesets.some(c => c.id === d.createdChangeset?.id && c.branch === d.createdChangeset.branch && c.repository === d.createdChangeset.repository && c.server === d.createdChangeset.server && c.mount === d.createdChangeset.mount))) return false;
|
|
64
72
|
if (dto.outcome === "no-op" && (a.exitCode !== 0 || p?.changesetsObserved !== 0 || p.conflictsObserved !== 0)) return false;
|
|
65
73
|
if (dto.outcome === "conflict" && (a.exitCode === 0 || p?.changesetsObserved !== 0 || !p.conflictsObserved)) return false;
|
|
66
74
|
for (const cap of [d.capture.help, d.capture.merge]) if (cap && (cap.stdoutRetainedBytes > cap.stdoutBytes || cap.stderrRetainedBytes > cap.stderrBytes || cap.complete && (!cap.validUtf8 || cap.truncated))) return false;
|
package/src/pi/status-output.ts
CHANGED
|
@@ -25,15 +25,17 @@ const item = object({
|
|
|
25
25
|
path: Type.String({ minLength: 1, maxLength: 4096, pattern: statusPathPattern }), isDirectory: Type.Boolean(),
|
|
26
26
|
revisionId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, pattern: "^[0-9]+$" })),
|
|
27
27
|
sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: statusPathPattern })),
|
|
28
|
+
similarity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
28
29
|
});
|
|
29
30
|
const xmlCount = Type.Integer({ minimum: 0, maximum: 20000 });
|
|
30
31
|
const xmlSummary = { totalPending: xmlCount, added: xmlCount, changed: xmlCount, moved: xmlCount, deleted: xmlCount, other: Type.Literal(0), private: xmlCount, tracked: xmlCount };
|
|
31
32
|
const xmlPathPattern = `^(?:/|[A-Za-z]:\\\\|\\\\\\\\)[^\\u0000-\\u001f${process.platform === "win32" ? "?" : ""}]*$`;
|
|
32
33
|
const xmlItem = object({
|
|
33
|
-
statusCode: Type.Union([Type.Literal("CH"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("PR")]),
|
|
34
|
+
statusCode: Type.Union([Type.Literal("CH"), Type.Literal("CO"), Type.Literal("CP"), Type.Literal("RP"), Type.Literal("AD"), Type.Literal("DE"), Type.Literal("LD"), Type.Literal("MV"), Type.Literal("LM"), Type.Literal("PR")]),
|
|
34
35
|
kind: Type.Union([Type.Literal("added"), Type.Literal("changed"), Type.Literal("moved"), Type.Literal("deleted"), Type.Literal("private")]),
|
|
35
36
|
path: Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern }), isDirectory: Type.Boolean(),
|
|
36
37
|
sourcePath: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, pattern: xmlPathPattern })),
|
|
38
|
+
similarity: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
|
|
37
39
|
});
|
|
38
40
|
export const statusOutputSchema = Type.Union([
|
|
39
41
|
object({ ...common, schemaVersion: Type.Literal(2), ok: Type.Literal(true), data: object({
|
|
@@ -72,18 +74,49 @@ const envelope = { schemaVersion: 1, action: "status", provenance } as const;
|
|
|
72
74
|
type StatusErrorCode = Extract<StatusOutput, { ok: false; schemaVersion: 1 }>["error"]["code"];
|
|
73
75
|
const errorOutput = (code: StatusErrorCode, message: string, schemaVersion: 1 | 2 = 1): StatusOutput => ({ ...envelope, schemaVersion, ok: false, completeness: { read: "incomplete", capture: "unknown", projection: false }, error: { code, message } });
|
|
74
76
|
|
|
77
|
+
// Independent DTO trust-boundary validation mirrors the observation identity rules.
|
|
78
|
+
const statusObservationPathKey = (value: string): string | null => {
|
|
79
|
+
if (!value || value.length > 4096 || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value)) return null;
|
|
80
|
+
const path = value.replace(/\\/g, "/");
|
|
81
|
+
if (!/^(?:\/|[A-Za-z]:\/)/.test(path) || path.split("/").some(part => part === "." || part === "..")) return null;
|
|
82
|
+
const windows = /^[A-Za-z]:\//.test(path) || path.startsWith("//");
|
|
83
|
+
const tail = path.slice(path.startsWith("//") ? 2 : windows ? 3 : 1);
|
|
84
|
+
if (tail.includes("//") || (windows && (/[<>:"|?*]/.test(tail) || tail.split("/").some(part => /[. ]$/.test(part))))) return null;
|
|
85
|
+
if (path.startsWith("//") && !/^\/\/[^/]+\/[^/]+\//.test(path)) return null;
|
|
86
|
+
const key = windows ? path.toLowerCase() : path;
|
|
87
|
+
return key.length > (windows ? 3 : 1) ? key.replace(/\/$/, "") : key;
|
|
88
|
+
};
|
|
89
|
+
const statusObservationPathRoot = (value: string): string => {
|
|
90
|
+
const path = value.replace(/\\/g, "/").toLowerCase();
|
|
91
|
+
return path.match(/^[a-z]:/)?.[0] ?? (path.startsWith("//") ? path.split("/").slice(0, 4).join("/") : "/");
|
|
92
|
+
};
|
|
93
|
+
|
|
75
94
|
export function validateStatusOutput(value: unknown, schemaVersion: 1 | 2 = 1): StatusOutput {
|
|
76
95
|
if (!Check(statusOutputSchema, value)) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", schemaVersion);
|
|
77
96
|
const checked = value as StatusOutput;
|
|
97
|
+
if (checked.ok && checked.data.mode === "machine") {
|
|
98
|
+
const invalidMove = checked.data.items.some(row => {
|
|
99
|
+
if (row.statusCode !== "LM") return row.similarity !== undefined;
|
|
100
|
+
const destination = statusObservationPathKey(row.path);
|
|
101
|
+
const source = statusObservationPathKey(row.sourcePath ?? "");
|
|
102
|
+
return row.kind !== "moved" || row.similarity === undefined || !source || !destination || source === destination ||
|
|
103
|
+
statusObservationPathRoot(row.path) !== statusObservationPathRoot(row.sourcePath ?? "") || !/^[1-9][0-9]{0,19}$/.test(row.revisionId ?? "");
|
|
104
|
+
});
|
|
105
|
+
if (invalidMove) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", schemaVersion);
|
|
106
|
+
}
|
|
78
107
|
if (checked.ok && checked.data.mode === "xml") {
|
|
79
108
|
const data = checked.data;
|
|
80
109
|
const { parsed, returned, omitted, excluded, overCap } = data.itemCount;
|
|
81
110
|
const seen = new Set<string>();
|
|
82
|
-
const kinds = { CH: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" };
|
|
111
|
+
const kinds = { CH: "changed", CO: "changed", CP: "added", RP: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", LM: "moved", PR: "private" };
|
|
83
112
|
const invalidRows = data.items.some(row => {
|
|
84
113
|
const unsafe = (path: string) => /[\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(path);
|
|
85
|
-
const invalid = unsafe(row.path) || (row.sourcePath !== undefined && unsafe(row.sourcePath)) || row.kind !== kinds[row.statusCode] || (row.kind === "moved") !== Boolean(row.sourcePath) || row.sourcePath === row.path
|
|
86
|
-
|
|
114
|
+
const invalid = unsafe(row.path) || (row.sourcePath !== undefined && unsafe(row.sourcePath)) || row.kind !== kinds[row.statusCode] || (row.kind === "moved") !== Boolean(row.sourcePath) || row.sourcePath === row.path;
|
|
115
|
+
const keys = [row.path, ...(row.sourcePath ? [row.sourcePath] : [])].map(statusObservationPathKey);
|
|
116
|
+
const invalidIdentity = keys.some(key => !key || seen.has(key)) || new Set(keys).size !== keys.length;
|
|
117
|
+
for (const key of keys) if (key) seen.add(key);
|
|
118
|
+
const invalidSimilarity = row.statusCode === "LM" ? row.similarity === undefined || statusObservationPathRoot(row.path) !== statusObservationPathRoot(row.sourcePath ?? "") : row.similarity !== undefined;
|
|
119
|
+
return invalid || invalidIdentity || invalidSimilarity;
|
|
87
120
|
});
|
|
88
121
|
if (data.summary.added + data.summary.changed + data.summary.deleted + data.summary.moved + data.summary.private + data.summary.other !== parsed || data.summary.other !== 0 || data.summary.tracked !== parsed - data.summary.private || invalidRows || returned !== data.items.length || parsed !== data.parse.records || parsed !== data.summary.totalPending || omitted !== parsed - returned || excluded !== 0 || overCap !== omitted || checked.completeness.read !== "complete" || checked.completeness.capture !== "complete" || checked.completeness.projection !== (returned === parsed)) return errorOutput("invalid_producer_data", "Status producer data did not match the output contract.", 2);
|
|
89
122
|
}
|
|
@@ -110,6 +143,7 @@ export function projectStatusOutput(observation: StatusObservation, maxItems = 1
|
|
|
110
143
|
statusCode: row.statusCode, kind: row.kind, path: row.workspacePath, isDirectory: row.isDirectory,
|
|
111
144
|
...(row.revisionId !== undefined ? { revisionId: row.revisionId } : {}),
|
|
112
145
|
...(row.sourceWorkspacePath !== undefined ? { sourcePath: row.sourceWorkspacePath } : {}),
|
|
146
|
+
...(row.similarity !== undefined ? { similarity: row.similarity } : {}),
|
|
113
147
|
})).filter(row => Check(item, row));
|
|
114
148
|
const items = eligible.slice(0, maxItems);
|
|
115
149
|
const excluded = pendingItems.length - eligible.length;
|
|
@@ -6,7 +6,7 @@ const attempt=obj({state:enm(["not-attempted","not-started","started","unknown"]
|
|
|
6
6
|
const capture=obj({stdoutBytes:uint(),stderrBytes:uint(),stdoutRetainedBytes:uint(65536),stderrRetainedBytes:uint(16384),truncated:Type.Boolean(),complete:Type.Boolean(),validUtf8:Type.Boolean()});
|
|
7
7
|
const stage=obj({attempt,capture:nil(capture),admission:enm(["not-observed","unsupported","admitted"])});
|
|
8
8
|
const text=Type.String({minLength:1,maxLength:4096});
|
|
9
|
-
const data=obj({intendedArgv:Type.Array(text,{minItems:1,maxItems:13}),workingDirectory:nil(text),requestedSource:nil(text),strategy:nil(enm(["auto","source","destination"])),cherrypicking:nil(Type.Boolean()),forced:nil(Type.Boolean()),previewRequested:nil(Type.Boolean()),outputFormatRequested:nil(enm(["text","json"])),apply:stage,shortStatus:stage,fullStatus:stage,pendingReadMode:enm(["short","copied-machine"]),commandCompleted:Type.Boolean(),observedWorkspaceIdentity:Type.Null(),observedSourceIdentity:Type.Null(),observedFinalizedMetadata:Type.Null(),observedPreservation:Type.Null(),observedPendingItems:Type.Null(),fileConflictCount:nil(uint(256)),protocol:enm(["not-observed","already-connected","file-conflict","apply-add","apply-add-copied","unsupported"]),checkinReadiness:enm(["unknown","blocked","supported-no-unresolved-signals"]),verification:Type.Literal("unverified"),effect:enm(["not-attempted","not-proven","uncertain"])});
|
|
9
|
+
const data=obj({intendedArgv:Type.Array(text,{minItems:1,maxItems:13}),workingDirectory:nil(text),requestedSource:nil(text),strategy:nil(enm(["auto","source","destination"])),cherrypicking:nil(Type.Boolean()),forced:nil(Type.Boolean()),previewRequested:nil(Type.Boolean()),outputFormatRequested:nil(enm(["text","json"])),apply:stage,shortStatus:stage,fullStatus:stage,pendingReadMode:enm(["short","copied-machine","merged-machine"]),commandCompleted:Type.Boolean(),observedWorkspaceIdentity:Type.Null(),observedSourceIdentity:Type.Null(),observedFinalizedMetadata:Type.Null(),observedPreservation:Type.Null(),observedPendingItems:Type.Null(),fileConflictCount:nil(uint(256)),protocol:enm(["not-observed","already-connected","file-conflict","apply-add","apply-add-copied","merged-candidate","unsupported"]),checkinReadiness:enm(["unknown","blocked","supported-no-unresolved-signals"]),verification:Type.Literal("unverified"),effect:enm(["not-attempted","not-proven","uncertain"])});
|
|
10
10
|
export const workspaceMergeOutputSchema=Type.Unsafe<WorkspaceMergeReceipt>(Type.Union([
|
|
11
11
|
obj({schemaVersion:Type.Literal(1),action:enm(["merge","finalize-merge"]),provenance:obj({source:Type.Literal("plastic"),producer:Type.Literal("@aefree/pi-plastic"),contentTrust:Type.Literal("external")}),completeness:obj({capture:enm(["complete","incomplete","unknown"]),projection:Type.Literal(true)}),data,ok:Type.Literal(true),outcome:enm(["preflight","command-completed"])}),
|
|
12
12
|
obj({schemaVersion:Type.Literal(1),action:enm(["merge","finalize-merge"]),provenance:obj({source:Type.Literal("plastic"),producer:Type.Literal("@aefree/pi-plastic"),contentTrust:Type.Literal("external")}),completeness:obj({capture:enm(["complete","incomplete","unknown"]),projection:Type.Literal(true)}),data,ok:Type.Literal(false),outcome:enm(["failed","uncertain","blocked"]),error:obj({code:enm(["invalid_request","launch_failed","aborted","uncertain","unsupported_readiness","conflict","producer_failed"]),message:Type.String({minLength:1,maxLength:256})})})
|
|
@@ -31,12 +31,15 @@ export function validateWorkspaceMergeOutput(v:unknown):v is WorkspaceMergeRecei
|
|
|
31
31
|
if(d.commandCompleted!==Boolean(d.apply.capture?.complete&&d.apply.attempt.state==="started"&&d.apply.attempt.exitCode===0))return false;
|
|
32
32
|
if(d.shortStatus.admission==="admitted"&&((d.pendingReadMode==="short"?d.shortStatus.capture?.stdoutBytes!==0:!d.shortStatus.capture?.stdoutBytes)||d.shortStatus.capture?.stderrBytes!==0)||d.fullStatus.admission==="admitted"&&(!d.fullStatus.capture?.stdoutBytes||d.fullStatus.capture?.stderrBytes!==0))return false;
|
|
33
33
|
if(d.pendingReadMode==="copied-machine"&&d.protocol!=="apply-add-copied"||d.protocol==="apply-add-copied"&&d.pendingReadMode!=="copied-machine")return false;
|
|
34
|
-
|
|
34
|
+
if((d.pendingReadMode==="merged-machine")!==(d.protocol==="merged-candidate"))return false;
|
|
35
|
+
const later=stages.slice(1).some(s=>s.admission!=="not-observed");if(later&&(!d.commandCompleted||!["already-connected","apply-add","apply-add-copied","merged-candidate"].includes(d.protocol)))return false;
|
|
35
36
|
if(d.fullStatus.admission!=="not-observed"&&(!d.shortStatus.capture?.complete||d.shortStatus.attempt.exitCode!==0))return false;
|
|
36
37
|
if(d.protocol==="not-observed"&&(d.apply.admission==="admitted"||d.fileConflictCount!==null)||d.protocol==="unsupported"&&(d.fileConflictCount!==null||d.apply.admission!=="unsupported"))return false;
|
|
37
|
-
if(["already-connected","apply-add","apply-add-copied","file-conflict"].includes(d.protocol)&&d.apply.admission!=="admitted")return false;
|
|
38
|
+
if(["already-connected","apply-add","apply-add-copied","file-conflict","merged-candidate"].includes(d.protocol)&&d.apply.admission!=="admitted")return false;
|
|
39
|
+
if(d.protocol==="merged-candidate"&&(d.fileConflictCount===null||d.fileConflictCount>128||d.checkinReadiness==="blocked"))return false;
|
|
38
40
|
if(d.protocol==="file-conflict"?(d.fileConflictCount===null||d.fileConflictCount===0||d.checkinReadiness!=="blocked"):d.checkinReadiness==="blocked")return false;
|
|
39
41
|
if(["already-connected","apply-add","apply-add-copied"].includes(d.protocol)&&d.fileConflictCount!==0)return false;
|
|
42
|
+
if(d.protocol==="merged-candidate"&&d.checkinReadiness==="supported-no-unresolved-signals"&&(d.strategy!=="auto"||d.forced!==false||d.cherrypicking!==false))return false;
|
|
40
43
|
if(d.checkinReadiness==="supported-no-unresolved-signals"&&(!d.commandCompleted||!stages.every(s=>s.admission==="admitted"&&s.capture?.complete&&s.attempt.exitCode===0&&s.capture.stderrBytes===0)))return false;
|
|
41
44
|
if(d.previewRequested===true&&(!dto.ok||dto.outcome!=="preflight"||stages.some(s=>s.admission!=="not-observed")||d.effect!=="not-attempted"||d.commandCompleted||d.checkinReadiness!=="unknown"))return false;
|
|
42
45
|
if(dto.ok){if(dto.outcome==="preflight"&&d.previewRequested!==true)return false;if(dto.outcome==="command-completed"&&(d.previewRequested!==false||d.effect!=="not-proven"||d.checkinReadiness!=="supported-no-unresolved-signals"))return false;}
|