run402 4.43.0 → 4.45.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/git-remote-run402.mjs +53 -8
- package/lib/repos.mjs +124 -13
- package/package.json +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +10 -0
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +14 -1
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +71 -0
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +150 -3
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/index.d.ts +2 -2
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +1 -1
- package/sdk/dist/node/index.js.map +1 -1
|
@@ -200,6 +200,146 @@ export function evolveRetentionRoots(previous, options) {
|
|
|
200
200
|
fail("REF_STATE_LIMIT_EXCEEDED", `${roots.length} retention roots exceed the ${GITVAULT_MAX_RETENTION_ROOT_ENTRIES} bound`, "evolving retention roots", { roots: roots.length });
|
|
201
201
|
return roots;
|
|
202
202
|
}
|
|
203
|
+
// ─── clone-installs-retained-refs (D1-D5): local refs/r402/retain/* bookkeeping ──
|
|
204
|
+
/**
|
|
205
|
+
* The client-local, protocol-owned namespace (design D4). Distinct from the
|
|
206
|
+
* VAULT-side `refs/run402/*` (e.g. `GITVAULT_DEPLOY_REF`) that rides the wire
|
|
207
|
+
* as part of `ref_state` — `refs/r402/*` never rides the wire at all; it is
|
|
208
|
+
* written directly into the local `.git` by the materializer and reconciled
|
|
209
|
+
* on every later fetch/fsck. A push naming any ref under it is refused by the
|
|
210
|
+
* remote helper before a transaction is ever built (see
|
|
211
|
+
* `git-remote-run402.mjs`'s `partitionProtectedRefPushes`) — this constant is
|
|
212
|
+
* the ONE place the namespace string is spelled, shared by both sides.
|
|
213
|
+
*/
|
|
214
|
+
export const GITVAULT_R402_REF_NAMESPACE = "refs/r402/";
|
|
215
|
+
/** Where a retained (branch-unreachable) deploy-capture tip gets its local ref (design D1/D2). */
|
|
216
|
+
export const GITVAULT_RETAIN_REF_PREFIX = `${GITVAULT_R402_REF_NAMESPACE}retain/`;
|
|
217
|
+
/**
|
|
218
|
+
* D1's ref-identity choice, recorded here because the design doc's own
|
|
219
|
+
* assumption did not hold: `GitvaultRetentionRoot` carries no per-capture
|
|
220
|
+
* stable id (only `{ref, oid, dropped_at_generation}`) — the capture id that
|
|
221
|
+
* DOES exist (`GitvaultHead.capture_binding.capture_id`) lives on the head
|
|
222
|
+
* that INTRODUCED a tip onto a canonical ref, not on the retention-root entry
|
|
223
|
+
* recording its later displacement, and correlating the two would require
|
|
224
|
+
* walking the chain further back than materialization already reads (D2
|
|
225
|
+
* forbids new reads here). The commit oid itself is already in hand, is
|
|
226
|
+
* content-addressed (so it is exactly as stable as a capture id — neither
|
|
227
|
+
* ever changes for the same history), and needs no correlation at all — the
|
|
228
|
+
* ref name IS the tip's own identity.
|
|
229
|
+
*/
|
|
230
|
+
export function gitvaultRetainedRefName(oid) {
|
|
231
|
+
return `${GITVAULT_RETAIN_REF_PREFIX}${oid}`;
|
|
232
|
+
}
|
|
233
|
+
/** `git for-each-ref --format='%(objectname) %(refname)' <prefix>` → `Map<refname, oid>`. Namespace-scoped by construction (the prefix argv). */
|
|
234
|
+
async function listRefsUnderPrefix(repoDir, prefix) {
|
|
235
|
+
const r = await hardenedGit(repoDir, ["for-each-ref", "--format=%(objectname) %(refname)", prefix]);
|
|
236
|
+
const out = new Map();
|
|
237
|
+
for (const raw of r.lines()) {
|
|
238
|
+
const line = raw.trim();
|
|
239
|
+
if (!line)
|
|
240
|
+
continue;
|
|
241
|
+
const sp = line.indexOf(" ");
|
|
242
|
+
if (sp === -1)
|
|
243
|
+
continue;
|
|
244
|
+
out.set(line.slice(sp + 1), line.slice(0, sp));
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* D2: install/remove local `refs/r402/retain/<oid>` refs so every retained
|
|
250
|
+
* (branch-unreachable) tip the vault's materialized retention roots name is
|
|
251
|
+
* locally referenced — the git-ecosystem `refs/pull/*` precedent, so a fresh
|
|
252
|
+
* `git fsck` is silent and `git for-each-ref refs/r402/` names what is
|
|
253
|
+
* retained and why (D6).
|
|
254
|
+
*
|
|
255
|
+
* Skips a root tip already reachable from a canonical ref (`state.refs`) or
|
|
256
|
+
* the HEAD target when detached — no redundant refs (D2). Reconciliation is
|
|
257
|
+
* namespace-scoped: only `refs/r402/retain/*` is ever read, written, or
|
|
258
|
+
* deleted; nothing else is touched, even when other bookkeeping under
|
|
259
|
+
* `refs/r402/*` exists.
|
|
260
|
+
*
|
|
261
|
+
* D3 — warn, never fail: driven from a SINGLE try/catch around the whole
|
|
262
|
+
* operation (list existing → compute the desired set → one atomic
|
|
263
|
+
* `update-ref --stdin` transaction for every create/update/delete). Any
|
|
264
|
+
* failure anywhere in that sequence returns a `warning` string and touches
|
|
265
|
+
* nothing further; it never throws, so a clone/fetch/fsck calling this can
|
|
266
|
+
* never fail on it. Called only when `repoDir` is an actual git repository —
|
|
267
|
+
* `repos fsck` addresses a vault by `repo_id`/`project_id` alone as often as
|
|
268
|
+
* by a local checkout, and "no local repo here" is a normal, silent no-op,
|
|
269
|
+
* never a warning.
|
|
270
|
+
*/
|
|
271
|
+
export async function reconcileRetainedTipRefs(repoDir, state) {
|
|
272
|
+
const empty = (warning = null) => ({ written: [], deleted: [], retained_count: 0, warning });
|
|
273
|
+
let isRepo;
|
|
274
|
+
try {
|
|
275
|
+
const probe = await hardenedGit(repoDir, ["rev-parse", "--git-dir"], { okStatuses: [128] });
|
|
276
|
+
isRepo = probe.status === 0;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return empty(); // no repository here (or it vanished) — nothing to reconcile, not a failure
|
|
280
|
+
}
|
|
281
|
+
if (!isRepo)
|
|
282
|
+
return empty();
|
|
283
|
+
try {
|
|
284
|
+
// Reachability basis = refs git actually WRITES locally on clone/fetch
|
|
285
|
+
// (refs/heads/*, refs/tags/*, plus a detached HEAD). A vault-canonical
|
|
286
|
+
// protocol ref (refs/run402/*) exists only in the vault's ref map — git's
|
|
287
|
+
// clone refspec never materializes it as a local ref, so its tip would
|
|
288
|
+
// dangle locally exactly like a displaced retention root. Its tip
|
|
289
|
+
// therefore joins the candidate set instead of the reachability basis
|
|
290
|
+
// (live-acceptance catch: the current deploy-capture tip dangled).
|
|
291
|
+
const locallyWritten = [];
|
|
292
|
+
const protocolTips = [];
|
|
293
|
+
for (const [ref, oid] of Object.entries(state.refs)) {
|
|
294
|
+
if (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/"))
|
|
295
|
+
locallyWritten.push(oid);
|
|
296
|
+
else
|
|
297
|
+
protocolTips.push(oid);
|
|
298
|
+
}
|
|
299
|
+
const reachableTips = [...new Set(locallyWritten)];
|
|
300
|
+
if (state.head_target.kind === "detached")
|
|
301
|
+
reachableTips.push(state.head_target.oid);
|
|
302
|
+
const candidateOids = [...new Set([...state.roots.map((r) => r.oid), ...protocolTips])].sort();
|
|
303
|
+
const retainedOids = [];
|
|
304
|
+
for (const oid of candidateOids) {
|
|
305
|
+
if (!(await hasObject(repoDir, oid)))
|
|
306
|
+
continue; // not present locally — nothing to reference, not a failure
|
|
307
|
+
let reachable = false;
|
|
308
|
+
for (const tip of reachableTips) {
|
|
309
|
+
if ((await hasObject(repoDir, tip)) && (await isAncestor(repoDir, oid, tip))) {
|
|
310
|
+
reachable = true;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (!reachable)
|
|
315
|
+
retainedOids.push(oid);
|
|
316
|
+
}
|
|
317
|
+
const existing = await listRefsUnderPrefix(repoDir, GITVAULT_RETAIN_REF_PREFIX);
|
|
318
|
+
const desired = new Map(retainedOids.map((oid) => [gitvaultRetainedRefName(oid), oid]));
|
|
319
|
+
const toWrite = [];
|
|
320
|
+
for (const [ref, oid] of desired)
|
|
321
|
+
if (existing.get(ref) !== oid)
|
|
322
|
+
toWrite.push([ref, oid]);
|
|
323
|
+
const toDelete = [...existing.keys()].filter((ref) => !desired.has(ref));
|
|
324
|
+
if (toWrite.length === 0 && toDelete.length === 0) {
|
|
325
|
+
return { written: [], deleted: [], retained_count: retainedOids.length, warning: null };
|
|
326
|
+
}
|
|
327
|
+
// ONE `update-ref --stdin` transaction for every create/update/delete —
|
|
328
|
+
// git applies the whole batch atomically, so a mid-batch failure leaves
|
|
329
|
+
// the namespace exactly as it was before this call (D3's "degrades to
|
|
330
|
+
// exactly today's behavior", not a half-reconciled namespace).
|
|
331
|
+
const lines = [];
|
|
332
|
+
for (const [ref, oid] of toWrite)
|
|
333
|
+
lines.push(`update ${ref} ${oid}\n`);
|
|
334
|
+
for (const ref of toDelete)
|
|
335
|
+
lines.push(`delete ${ref}\n`);
|
|
336
|
+
await hardenedGit(repoDir, ["update-ref", "--stdin"], { input: lines.join("") });
|
|
337
|
+
return { written: toWrite.map(([ref]) => ref), deleted: toDelete, retained_count: retainedOids.length, warning: null };
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
return empty(`refs/r402/retain bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
203
343
|
/** Validate a listing request before it is sent (the request schema, D186). */
|
|
204
344
|
export function validateHeadsListingRequest(request) {
|
|
205
345
|
if (!GITVAULT_HEX16_RE.test(request.after_generation))
|
|
@@ -2186,8 +2326,10 @@ export class GitvaultVault {
|
|
|
2186
2326
|
*/
|
|
2187
2327
|
async restoreObjectsInto(targetRepoDir) {
|
|
2188
2328
|
const newest = await this.materialize();
|
|
2189
|
-
if (!newest.head)
|
|
2190
|
-
|
|
2329
|
+
if (!newest.head) {
|
|
2330
|
+
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: {}, roots: [], head_target: newest.head_target });
|
|
2331
|
+
return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2332
|
+
}
|
|
2191
2333
|
const writerKey = newest.genesis.creator_signing_pubkey;
|
|
2192
2334
|
// walk back to the newest checkpoint-bearing head
|
|
2193
2335
|
const heads = [];
|
|
@@ -2238,7 +2380,12 @@ export class GitvaultVault {
|
|
|
2238
2380
|
if (!(await hasObject(targetRepoDir, t)))
|
|
2239
2381
|
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring gitvault objects", { oid: t });
|
|
2240
2382
|
}
|
|
2241
|
-
|
|
2383
|
+
// clone-installs-retained-refs (D2): every retained tip just restored
|
|
2384
|
+
// above is now present locally — install/reconcile its refs/r402/retain/*
|
|
2385
|
+
// ref so `git fsck` is silent. Runs AFTER coverage verification so a
|
|
2386
|
+
// reconcile never references an object the restore itself failed to land.
|
|
2387
|
+
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
2388
|
+
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2242
2389
|
}
|
|
2243
2390
|
}
|
|
2244
2391
|
/** §4.7 cross-field equality: covers_through agree; the claim set's ordered pack ids/hashes/sizes/total equal the manifest's (shared stored fields only). */
|