gentle-pi 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
|
@@ -1,861 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
chmodSync,
|
|
5
|
-
closeSync,
|
|
6
|
-
existsSync,
|
|
7
|
-
fsyncSync,
|
|
8
|
-
mkdirSync,
|
|
9
|
-
openSync,
|
|
10
|
-
readFileSync,
|
|
11
|
-
realpathSync,
|
|
12
|
-
renameSync,
|
|
13
|
-
rmSync,
|
|
14
|
-
statSync,
|
|
15
|
-
unlinkSync,
|
|
16
|
-
writeFileSync,
|
|
17
|
-
} from "node:fs";
|
|
18
|
-
import { hostname } from "node:os";
|
|
19
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { execFileSync } from "node:child_process";
|
|
22
|
-
import {
|
|
23
|
-
createNativeReviewCli,
|
|
24
|
-
type NativeReviewCli,
|
|
25
|
-
type NativeValidateResult,
|
|
26
|
-
} from "./native-review-cli.ts";
|
|
27
|
-
|
|
28
|
-
const TRANSACTION_SCHEMA = "gentle-pi.git-commit-transaction/v1";
|
|
29
|
-
const INVOCATION_SCHEMA = "gentle-pi.git-commit-transaction-invocation/v1";
|
|
30
|
-
const INDEX_ASSERTION_SCHEMA = "gentle-pi.git-commit-index-assertion/v1";
|
|
31
|
-
const COMMIT_CAPTURE_SCHEMA = "gentle-pi.git-commit-capture/v1";
|
|
32
|
-
const GIT_TIMEOUT_MS = 10_000;
|
|
33
|
-
|
|
34
|
-
export const COMMIT_TRANSACTION_STATE = {
|
|
35
|
-
PREPARED: "prepared",
|
|
36
|
-
HOOK_RUNNING: "hook-running",
|
|
37
|
-
AWAITING_NATIVE: "awaiting-native",
|
|
38
|
-
AWAITING_REVIEW: "awaiting-review",
|
|
39
|
-
VALIDATION_FAILED: "validation-failed",
|
|
40
|
-
VALIDATED: "validated",
|
|
41
|
-
COMMIT_RUNNING: "commit-running",
|
|
42
|
-
COMMIT_FAILED: "commit-failed",
|
|
43
|
-
COMMITTED: "committed",
|
|
44
|
-
HOOK_FAILED: "hook-failed",
|
|
45
|
-
INTERRUPTED: "interrupted",
|
|
46
|
-
INCIDENT: "incident",
|
|
47
|
-
ABANDONED: "abandoned",
|
|
48
|
-
} as const;
|
|
49
|
-
|
|
50
|
-
export type CommitTransactionState =
|
|
51
|
-
(typeof COMMIT_TRANSACTION_STATE)[keyof typeof COMMIT_TRANSACTION_STATE];
|
|
52
|
-
|
|
53
|
-
export interface CommitTransactionAuthorization {
|
|
54
|
-
lineageId: string;
|
|
55
|
-
storeRevision: string;
|
|
56
|
-
fingerprint: string;
|
|
57
|
-
intendedTree: string;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface CommitTransactionInvocation {
|
|
61
|
-
schema: typeof INVOCATION_SCHEMA;
|
|
62
|
-
transactionId: string;
|
|
63
|
-
command: string;
|
|
64
|
-
commandHash: string;
|
|
65
|
-
cwd: string;
|
|
66
|
-
arguments: readonly string[];
|
|
67
|
-
authorization: CommitTransactionAuthorization;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface CommitTransactionRecordBody {
|
|
71
|
-
schema: typeof TRANSACTION_SCHEMA;
|
|
72
|
-
transaction_id: string;
|
|
73
|
-
repository_id: string;
|
|
74
|
-
repository_root: string;
|
|
75
|
-
common_directory: string;
|
|
76
|
-
git_directory: string;
|
|
77
|
-
command: string;
|
|
78
|
-
command_hash: string;
|
|
79
|
-
arguments: readonly string[];
|
|
80
|
-
original_head?: string;
|
|
81
|
-
original_head_tree?: string;
|
|
82
|
-
original_index_tree: string;
|
|
83
|
-
original_index_hash: string;
|
|
84
|
-
authorized_pre_hook_tree: string;
|
|
85
|
-
state: CommitTransactionState;
|
|
86
|
-
created_at: string;
|
|
87
|
-
updated_at: string;
|
|
88
|
-
hook_runs: number;
|
|
89
|
-
invocation_ids: readonly string[];
|
|
90
|
-
lineage_history: readonly string[];
|
|
91
|
-
post_hook_tree?: string;
|
|
92
|
-
post_hook_index_hash?: string;
|
|
93
|
-
authorized_tree?: string;
|
|
94
|
-
authority_revision?: string;
|
|
95
|
-
gate_context_hash?: string;
|
|
96
|
-
committed_head?: string;
|
|
97
|
-
committed_tree?: string;
|
|
98
|
-
git_created_head?: string;
|
|
99
|
-
git_created_tree?: string;
|
|
100
|
-
error?: string;
|
|
101
|
-
native_result?: Record<string, unknown>;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export interface CommitTransactionRecord extends CommitTransactionRecordBody {
|
|
105
|
-
record_hash: string;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export interface CommitTransactionInspection {
|
|
109
|
-
status: "clean" | "active" | "corrupted";
|
|
110
|
-
record?: CommitTransactionRecord;
|
|
111
|
-
reason?: string;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export interface CommitTransactionResult {
|
|
115
|
-
transactionId: string;
|
|
116
|
-
status: "committed" | "recovered";
|
|
117
|
-
head: string;
|
|
118
|
-
tree: string;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface CommitTransactionDependencies {
|
|
122
|
-
nativeReviewCli?: NativeReviewCli;
|
|
123
|
-
runnerPath?: string;
|
|
124
|
-
now?: () => Date;
|
|
125
|
-
signal?: AbortSignal;
|
|
126
|
-
failpoint?: "after-commit-before-proof";
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
interface RepositoryBinding {
|
|
130
|
-
root: string;
|
|
131
|
-
commonDir: string;
|
|
132
|
-
gitDir: string;
|
|
133
|
-
repositoryId: string;
|
|
134
|
-
stateDir: string;
|
|
135
|
-
activePath: string;
|
|
136
|
-
lockPath: string;
|
|
137
|
-
historyDir: string;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
interface LockBody {
|
|
141
|
-
schema: "gentle-pi.git-commit-transaction-lock/v1";
|
|
142
|
-
pid: number;
|
|
143
|
-
host: string;
|
|
144
|
-
transaction_id: string;
|
|
145
|
-
created_at: string;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
interface ProcessResult {
|
|
149
|
-
code: number;
|
|
150
|
-
signal: NodeJS.Signals | null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function sha256(value: string | Buffer): string {
|
|
154
|
-
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function canonicalJson(value: unknown): string {
|
|
158
|
-
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
159
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
160
|
-
const object = value as Record<string, unknown>;
|
|
161
|
-
return `{${Object.keys(object).filter((key) => object[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function recordHash(body: CommitTransactionRecordBody): string {
|
|
165
|
-
return sha256(canonicalJson(body));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function git(cwd: string, args: readonly string[]): string {
|
|
169
|
-
return execFileSync("git", args, {
|
|
170
|
-
cwd,
|
|
171
|
-
encoding: "utf8",
|
|
172
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
173
|
-
timeout: GIT_TIMEOUT_MS,
|
|
174
|
-
windowsHide: true,
|
|
175
|
-
}).trim();
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function absoluteGitPath(cwd: string, name: string): string {
|
|
179
|
-
const value = git(cwd, ["rev-parse", "--path-format=absolute", "--git-path", name]);
|
|
180
|
-
return isAbsolute(value) ? value : resolve(cwd, value);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Runs a probe that may exit nonzero as an expected signal (absent ref, unborn
|
|
184
|
-
// HEAD). Returns the exit status and trimmed stdout. Timeout and I/O failures
|
|
185
|
-
// propagate instead of being masked as a status, so callers fail closed.
|
|
186
|
-
function probeGit(cwd: string, args: readonly string[]): { status: number; stdout: string } {
|
|
187
|
-
try {
|
|
188
|
-
const stdout = execFileSync("git", args, {
|
|
189
|
-
cwd,
|
|
190
|
-
encoding: "utf8",
|
|
191
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
192
|
-
timeout: GIT_TIMEOUT_MS,
|
|
193
|
-
windowsHide: true,
|
|
194
|
-
});
|
|
195
|
-
return { status: 0, stdout: stdout.trim() };
|
|
196
|
-
} catch (error) {
|
|
197
|
-
const detail = error as NodeJS.ErrnoException & { killed?: boolean; status?: number; stdout?: string | Buffer };
|
|
198
|
-
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
|
|
199
|
-
if (typeof detail.status === "number") return { status: detail.status, stdout: typeof detail.stdout === "string" ? detail.stdout.trim() : "" };
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Resolves HEAD to a commit SHA, or undefined only for a valid unborn symbolic
|
|
205
|
-
// HEAD (symbolic HEAD pointing at a branch with no commits). Timeout, I/O,
|
|
206
|
-
// corruption, and all other failures propagate (fail closed on uncertain HEAD
|
|
207
|
-
// state). Classification uses status-based probes, not localized stderr text.
|
|
208
|
-
function resolveHead(cwd: string): string | undefined {
|
|
209
|
-
try {
|
|
210
|
-
return git(cwd, ["rev-parse", "--verify", "HEAD"]);
|
|
211
|
-
} catch (error) {
|
|
212
|
-
const detail = error as NodeJS.ErrnoException & { killed?: boolean };
|
|
213
|
-
if (detail.code === "ETIMEDOUT" || detail.killed === true) throw error;
|
|
214
|
-
if (typeof detail.status !== "number") throw error;
|
|
215
|
-
const symbolic = probeGit(cwd, ["symbolic-ref", "--quiet", "HEAD"]);
|
|
216
|
-
if (symbolic.status !== 0) throw error;
|
|
217
|
-
// show-ref --verify --quiet distinguishes: status 1 = ref absent (valid
|
|
218
|
-
// unborn), status 0 = ref exists and valid (rethrow original HEAD error),
|
|
219
|
-
// any other status (128, etc.) = corruption/missing object (fail closed).
|
|
220
|
-
const refProbe = probeGit(cwd, ["show-ref", "--verify", "--quiet", symbolic.stdout]);
|
|
221
|
-
if (refProbe.status === 1) return undefined;
|
|
222
|
-
throw error;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function repositoryBinding(cwd: string): RepositoryBinding {
|
|
227
|
-
const root = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]));
|
|
228
|
-
const commonDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
229
|
-
const gitDirValue = git(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
230
|
-
const commonDir = realpathSync(commonDirValue);
|
|
231
|
-
const gitDir = realpathSync(gitDirValue);
|
|
232
|
-
// Preserve the durable repository identity across the unborn-handling
|
|
233
|
-
// upgrade: a born repository keeps the byte-for-byte previous formula
|
|
234
|
-
// `sha256(canonicalJson({ common_directory: commonDir, roots }))` with
|
|
235
|
-
// `roots` the sorted root commits reachable from HEAD. An unborn
|
|
236
|
-
// repository has no HEAD, so `rev-list HEAD` cannot run; resolveHead
|
|
237
|
-
// already classifies HEAD state and propagates timeout/corruption/I/O
|
|
238
|
-
// failures rather than masking them, so the unborn branch gets a
|
|
239
|
-
// deterministic safe roots representation (the empty set) without
|
|
240
|
-
// hiding real errors.
|
|
241
|
-
const head = resolveHead(root);
|
|
242
|
-
const roots = head === undefined
|
|
243
|
-
? []
|
|
244
|
-
: git(root, ["rev-list", "--max-parents=0", "HEAD"]).split(/\r?\n/).filter(Boolean).sort();
|
|
245
|
-
const repositoryId = sha256(canonicalJson({ common_directory: commonDir, roots }));
|
|
246
|
-
const worktreeKey = sha256(gitDir).slice("sha256:".length, "sha256:".length + 24);
|
|
247
|
-
const stateDir = join(commonDir, "gentle-pi", "commit-transactions", worktreeKey);
|
|
248
|
-
return {
|
|
249
|
-
root,
|
|
250
|
-
commonDir,
|
|
251
|
-
gitDir,
|
|
252
|
-
repositoryId,
|
|
253
|
-
stateDir,
|
|
254
|
-
activePath: join(stateDir, "active.json"),
|
|
255
|
-
lockPath: join(stateDir, "lock.json"),
|
|
256
|
-
historyDir: join(stateDir, "history"),
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function ensureStateDirectories(binding: RepositoryBinding): void {
|
|
261
|
-
mkdirSync(binding.historyDir, { recursive: true, mode: 0o700 });
|
|
262
|
-
chmodSync(dirname(binding.stateDir), 0o700);
|
|
263
|
-
chmodSync(binding.stateDir, 0o700);
|
|
264
|
-
chmodSync(binding.historyDir, 0o700);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function atomicWrite(path: string, value: string): void {
|
|
268
|
-
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
269
|
-
const descriptor = openSync(temporary, "wx", 0o600);
|
|
270
|
-
try {
|
|
271
|
-
writeFileSync(descriptor, value, "utf8");
|
|
272
|
-
fsyncSync(descriptor);
|
|
273
|
-
} finally {
|
|
274
|
-
closeSync(descriptor);
|
|
275
|
-
}
|
|
276
|
-
renameSync(temporary, path);
|
|
277
|
-
if (process.platform !== "win32") {
|
|
278
|
-
const directory = openSync(dirname(path), "r");
|
|
279
|
-
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function writeRecord(path: string, body: CommitTransactionRecordBody): CommitTransactionRecord {
|
|
284
|
-
const record: CommitTransactionRecord = { ...body, record_hash: recordHash(body) };
|
|
285
|
-
atomicWrite(path, `${canonicalJson(record)}\n`);
|
|
286
|
-
return record;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function isStringArray(value: unknown): value is string[] {
|
|
290
|
-
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function decodeRecord(value: unknown): CommitTransactionRecord {
|
|
294
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction record is not an object");
|
|
295
|
-
const record = value as Record<string, unknown>;
|
|
296
|
-
if (record.schema !== TRANSACTION_SCHEMA) throw new Error("commit transaction record schema is incompatible");
|
|
297
|
-
for (const field of [
|
|
298
|
-
"transaction_id", "repository_id", "repository_root", "common_directory", "git_directory",
|
|
299
|
-
"command", "command_hash", "original_index_tree",
|
|
300
|
-
"original_index_hash", "authorized_pre_hook_tree", "state", "created_at", "updated_at", "record_hash",
|
|
301
|
-
]) if (typeof record[field] !== "string" || (record[field] as string).length === 0) throw new Error(`commit transaction record ${field} is invalid`);
|
|
302
|
-
if (!isStringArray(record.arguments) || !isStringArray(record.invocation_ids) || !isStringArray(record.lineage_history)) throw new Error("commit transaction record arrays are invalid");
|
|
303
|
-
for (const field of ["original_head", "original_head_tree", "post_hook_tree", "post_hook_index_hash", "authorized_tree", "authority_revision", "gate_context_hash", "committed_head", "committed_tree", "git_created_head", "git_created_tree", "error"] as const) {
|
|
304
|
-
if (record[field] !== undefined && typeof record[field] !== "string") throw new Error(`commit transaction record ${field} is invalid`);
|
|
305
|
-
}
|
|
306
|
-
if (!Number.isSafeInteger(record.hook_runs) || (record.hook_runs as number) < 0) throw new Error("commit transaction hook count is invalid");
|
|
307
|
-
if (!(Object.values(COMMIT_TRANSACTION_STATE) as readonly unknown[]).includes(record.state)) throw new Error("commit transaction state is invalid");
|
|
308
|
-
const { record_hash: hash, ...body } = record;
|
|
309
|
-
if (recordHash(body as unknown as CommitTransactionRecordBody) !== hash) throw new Error("commit transaction record integrity check failed");
|
|
310
|
-
return record as unknown as CommitTransactionRecord;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function readRecord(path: string): CommitTransactionRecord | undefined {
|
|
314
|
-
if (!existsSync(path)) return undefined;
|
|
315
|
-
return decodeRecord(JSON.parse(readFileSync(path, "utf8")));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function bodyOf(record: CommitTransactionRecord): CommitTransactionRecordBody {
|
|
319
|
-
const { record_hash: _hash, ...body } = record;
|
|
320
|
-
return body;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function transition(
|
|
324
|
-
binding: RepositoryBinding,
|
|
325
|
-
record: CommitTransactionRecord,
|
|
326
|
-
state: CommitTransactionState,
|
|
327
|
-
patch: Partial<CommitTransactionRecordBody> = {},
|
|
328
|
-
now: () => Date = () => new Date(),
|
|
329
|
-
): CommitTransactionRecord {
|
|
330
|
-
return writeRecord(binding.activePath, {
|
|
331
|
-
...bodyOf(record),
|
|
332
|
-
...patch,
|
|
333
|
-
state,
|
|
334
|
-
updated_at: now().toISOString(),
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function archive(binding: RepositoryBinding, record: CommitTransactionRecord): CommitTransactionRecord {
|
|
339
|
-
const archived = writeRecord(join(binding.historyDir, `${record.transaction_id}.json`), bodyOf(record));
|
|
340
|
-
if (existsSync(binding.activePath)) unlinkSync(binding.activePath);
|
|
341
|
-
return archived;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function processIsAlive(pid: number): boolean {
|
|
345
|
-
try { process.kill(pid, 0); return true; } catch (error) {
|
|
346
|
-
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function acquireLock(binding: RepositoryBinding, transactionId: string, now: () => Date): () => void {
|
|
351
|
-
ensureStateDirectories(binding);
|
|
352
|
-
const body: LockBody = {
|
|
353
|
-
schema: "gentle-pi.git-commit-transaction-lock/v1",
|
|
354
|
-
pid: process.pid,
|
|
355
|
-
host: hostname(),
|
|
356
|
-
transaction_id: transactionId,
|
|
357
|
-
created_at: now().toISOString(),
|
|
358
|
-
};
|
|
359
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
360
|
-
try {
|
|
361
|
-
const descriptor = openSync(binding.lockPath, "wx", 0o600);
|
|
362
|
-
try { writeFileSync(descriptor, `${canonicalJson(body)}\n`, "utf8"); fsyncSync(descriptor); }
|
|
363
|
-
finally { closeSync(descriptor); }
|
|
364
|
-
return () => { try { unlinkSync(binding.lockPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } };
|
|
365
|
-
} catch (error) {
|
|
366
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
367
|
-
let existing: Partial<LockBody> = {};
|
|
368
|
-
try { existing = JSON.parse(readFileSync(binding.lockPath, "utf8")) as Partial<LockBody>; } catch { /* fail closed below */ }
|
|
369
|
-
if (existing.host === hostname() && typeof existing.pid === "number" && !processIsAlive(existing.pid)) {
|
|
370
|
-
unlinkSync(binding.lockPath);
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
throw new Error(`commit transaction lock is active${typeof existing.transaction_id === "string" ? ` for ${existing.transaction_id}` : ""}`);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
throw new Error("commit transaction stale lock could not be reconciled");
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function indexFingerprint(cwd: string): string {
|
|
380
|
-
const indexPath = absoluteGitPath(cwd, "index");
|
|
381
|
-
if (!existsSync(indexPath)) return sha256("missing-index");
|
|
382
|
-
const before = statSync(indexPath);
|
|
383
|
-
const bytes = readFileSync(indexPath);
|
|
384
|
-
const after = statSync(indexPath);
|
|
385
|
-
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
386
|
-
throw new Error("Git index changed while its transaction fingerprint was captured");
|
|
387
|
-
}
|
|
388
|
-
return sha256(bytes);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function assertSafeCommitArguments(arguments_: readonly string[]): void {
|
|
392
|
-
const booleanOptions = new Set([
|
|
393
|
-
"--allow-empty", "--allow-empty-message", "--amend", "--edit", "--no-edit", "--no-gpg-sign",
|
|
394
|
-
"--no-post-rewrite", "--no-signoff", "--no-status", "--no-verify", "--quiet", "--short",
|
|
395
|
-
"--signoff", "--status", "--verbose",
|
|
396
|
-
]);
|
|
397
|
-
const valueOptions = new Set([
|
|
398
|
-
"--author", "--cleanup", "--date", "--file", "--fixup", "--message", "--reedit-message",
|
|
399
|
-
"--reuse-message", "--squash", "-C", "-F", "-c", "-m",
|
|
400
|
-
]);
|
|
401
|
-
const unsupportedTreeOptions = /^(?:--all|--include|--interactive|--only|--patch|--pathspec-from-file|--pathspec-file-nul|-a|-i|-o|-p)$/;
|
|
402
|
-
for (let index = 0; index < arguments_.length; index += 1) {
|
|
403
|
-
const argument = arguments_[index]!;
|
|
404
|
-
if (argument === "--") {
|
|
405
|
-
if (index !== arguments_.length - 1) throw new Error("commit pathspecs are unsupported by the transaction runner");
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
if (argument === "--dry-run") throw new Error("commit --dry-run does not create a transaction");
|
|
409
|
-
if (unsupportedTreeOptions.test(argument) || unsupportedTreeOptions.test(argument.split("=")[0]!)) throw new Error(`unsupported commit tree semantics: ${argument}`);
|
|
410
|
-
if (argument === "-S" || argument === "--gpg-sign") continue;
|
|
411
|
-
if (/^-S.+/.test(argument) || argument.startsWith("--gpg-sign=")) continue;
|
|
412
|
-
if (/^-[^-]+$/.test(argument) && argument.length > 2) {
|
|
413
|
-
const flags = argument.slice(1);
|
|
414
|
-
if (/[^emnsqv]/.test(flags)) throw new Error(`unsupported combined commit option: ${argument}`);
|
|
415
|
-
if (flags.includes("m")) {
|
|
416
|
-
index += 1;
|
|
417
|
-
if (arguments_[index] === undefined) throw new Error("commit message option is missing its value");
|
|
418
|
-
}
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
if (booleanOptions.has(argument)) continue;
|
|
422
|
-
if ([...valueOptions].some((option) => argument.startsWith(`${option}=`))) continue;
|
|
423
|
-
if (valueOptions.has(argument)) {
|
|
424
|
-
index += 1;
|
|
425
|
-
if (arguments_[index] === undefined) throw new Error(`commit option ${argument} is missing its value`);
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
if (!argument.startsWith("-")) throw new Error("commit pathspecs are unsupported by the transaction runner");
|
|
429
|
-
throw new Error(`unsupported commit option: ${argument}`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
function shellQuote(value: string): string {
|
|
434
|
-
if (process.platform === "win32") return `\"${value.replaceAll("\"", "\\\"")}\"`;
|
|
435
|
-
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function invocationBody(invocation: CommitTransactionInvocation): Record<string, unknown> {
|
|
439
|
-
return {
|
|
440
|
-
schema: invocation.schema,
|
|
441
|
-
transaction_id: invocation.transactionId,
|
|
442
|
-
command: invocation.command,
|
|
443
|
-
command_hash: invocation.commandHash,
|
|
444
|
-
cwd: invocation.cwd,
|
|
445
|
-
arguments: invocation.arguments,
|
|
446
|
-
authorization: {
|
|
447
|
-
lineage_id: invocation.authorization.lineageId,
|
|
448
|
-
store_revision: invocation.authorization.storeRevision,
|
|
449
|
-
fingerprint: invocation.authorization.fingerprint,
|
|
450
|
-
intended_tree: invocation.authorization.intendedTree,
|
|
451
|
-
},
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export function encodeCommitTransactionInvocation(invocation: CommitTransactionInvocation): string {
|
|
456
|
-
return Buffer.from(canonicalJson(invocationBody(invocation)), "utf8").toString("base64url");
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
export function decodeCommitTransactionInvocation(encoded: string): CommitTransactionInvocation {
|
|
460
|
-
let value: unknown;
|
|
461
|
-
try { value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); }
|
|
462
|
-
catch { throw new Error("commit transaction invocation is malformed"); }
|
|
463
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction invocation is invalid");
|
|
464
|
-
const body = value as Record<string, unknown>;
|
|
465
|
-
const authorization = body.authorization;
|
|
466
|
-
if (body.schema !== INVOCATION_SCHEMA || typeof body.transaction_id !== "string" || typeof body.command !== "string" || typeof body.command_hash !== "string" || typeof body.cwd !== "string" || !isStringArray(body.arguments) || typeof authorization !== "object" || authorization === null || Array.isArray(authorization)) throw new Error("commit transaction invocation is incompatible");
|
|
467
|
-
const native = authorization as Record<string, unknown>;
|
|
468
|
-
for (const field of ["lineage_id", "store_revision", "fingerprint", "intended_tree"]) if (typeof native[field] !== "string" || (native[field] as string).length === 0) throw new Error(`commit transaction authorization ${field} is invalid`);
|
|
469
|
-
if (sha256(body.command) !== body.command_hash) throw new Error("commit transaction command hash is invalid");
|
|
470
|
-
assertSafeCommitArguments(body.arguments);
|
|
471
|
-
return {
|
|
472
|
-
schema: INVOCATION_SCHEMA,
|
|
473
|
-
transactionId: body.transaction_id,
|
|
474
|
-
command: body.command,
|
|
475
|
-
commandHash: body.command_hash,
|
|
476
|
-
cwd: body.cwd,
|
|
477
|
-
arguments: body.arguments,
|
|
478
|
-
authorization: {
|
|
479
|
-
lineageId: native.lineage_id as string,
|
|
480
|
-
storeRevision: native.store_revision as string,
|
|
481
|
-
fingerprint: native.fingerprint as string,
|
|
482
|
-
intendedTree: native.intended_tree as string,
|
|
483
|
-
},
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
export function prepareCommitTransactionInvocation(input: {
|
|
488
|
-
command: string;
|
|
489
|
-
cwd: string;
|
|
490
|
-
arguments: readonly string[];
|
|
491
|
-
authorization: CommitTransactionAuthorization;
|
|
492
|
-
}): CommitTransactionInvocation {
|
|
493
|
-
assertSafeCommitArguments(input.arguments);
|
|
494
|
-
const binding = repositoryBinding(input.cwd);
|
|
495
|
-
const head = resolveHead(binding.root);
|
|
496
|
-
const currentTree = git(binding.root, ["write-tree"]);
|
|
497
|
-
if (currentTree !== input.authorization.intendedTree) throw new Error("commit transaction pre-hook index no longer matches its controller authorization");
|
|
498
|
-
let transactionId = randomUUID();
|
|
499
|
-
const active = readRecord(binding.activePath);
|
|
500
|
-
const commandHash = sha256(input.command);
|
|
501
|
-
if (active !== undefined && active.command_hash === commandHash && active.original_head === head) transactionId = active.transaction_id;
|
|
502
|
-
return {
|
|
503
|
-
schema: INVOCATION_SCHEMA,
|
|
504
|
-
transactionId,
|
|
505
|
-
command: input.command,
|
|
506
|
-
commandHash,
|
|
507
|
-
cwd: binding.root,
|
|
508
|
-
arguments: [...input.arguments],
|
|
509
|
-
authorization: { ...input.authorization },
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
export function commitTransactionRunnerPath(): string {
|
|
514
|
-
return fileURLToPath(new URL("../scripts/run-git-commit-transaction.mjs", import.meta.url));
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
export function buildCommitTransactionShellCommand(invocation: CommitTransactionInvocation): string {
|
|
518
|
-
return [
|
|
519
|
-
shellQuote(process.execPath),
|
|
520
|
-
shellQuote(commitTransactionRunnerPath()),
|
|
521
|
-
"run",
|
|
522
|
-
shellQuote(encodeCommitTransactionInvocation(invocation)),
|
|
523
|
-
].join(" ");
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function runProcess(file: string, arguments_: readonly string[], cwd: string, signal?: AbortSignal): Promise<ProcessResult> {
|
|
527
|
-
return new Promise((resolvePromise, reject) => {
|
|
528
|
-
const child = spawn(file, [...arguments_], { cwd, env: process.env, stdio: "inherit", windowsHide: true, signal });
|
|
529
|
-
child.once("error", reject);
|
|
530
|
-
child.once("close", (code, processSignal) => resolvePromise({ code: code ?? 1, signal: processSignal }));
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function assertionPayload(binding: RepositoryBinding, record: CommitTransactionRecord): string {
|
|
535
|
-
return Buffer.from(canonicalJson({
|
|
536
|
-
schema: INDEX_ASSERTION_SCHEMA,
|
|
537
|
-
cwd: binding.root,
|
|
538
|
-
transaction_id: record.transaction_id,
|
|
539
|
-
authorized_tree: record.authorized_tree,
|
|
540
|
-
}), "utf8").toString("base64url");
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function capturePayload(binding: RepositoryBinding, record: CommitTransactionRecord): string {
|
|
544
|
-
return Buffer.from(canonicalJson({
|
|
545
|
-
schema: COMMIT_CAPTURE_SCHEMA,
|
|
546
|
-
cwd: binding.root,
|
|
547
|
-
transaction_id: record.transaction_id,
|
|
548
|
-
authorized_tree: record.authorized_tree,
|
|
549
|
-
}), "utf8").toString("base64url");
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function writeHook(path: string, lines: readonly string[]): void {
|
|
553
|
-
writeFileSync(path, `#!/bin/sh\nset -eu\n${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o700 });
|
|
554
|
-
chmodSync(path, 0o700);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
function createHookProxy(binding: RepositoryBinding, record: CommitTransactionRecord, runnerPath: string): string {
|
|
558
|
-
if (record.authorized_tree === undefined) throw new Error("commit transaction cannot create hooks before native authorization");
|
|
559
|
-
const originalHooks = absoluteGitPath(binding.root, "hooks");
|
|
560
|
-
const proxy = join(binding.stateDir, `hooks-${record.transaction_id}`);
|
|
561
|
-
rmSync(proxy, { recursive: true, force: true });
|
|
562
|
-
mkdirSync(proxy, { recursive: true, mode: 0o700 });
|
|
563
|
-
const assertion = `${shellQuote(process.execPath)} ${shellQuote(runnerPath)} assert-index ${shellQuote(assertionPayload(binding, record))}`;
|
|
564
|
-
const capture = `${shellQuote(process.execPath)} ${shellQuote(runnerPath)} capture-commit ${shellQuote(capturePayload(binding, record))}`;
|
|
565
|
-
writeHook(join(proxy, "pre-commit"), [`exec ${assertion}`]);
|
|
566
|
-
for (const name of ["prepare-commit-msg", "commit-msg"]) {
|
|
567
|
-
const original = join(originalHooks, name);
|
|
568
|
-
const lines = existsSync(original) && (statSync(original).mode & 0o111) !== 0
|
|
569
|
-
? [`${shellQuote(original)} \"$@\"`, `exec ${assertion}`]
|
|
570
|
-
: [`exec ${assertion}`];
|
|
571
|
-
writeHook(join(proxy, name), lines);
|
|
572
|
-
}
|
|
573
|
-
const postCommit = join(originalHooks, "post-commit");
|
|
574
|
-
writeHook(join(proxy, "post-commit"), [capture, ...(existsSync(postCommit) && (statSync(postCommit).mode & 0o111) !== 0 ? [`exec ${shellQuote(postCommit)} \"$@\"`] : [])]);
|
|
575
|
-
const postRewrite = join(originalHooks, "post-rewrite");
|
|
576
|
-
if (existsSync(postRewrite) && (statSync(postRewrite).mode & 0o111) !== 0) writeHook(join(proxy, "post-rewrite"), [`exec ${shellQuote(postRewrite)} \"$@\"`]);
|
|
577
|
-
return proxy;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function nativeResultDocument(result: NativeValidateResult): Record<string, unknown> {
|
|
581
|
-
return {
|
|
582
|
-
allowed: result.allowed,
|
|
583
|
-
result: result.result,
|
|
584
|
-
action: result.action,
|
|
585
|
-
reason: result.reason,
|
|
586
|
-
context: result.gateContext.raw,
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function validateNativeTree(result: NativeValidateResult, lineageId: string, tree: string): void {
|
|
591
|
-
if (!result.allowed || result.result !== "allow") throw new Error(`native pre-commit validation denied the post-hook tree: ${result.result}; ${result.action}; ${result.reason}`);
|
|
592
|
-
if (result.gateContext.lineageId !== lineageId) throw new Error("native pre-commit validation returned a different lineage");
|
|
593
|
-
if (result.gateContext.raw.gate !== "pre-commit") throw new Error("native pre-commit validation returned a different gate");
|
|
594
|
-
if (result.gateContext.raw.candidate_tree !== tree) throw new Error("native pre-commit validation did not authorize the exact post-hook tree");
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function createRecord(binding: RepositoryBinding, invocation: CommitTransactionInvocation, now: () => Date): CommitTransactionRecord {
|
|
598
|
-
const head = resolveHead(binding.root);
|
|
599
|
-
const tree = git(binding.root, ["write-tree"]);
|
|
600
|
-
if (tree !== invocation.authorization.intendedTree) throw new Error("commit transaction index changed after controller authorization");
|
|
601
|
-
const timestamp = now().toISOString();
|
|
602
|
-
return writeRecord(binding.activePath, {
|
|
603
|
-
schema: TRANSACTION_SCHEMA,
|
|
604
|
-
transaction_id: invocation.transactionId,
|
|
605
|
-
repository_id: binding.repositoryId,
|
|
606
|
-
repository_root: binding.root,
|
|
607
|
-
common_directory: binding.commonDir,
|
|
608
|
-
git_directory: binding.gitDir,
|
|
609
|
-
command: invocation.command,
|
|
610
|
-
command_hash: invocation.commandHash,
|
|
611
|
-
arguments: [...invocation.arguments],
|
|
612
|
-
original_head: head,
|
|
613
|
-
original_head_tree: head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]),
|
|
614
|
-
original_index_tree: tree,
|
|
615
|
-
original_index_hash: indexFingerprint(binding.root),
|
|
616
|
-
authorized_pre_hook_tree: invocation.authorization.intendedTree,
|
|
617
|
-
state: COMMIT_TRANSACTION_STATE.PREPARED,
|
|
618
|
-
created_at: timestamp,
|
|
619
|
-
updated_at: timestamp,
|
|
620
|
-
hook_runs: 0,
|
|
621
|
-
invocation_ids: [invocation.transactionId],
|
|
622
|
-
lineage_history: [invocation.authorization.lineageId],
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function assertInvocationMatches(binding: RepositoryBinding, record: CommitTransactionRecord, invocation: CommitTransactionInvocation): void {
|
|
627
|
-
if (record.repository_id !== binding.repositoryId || record.repository_root !== binding.root || record.common_directory !== binding.commonDir || record.git_directory !== binding.gitDir) throw new Error("commit transaction repository identity changed");
|
|
628
|
-
if (record.transaction_id !== invocation.transactionId || record.command_hash !== invocation.commandHash || record.command !== invocation.command || canonicalJson(record.arguments) !== canonicalJson(invocation.arguments)) throw new Error("commit transaction exact retry does not match the durable command intent");
|
|
629
|
-
if (resolveHead(binding.root) !== record.original_head) throw new Error("commit transaction HEAD changed before reconciliation");
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function recoverCompletedCommit(binding: RepositoryBinding, record: CommitTransactionRecord, now: () => Date): CommitTransactionResult | undefined {
|
|
633
|
-
if (record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING && record.state !== COMMIT_TRANSACTION_STATE.COMMITTED) return undefined;
|
|
634
|
-
const head = resolveHead(binding.root);
|
|
635
|
-
if (head === record.original_head) return undefined;
|
|
636
|
-
if (head === undefined) {
|
|
637
|
-
transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { error: "HEAD disappeared during commit transaction" }, now);
|
|
638
|
-
throw new Error("commit transaction incident: HEAD disappeared during commit; publication remains blocked");
|
|
639
|
-
}
|
|
640
|
-
const tree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
|
|
641
|
-
if (record.git_created_head === undefined || record.git_created_tree === undefined || head !== record.git_created_head || tree !== record.git_created_tree || tree !== record.authorized_tree) {
|
|
642
|
-
transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: tree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now);
|
|
643
|
-
throw new Error("commit transaction incident: HEAD identity differs from the exact Git-created authorized commit; push, PR, and release remain blocked");
|
|
644
|
-
}
|
|
645
|
-
const committed = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMITTED, { committed_head: head, committed_tree: tree }, now);
|
|
646
|
-
archive(binding, committed);
|
|
647
|
-
return { transactionId: record.transaction_id, status: "recovered", head, tree };
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function appendInvocation(record: CommitTransactionRecord, invocation: CommitTransactionInvocation): Partial<CommitTransactionRecordBody> {
|
|
651
|
-
return {
|
|
652
|
-
invocation_ids: [...new Set([...record.invocation_ids, invocation.transactionId])],
|
|
653
|
-
lineage_history: [...new Set([...record.lineage_history, invocation.authorization.lineageId])],
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
export async function runGitCommitTransaction(
|
|
658
|
-
invocation: CommitTransactionInvocation,
|
|
659
|
-
dependencies: CommitTransactionDependencies = {},
|
|
660
|
-
): Promise<CommitTransactionResult> {
|
|
661
|
-
assertSafeCommitArguments(invocation.arguments);
|
|
662
|
-
if (sha256(invocation.command) !== invocation.commandHash) throw new Error("commit transaction command identity is invalid");
|
|
663
|
-
const now = dependencies.now ?? (() => new Date());
|
|
664
|
-
const binding = repositoryBinding(invocation.cwd);
|
|
665
|
-
const releaseLock = acquireLock(binding, invocation.transactionId, now);
|
|
666
|
-
let record: CommitTransactionRecord | undefined;
|
|
667
|
-
try {
|
|
668
|
-
record = readRecord(binding.activePath);
|
|
669
|
-
if (record !== undefined) {
|
|
670
|
-
assertInvocationMatches(binding, record, invocation);
|
|
671
|
-
const recovered = recoverCompletedCommit(binding, record, now);
|
|
672
|
-
if (recovered !== undefined) return recovered;
|
|
673
|
-
if ([COMMIT_TRANSACTION_STATE.HOOK_FAILED, COMMIT_TRANSACTION_STATE.VALIDATION_FAILED, COMMIT_TRANSACTION_STATE.COMMIT_FAILED, COMMIT_TRANSACTION_STATE.INTERRUPTED, COMMIT_TRANSACTION_STATE.INCIDENT, COMMIT_TRANSACTION_STATE.HOOK_RUNNING, COMMIT_TRANSACTION_STATE.COMMIT_RUNNING].includes(record.state as never)) {
|
|
674
|
-
throw new Error(`commit transaction ${record.transaction_id} requires explicit recovery from state ${record.state}; no Git state was rolled back`);
|
|
675
|
-
}
|
|
676
|
-
if (record.state !== COMMIT_TRANSACTION_STATE.AWAITING_REVIEW && record.state !== COMMIT_TRANSACTION_STATE.AWAITING_NATIVE && record.state !== COMMIT_TRANSACTION_STATE.VALIDATED && record.state !== COMMIT_TRANSACTION_STATE.PREPARED) throw new Error(`commit transaction cannot resume from ${record.state}`);
|
|
677
|
-
record = transition(binding, record, record.state, appendInvocation(record, invocation), now);
|
|
678
|
-
} else {
|
|
679
|
-
record = createRecord(binding, invocation, now);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
const noVerify = invocation.arguments.includes("--no-verify") || invocation.arguments.some((argument) => /^-[^-]*n/.test(argument));
|
|
683
|
-
if (record.state === COMMIT_TRANSACTION_STATE.PREPARED) {
|
|
684
|
-
if (!noVerify) {
|
|
685
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.HOOK_RUNNING, { hook_runs: record.hook_runs + 1 }, now);
|
|
686
|
-
const hook = await runProcess("git", ["hook", "run", "--ignore-missing", "pre-commit"], binding.root, dependencies.signal);
|
|
687
|
-
if (hook.code !== 0 || hook.signal !== null) {
|
|
688
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.HOOK_FAILED, { error: `pre-commit hook failed with ${hook.signal ?? `exit ${hook.code}`}` }, now);
|
|
689
|
-
throw new Error(`pre-commit hook failed; transaction ${record.transaction_id} created no commit and requires explicit recovery`);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
const postHookTree = git(binding.root, ["write-tree"]);
|
|
693
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.AWAITING_NATIVE, {
|
|
694
|
-
post_hook_tree: postHookTree,
|
|
695
|
-
post_hook_index_hash: indexFingerprint(binding.root),
|
|
696
|
-
}, now);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
if (record.state === COMMIT_TRANSACTION_STATE.AWAITING_REVIEW) {
|
|
700
|
-
const tree = git(binding.root, ["write-tree"]);
|
|
701
|
-
if (tree !== record.post_hook_tree || indexFingerprint(binding.root) !== record.post_hook_index_hash) throw new Error("post-hook index changed while the commit transaction awaited review");
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
if (record.state === COMMIT_TRANSACTION_STATE.AWAITING_NATIVE || record.state === COMMIT_TRANSACTION_STATE.AWAITING_REVIEW || record.state === COMMIT_TRANSACTION_STATE.VALIDATED) {
|
|
705
|
-
const currentTree = git(binding.root, ["write-tree"]);
|
|
706
|
-
if (record.post_hook_tree !== currentTree) throw new Error("commit transaction post-hook tree changed before native validation");
|
|
707
|
-
if (currentTree !== invocation.authorization.intendedTree) {
|
|
708
|
-
const mutation = `pre-commit hook mutated the staged candidate: post-hook tree ${currentTree} is not the authorized tree ${invocation.authorization.intendedTree}; the content-bound receipt no longer covers this index; normalize sources and re-run review explicitly, or make the hook convergent, then retry the exact command; transaction ${record.transaction_id} created no commit`;
|
|
709
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.AWAITING_REVIEW, { error: mutation }, now);
|
|
710
|
-
throw new Error(mutation);
|
|
711
|
-
}
|
|
712
|
-
const nativeReviewCli = dependencies.nativeReviewCli ?? createNativeReviewCli();
|
|
713
|
-
let nativeResult: NativeValidateResult;
|
|
714
|
-
try {
|
|
715
|
-
nativeResult = await nativeReviewCli.validate({ cwd: binding.root, gate: "pre-commit", lineageId: invocation.authorization.lineageId, ...(dependencies.signal === undefined ? {} : { signal: dependencies.signal }) });
|
|
716
|
-
} catch (error) {
|
|
717
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.VALIDATION_FAILED, { error: error instanceof Error ? error.message : String(error) }, now);
|
|
718
|
-
throw error;
|
|
719
|
-
}
|
|
720
|
-
if (!nativeResult.allowed || nativeResult.result !== "allow") {
|
|
721
|
-
const state = nativeResult.result === "scope-changed" ? COMMIT_TRANSACTION_STATE.AWAITING_REVIEW : COMMIT_TRANSACTION_STATE.VALIDATION_FAILED;
|
|
722
|
-
record = transition(binding, record, state, { native_result: nativeResultDocument(nativeResult), error: nativeResult.reason }, now);
|
|
723
|
-
throw new Error(`native pre-commit validation denied the post-hook tree: ${nativeResult.result}; ${nativeResult.action}; ${nativeResult.reason}`);
|
|
724
|
-
}
|
|
725
|
-
try { validateNativeTree(nativeResult, invocation.authorization.lineageId, currentTree); }
|
|
726
|
-
catch (error) {
|
|
727
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { native_result: nativeResultDocument(nativeResult), error: error instanceof Error ? error.message : String(error) }, now);
|
|
728
|
-
throw error;
|
|
729
|
-
}
|
|
730
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.VALIDATED, {
|
|
731
|
-
authorized_tree: currentTree,
|
|
732
|
-
authority_revision: nativeResult.gateContext.storeRevision,
|
|
733
|
-
gate_context_hash: sha256(canonicalJson(nativeResult.gateContext.raw)),
|
|
734
|
-
native_result: nativeResultDocument(nativeResult),
|
|
735
|
-
error: undefined,
|
|
736
|
-
}, now);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
const runnerPath = dependencies.runnerPath ?? commitTransactionRunnerPath();
|
|
740
|
-
const proxy = createHookProxy(binding, record, runnerPath);
|
|
741
|
-
dependencies.signal?.throwIfAborted();
|
|
742
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMIT_RUNNING, {}, now);
|
|
743
|
-
const commit = await runProcess("git", ["-c", `core.hooksPath=${proxy}`, "commit", ...invocation.arguments], binding.root);
|
|
744
|
-
if (dependencies.failpoint === "after-commit-before-proof") throw new Error("commit transaction test interruption after Git returned");
|
|
745
|
-
record = readRecord(binding.activePath) ?? record;
|
|
746
|
-
const head = resolveHead(binding.root);
|
|
747
|
-
const headTree = head === undefined ? undefined : git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
|
|
748
|
-
const headChanged = head !== record.original_head;
|
|
749
|
-
if (headChanged && head === record.git_created_head && headTree === record.git_created_tree && headTree === record.authorized_tree) {
|
|
750
|
-
const committed = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMITTED, { committed_head: head, committed_tree: headTree, ...(commit.code === 0 && commit.signal === null ? {} : { error: `Git returned ${commit.signal ?? `exit ${commit.code}`} after creating the authorized commit` }) }, now);
|
|
751
|
-
archive(binding, committed);
|
|
752
|
-
return { transactionId: record.transaction_id, status: "committed", head: head!, tree: headTree! };
|
|
753
|
-
}
|
|
754
|
-
if (headChanged) {
|
|
755
|
-
transition(binding, record, COMMIT_TRANSACTION_STATE.INCIDENT, { committed_head: head, committed_tree: headTree, error: "HEAD identity differs from the exact Git-created authorized commit" }, now);
|
|
756
|
-
throw new Error("commit transaction incident: HEAD identity changed after Git created the authorized commit; publication remains blocked");
|
|
757
|
-
}
|
|
758
|
-
record = transition(binding, record, COMMIT_TRANSACTION_STATE.COMMIT_FAILED, { error: `Git commit failed with ${commit.signal ?? `exit ${commit.code}`}` }, now);
|
|
759
|
-
throw new Error(`Git commit failed; transaction ${record.transaction_id} created no commit and requires explicit recovery`);
|
|
760
|
-
} catch (error) {
|
|
761
|
-
if (record !== undefined && dependencies.signal?.aborted === true && existsSync(binding.activePath)) {
|
|
762
|
-
try {
|
|
763
|
-
const active = readRecord(binding.activePath) ?? record;
|
|
764
|
-
if (resolveHead(binding.root) === active.original_head) transition(binding, active, COMMIT_TRANSACTION_STATE.INTERRUPTED, { error: "commit transaction was cancelled" }, now);
|
|
765
|
-
} catch { /* retain the earlier durable state */ }
|
|
766
|
-
}
|
|
767
|
-
throw error;
|
|
768
|
-
} finally {
|
|
769
|
-
releaseLock();
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
export function assertCommitTransactionIndex(encoded: string): void {
|
|
774
|
-
let value: unknown;
|
|
775
|
-
try { value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); }
|
|
776
|
-
catch { throw new Error("commit transaction index assertion is malformed"); }
|
|
777
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction index assertion is invalid");
|
|
778
|
-
const body = value as Record<string, unknown>;
|
|
779
|
-
if (body.schema !== INDEX_ASSERTION_SCHEMA || typeof body.cwd !== "string" || typeof body.transaction_id !== "string" || typeof body.authorized_tree !== "string") throw new Error("commit transaction index assertion is incompatible");
|
|
780
|
-
const binding = repositoryBinding(body.cwd);
|
|
781
|
-
const record = readRecord(binding.activePath);
|
|
782
|
-
if (record === undefined || record.transaction_id !== body.transaction_id || record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING || record.authorized_tree !== body.authorized_tree) throw new Error("commit transaction index assertion has no matching active authorization");
|
|
783
|
-
if (git(binding.root, ["write-tree"]) !== body.authorized_tree) throw new Error("Git hook changed the index after native pre-commit authorization");
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
export function captureCommitTransactionHead(encoded: string): void {
|
|
787
|
-
let value: unknown;
|
|
788
|
-
try { value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); }
|
|
789
|
-
catch { throw new Error("commit transaction capture is malformed"); }
|
|
790
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("commit transaction capture is invalid");
|
|
791
|
-
const body = value as Record<string, unknown>;
|
|
792
|
-
if (body.schema !== COMMIT_CAPTURE_SCHEMA || typeof body.cwd !== "string" || typeof body.transaction_id !== "string" || typeof body.authorized_tree !== "string") throw new Error("commit transaction capture is incompatible");
|
|
793
|
-
const binding = repositoryBinding(body.cwd);
|
|
794
|
-
const record = readRecord(binding.activePath);
|
|
795
|
-
if (record === undefined || record.transaction_id !== body.transaction_id || record.state !== COMMIT_TRANSACTION_STATE.COMMIT_RUNNING || record.authorized_tree !== body.authorized_tree) throw new Error("commit transaction capture has no matching active authorization");
|
|
796
|
-
const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
|
|
797
|
-
const tree = git(binding.root, ["rev-parse", "--verify", "HEAD^{tree}"]);
|
|
798
|
-
if (head === record.original_head || tree !== record.authorized_tree) throw new Error("commit transaction capture does not match a new authorized commit");
|
|
799
|
-
if (record.git_created_head !== undefined && (record.git_created_head !== head || record.git_created_tree !== tree)) throw new Error("commit transaction capture conflicts with the recorded Git commit");
|
|
800
|
-
transition(binding, record, COMMIT_TRANSACTION_STATE.COMMIT_RUNNING, { git_created_head: head, git_created_tree: tree });
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
export function inspectCommitTransaction(cwd: string): CommitTransactionInspection {
|
|
804
|
-
let binding: RepositoryBinding;
|
|
805
|
-
try { binding = repositoryBinding(cwd); }
|
|
806
|
-
catch (error) { return { status: "corrupted", reason: error instanceof Error ? error.message : String(error) }; }
|
|
807
|
-
try {
|
|
808
|
-
const record = readRecord(binding.activePath);
|
|
809
|
-
return record === undefined ? { status: "clean" } : { status: "active", record };
|
|
810
|
-
} catch (error) {
|
|
811
|
-
return { status: "corrupted", reason: error instanceof Error ? error.message : String(error) };
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
export function reconcileCommitTransaction(cwd: string): CommitTransactionInspection {
|
|
816
|
-
const binding = repositoryBinding(cwd);
|
|
817
|
-
const active = readRecord(binding.activePath);
|
|
818
|
-
if (active === undefined) return { status: "clean" };
|
|
819
|
-
const now = () => new Date();
|
|
820
|
-
const releaseLock = acquireLock(binding, active.transaction_id, now);
|
|
821
|
-
try {
|
|
822
|
-
const record = readRecord(binding.activePath);
|
|
823
|
-
if (record === undefined) return { status: "clean" };
|
|
824
|
-
const recovered = recoverCompletedCommit(binding, record, now);
|
|
825
|
-
return recovered === undefined ? { status: "active", record } : { status: "clean" };
|
|
826
|
-
} finally { releaseLock(); }
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
export function assertNoUnresolvedCommitTransaction(cwd: string): void {
|
|
830
|
-
const inspection = inspectCommitTransaction(cwd);
|
|
831
|
-
if (inspection.status === "clean") return;
|
|
832
|
-
if (inspection.status === "corrupted") throw new Error(`commit transaction recovery state is corrupted: ${inspection.reason}`);
|
|
833
|
-
throw new Error(`commit transaction ${inspection.record!.transaction_id} is unresolved in state ${inspection.record!.state}; publication is blocked until deterministic recovery completes`);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
export function abandonCommitTransaction(cwd: string): CommitTransactionRecord {
|
|
837
|
-
const binding = repositoryBinding(cwd);
|
|
838
|
-
const now = () => new Date();
|
|
839
|
-
const active = readRecord(binding.activePath);
|
|
840
|
-
if (active === undefined) throw new Error("no active commit transaction exists");
|
|
841
|
-
const releaseLock = acquireLock(binding, active.transaction_id, now);
|
|
842
|
-
try {
|
|
843
|
-
const record = readRecord(binding.activePath);
|
|
844
|
-
if (record === undefined) throw new Error("active commit transaction disappeared during recovery");
|
|
845
|
-
if (resolveHead(binding.root) !== record.original_head) throw new Error("cannot abandon a commit transaction after HEAD changed; reconcile the committed tree instead");
|
|
846
|
-
const abandoned = transition(binding, record, COMMIT_TRANSACTION_STATE.ABANDONED, { error: "explicitly abandoned without changing HEAD or index" }, now);
|
|
847
|
-
return archive(binding, abandoned);
|
|
848
|
-
} finally { releaseLock(); }
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
export function verifyCommitTransactionResult(cwd: string, transactionId: string): CommitTransactionResult {
|
|
852
|
-
const binding = repositoryBinding(cwd);
|
|
853
|
-
const active = readRecord(binding.activePath);
|
|
854
|
-
if (active?.transaction_id === transactionId) throw new Error(`commit transaction ${transactionId} remains unresolved in state ${active.state}`);
|
|
855
|
-
const history = readRecord(join(binding.historyDir, `${transactionId}.json`));
|
|
856
|
-
if (history === undefined || history.state !== COMMIT_TRANSACTION_STATE.COMMITTED || history.committed_head === undefined || history.committed_tree === undefined || history.git_created_head !== history.committed_head || history.git_created_tree !== history.committed_tree || history.authorized_tree !== history.committed_tree) throw new Error(`commit transaction ${transactionId} has no durable verified commit result`);
|
|
857
|
-
const head = git(binding.root, ["rev-parse", "--verify", "HEAD"]);
|
|
858
|
-
const tree = git(binding.root, ["rev-parse", "--verify", `${history.committed_head}^{tree}`]);
|
|
859
|
-
if (head !== history.committed_head || tree !== history.committed_tree) throw new Error(`commit transaction ${transactionId} HEAD proof changed before tool_result reconciliation`);
|
|
860
|
-
return { transactionId, status: "committed", head, tree };
|
|
861
|
-
}
|