@intx/hub-sessions 0.1.2 → 0.2.2

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.
Files changed (87) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +84 -1
  3. package/dist/agent-repo.d.ts +89 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +185 -0
  7. package/dist/asset-service.d.ts +123 -0
  8. package/dist/asset-service.js +349 -0
  9. package/dist/available-skills-stanza.d.ts +21 -0
  10. package/dist/available-skills-stanza.js +32 -0
  11. package/dist/credential-push.d.ts +32 -0
  12. package/dist/credential-push.js +85 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +357 -0
  17. package/dist/hub-session-lookups.d.ts +17 -0
  18. package/dist/hub-session-lookups.js +204 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +122 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1692 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +53 -0
  30. package/dist/repo-store/subscribe-kind.js +179 -0
  31. package/dist/repo-store/types.d.ts +483 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +235 -0
  34. package/dist/session-service.js +997 -0
  35. package/dist/skill-kind.d.ts +41 -0
  36. package/dist/skill-kind.js +288 -0
  37. package/dist/substrate.d.ts +8 -0
  38. package/dist/substrate.js +21 -0
  39. package/dist/workflow-kind.d.ts +21 -0
  40. package/dist/workflow-kind.js +263 -0
  41. package/dist/workflow-run-event-log.d.ts +21 -0
  42. package/dist/workflow-run-event-log.js +51 -0
  43. package/dist/workflow-run-kind.d.ts +326 -0
  44. package/dist/workflow-run-kind.js +2646 -0
  45. package/dist/workflow-run-reader.d.ts +47 -0
  46. package/dist/workflow-run-reader.js +157 -0
  47. package/dist/ws/index.d.ts +3 -0
  48. package/dist/ws/index.js +3 -0
  49. package/dist/ws/sidecar-events.d.ts +134 -0
  50. package/dist/ws/sidecar-events.js +70 -0
  51. package/dist/ws/sidecar-handler.d.ts +184 -0
  52. package/dist/ws/sidecar-handler.js +1603 -0
  53. package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
  54. package/dist/ws/sidecar-token-authenticator.js +24 -0
  55. package/package.json +34 -12
  56. package/src/agent-repo.test.ts +0 -310
  57. package/src/agent-repo.ts +0 -165
  58. package/src/agent-state-kind.test.ts +0 -247
  59. package/src/agent-state-kind.ts +0 -204
  60. package/src/asset-service.test.ts +0 -540
  61. package/src/asset-service.ts +0 -378
  62. package/src/available-skills-stanza.test.ts +0 -87
  63. package/src/available-skills-stanza.ts +0 -47
  64. package/src/credential-push.ts +0 -65
  65. package/src/event-collector-registry.test.ts +0 -73
  66. package/src/event-collector-registry.ts +0 -171
  67. package/src/event-collector.test.ts +0 -1387
  68. package/src/event-collector.ts +0 -424
  69. package/src/hub-session-lookups.ts +0 -206
  70. package/src/hub-session-orchestrator.test.ts +0 -510
  71. package/src/hub-session-orchestrator.ts +0 -213
  72. package/src/index.ts +0 -78
  73. package/src/repo-store/index.ts +0 -15
  74. package/src/repo-store/store.test.ts +0 -1169
  75. package/src/repo-store/store.ts +0 -428
  76. package/src/repo-store/types.ts +0 -253
  77. package/src/session-service.test.ts +0 -895
  78. package/src/session-service.ts +0 -464
  79. package/src/skill-kind.test.ts +0 -599
  80. package/src/skill-kind.ts +0 -350
  81. package/src/ws/index.ts +0 -18
  82. package/src/ws/sidecar-events.test.ts +0 -96
  83. package/src/ws/sidecar-events.ts +0 -231
  84. package/src/ws/sidecar-handler.test.ts +0 -2217
  85. package/src/ws/sidecar-handler.ts +0 -1574
  86. package/tsconfig.json +0 -4
  87. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,1692 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import git, {} from "isomorphic-git";
4
+ import { createSSHSignature } from "@intx/crypto";
5
+ import { initRepo as storageInitRepo, createDeployPack, receivePackObjects, collectReachableObjects, maybeGC, } from "@intx/storage-isogit";
6
+ import { hasCode } from "@intx/types";
7
+ import { getLogger } from "@intx/log";
8
+ import { SAFE_REPO_ID } from "./types.js";
9
+ const DEFAULT_SUBSCRIBE_BUFFER_LIMIT = 1024;
10
+ const AUTHOR = {
11
+ name: "interchange-hub",
12
+ email: "hub@interchange.local",
13
+ };
14
+ const logger = getLogger(["hub", "repo-store"]);
15
+ /**
16
+ * In-process push-serialization lock. Keyed by `${kind}/${id}`. Each
17
+ * entry holds the tail of the chain of in-flight critical sections for
18
+ * that repo; the next acquirer awaits the current tail and replaces it
19
+ * with its own pending completion. On release the tail-check
20
+ * (`if (locks.get(key) === myTail) locks.delete(key)`) prevents the map
21
+ * from leaking entries once the chain drains.
22
+ *
23
+ * Single-process assumption: this protects against concurrent operations
24
+ * inside a single hub instance. Cross-process serialization (e.g. a
25
+ * second hub replica or an external git client touching the same
26
+ * on-disk repo) would need a filesystem-backed lock; the migration path
27
+ * is to swap the body of `withRepoLock` for an FS-lock acquire/release
28
+ * around the same critical section.
29
+ */
30
+ const locks = new Map();
31
+ async function withRepoLock(repoId, fn) {
32
+ const key = `${repoId.kind}/${repoId.id}`;
33
+ const previous = locks.get(key) ?? Promise.resolve();
34
+ let releaseFn = () => undefined;
35
+ const tail = new Promise((resolve) => {
36
+ releaseFn = resolve;
37
+ });
38
+ locks.set(key, tail);
39
+ try {
40
+ await previous;
41
+ return await fn();
42
+ }
43
+ finally {
44
+ if (locks.get(key) === tail) {
45
+ locks.delete(key);
46
+ }
47
+ releaseFn();
48
+ }
49
+ }
50
+ export function createRepoStore(config) {
51
+ const { dataDir, signingKey, handlers, authorize, signingCallback, gc } = config;
52
+ // Per-repo-directory isomorphic-git memoization cache. The store
53
+ // threads that dir's cache through every index-touching and
54
+ // object-reading git.* call, so isomorphic-git reuses the parsed
55
+ // on-disk index across the repo's serialized write sequence instead
56
+ // of re-reading and re-parsing it on every git.add / remove /
57
+ // updateIndex / commit / listFiles, and reuses parsed packfile
58
+ // indexes across object reads. The index-free tree assembly
59
+ // (git.writeTree / git.writeBlob) uses no index and threads no cache.
60
+ // The store is the single writer under withRepoLock, so a long-lived
61
+ // per-repo cache never races a concurrent mutator.
62
+ //
63
+ // The cache stays a pure accelerator, never a second source of truth,
64
+ // because the on-disk repo remains authoritative on every axis:
65
+ // isomorphic-git persists the index to disk on each dirty mutation and
66
+ // re-reads it on a stat mismatch; the object cache is OID-keyed
67
+ // (content-addressed, so it is never stale for a committed object);
68
+ // object reads enumerate pack files from disk on every call (so a GC
69
+ // repack's now-pruned packs are simply never consulted again); and
70
+ // refs are not cached at all — resolveRef and friends take no cache —
71
+ // so the cache can never serve a stale ref tip. Those same properties
72
+ // make it safe to drop a dir's cache at any instant: the next call
73
+ // just re-reads from disk.
74
+ //
75
+ // Two bounds keep a long-lived store from retaining parsed packfiles
76
+ // (and their pack bytes) without limit: a dir's cache is rebuilt once
77
+ // it has served GIT_CACHE_MAX_OPS calls, and the store holds at most
78
+ // GIT_CACHE_MAX_REPOS dir caches, evicting the least-recently-used.
79
+ // `invalidateGitCache` additionally drops a dir's cache after an
80
+ // out-of-band mutation that bypasses it — a received pack writes
81
+ // objects and advances a ref without threading this cache — so the
82
+ // next read rebuilds from the mutated repo.
83
+ const GIT_CACHE_MAX_OPS = 8192;
84
+ const GIT_CACHE_MAX_REPOS = 256;
85
+ const gitCaches = new Map();
86
+ function cacheFor(dir) {
87
+ let entry = gitCaches.get(dir);
88
+ if (entry === undefined) {
89
+ entry = { cache: {}, ops: 0 };
90
+ }
91
+ else {
92
+ // Re-insert so this dir ranks most-recently-used for the LRU
93
+ // eviction below; rebuild once it has spent its op budget.
94
+ gitCaches.delete(dir);
95
+ if (entry.ops >= GIT_CACHE_MAX_OPS)
96
+ entry = { cache: {}, ops: 0 };
97
+ }
98
+ entry.ops += 1;
99
+ gitCaches.set(dir, entry);
100
+ while (gitCaches.size > GIT_CACHE_MAX_REPOS) {
101
+ const lru = gitCaches.keys().next().value;
102
+ if (lru === undefined)
103
+ break;
104
+ gitCaches.delete(lru);
105
+ }
106
+ return entry.cache;
107
+ }
108
+ function invalidateGitCache(dir) {
109
+ gitCaches.delete(dir);
110
+ }
111
+ // Per-(repoId, ref) seq cache. Value is the seq of the ref's
112
+ // current tip; the next commit on the ref gets `cached + 1`. The
113
+ // cache is populated lazily — on each ref update we either bump
114
+ // the cached value or recompute it from `git.log` walk. Cleared
115
+ // on any failure inside the update path so a half-applied state
116
+ // never poisons future reads.
117
+ const seqCache = new Map();
118
+ // Per-(repoId, ref) subscriber set. Each subscriber holds its own
119
+ // buffer, kind filter, and waiter callback so concurrent
120
+ // subscribers do not interfere with each other.
121
+ const subscribers = new Map();
122
+ // Repo-scoped cache key shared by the per-repoId caches below
123
+ // (existing-commit set, and any future per-repo bookkeeping).
124
+ function indexCacheKey(repoId) {
125
+ return `${repoId.kind}/${repoId.id}`;
126
+ }
127
+ // Per-commit reachable-object cache. createPack for a workflow-run
128
+ // ref walks the first-parent chain and unions every commit's
129
+ // reachable objects so the receiver gets the full history needed to
130
+ // validate per-commit transitions. Without this cache the walk
131
+ // recomputes the reachable set for every ancestor on every push, so
132
+ // a long-running deployment's createPack cost scales as O(N^2) in
133
+ // the number of commits.
134
+ const chainReachabilityCache = new Map();
135
+ // Per-repoId cache of "every commit OID currently reachable from
136
+ // any branch or tag in the repo". The substrate uses this in
137
+ // `receivePack` to skip per-commit validation for commits the
138
+ // receiver already had before the pack arrived. Computing the set
139
+ // fresh on every receive scans every ref's parent chain — for a
140
+ // long-running workflow-run repo this scales with history depth.
141
+ // Caching it as a flat set and incrementally extending it on each
142
+ // ref update lets receivePack pay O(new commits) instead of
143
+ // O(history) per call. The cache is initialised lazily on first
144
+ // receivePack to avoid the cold-start scan for repos that only
145
+ // ever write via writeTree.
146
+ const existingCommitsCache = new Map();
147
+ // Per-(repoId, ref) cache of "the commit OID the receiver has acked
148
+ // for a workflow-run pack". After the first push lands a ref's tip on
149
+ // the receiver, every subsequent push only needs to carry the commits
150
+ // added since then — the parent chain older than this tip is already
151
+ // on the receiver, so re-shipping it wastes pack bytes and inflates
152
+ // `receivePack` time per push. The cursor advances in
153
+ // `commitPackedTip`, which the push pipeline calls once the receiver
154
+ // acks the transfer — NOT when `createPack` builds the pack. Advancing
155
+ // on build would strand the receiver whenever a transfer is cancelled
156
+ // before its ack (a reconnect drops the in-flight push): the next
157
+ // rebuild would walk back only to the un-acked commit and omit it, so
158
+ // the receiver would reject the pack with a dangling parent forever.
159
+ // Gating the advance on the ack keeps a cancelled transfer cleanly
160
+ // re-shippable. The cache key is `${repoId.kind}/${repoId.id}/${ref}`
161
+ // so writes against different refs on the same repo (events vs the
162
+ // workflow-run ref) do not interfere.
163
+ const lastPackedTip = new Map();
164
+ function lastPackedTipKey(repoId, ref) {
165
+ return `${repoId.kind}/${repoId.id}/${ref}`;
166
+ }
167
+ function refKey(repoId, ref) {
168
+ return `${repoId.kind}/${repoId.id}/${ref}`;
169
+ }
170
+ // Count commits reachable from `ref`. Returns 0 when the ref does
171
+ // not yet exist. `git.log` returns newest-first; the count gives
172
+ // the seq the next commit would land at if added now (the current
173
+ // tip's seq is `count - 1`).
174
+ async function countCommits(dir, ref) {
175
+ try {
176
+ const entries = await git.log({ fs, dir, cache: cacheFor(dir), ref });
177
+ return entries.length;
178
+ }
179
+ catch (err) {
180
+ if (hasCode(err) && err.code === "NotFoundError")
181
+ return 0;
182
+ throw err;
183
+ }
184
+ }
185
+ // Walk the ref's commit history oldest-first, assigning seq 0 to
186
+ // the root commit and incrementing toward HEAD. Each entry's
187
+ // `event` is the substrate-level commit descriptor — same shape
188
+ // the live path emits, so replay and live are vocabulary-identical.
189
+ async function replayHistory(dir, ref) {
190
+ let entries;
191
+ try {
192
+ entries = await git.log({ fs, dir, cache: cacheFor(dir), ref });
193
+ }
194
+ catch (err) {
195
+ if (hasCode(err) && err.code === "NotFoundError")
196
+ return [];
197
+ throw err;
198
+ }
199
+ const reversed = [...entries].reverse();
200
+ const out = [];
201
+ let prev = null;
202
+ for (let i = 0; i < reversed.length; i++) {
203
+ const entry = reversed[i];
204
+ if (entry === undefined)
205
+ throw new Error("unreachable");
206
+ const event = {
207
+ type: "ref.updated",
208
+ ref,
209
+ oldSha: prev,
210
+ newSha: entry.oid,
211
+ };
212
+ out.push({ seq: i, event });
213
+ prev = entry.oid;
214
+ }
215
+ return out;
216
+ }
217
+ function deliverToSubscriber(sub, entry) {
218
+ if (sub.closed)
219
+ return;
220
+ if (sub.waiter !== null) {
221
+ const w = sub.waiter;
222
+ sub.waiter = null;
223
+ w({ value: entry, done: false });
224
+ return;
225
+ }
226
+ if (sub.buffer.length >= sub.bufferLimit) {
227
+ sub.error = new Error(`subscribe_buffer_overrun: subscriber exceeded bufferLimit=${String(sub.bufferLimit)}`);
228
+ sub.closed = true;
229
+ return;
230
+ }
231
+ sub.buffer.push(entry);
232
+ }
233
+ // Called from inside the per-repo lock immediately after a
234
+ // successful ref update. Computes the new tip's seq (either by
235
+ // bumping the cached value or by walking the log), then fans the
236
+ // event out to every subscriber registered for this (repoId, ref).
237
+ // Errors raised by individual subscriber delivery (e.g. buffer
238
+ // overrun) are captured on the subscriber's state so the
239
+ // ref-update path itself is never destabilised by a slow consumer.
240
+ async function emitRefUpdate(repoId, ref, oldSha, newSha) {
241
+ const key = refKey(repoId, ref);
242
+ let seq;
243
+ const cached = seqCache.get(key);
244
+ if (cached !== undefined) {
245
+ seq = cached + 1;
246
+ }
247
+ else {
248
+ const dir = repoDir(repoId);
249
+ const count = await countCommits(dir, ref);
250
+ // `count` is the number of commits including the one we just
251
+ // produced. The tip's seq is `count - 1`.
252
+ seq = Math.max(0, count - 1);
253
+ }
254
+ seqCache.set(key, seq);
255
+ const event = {
256
+ type: "ref.updated",
257
+ ref,
258
+ oldSha,
259
+ newSha,
260
+ };
261
+ const entry = { seq, event };
262
+ const set = subscribers.get(key);
263
+ if (set === undefined)
264
+ return;
265
+ for (const sub of set)
266
+ deliverToSubscriber(sub, entry);
267
+ }
268
+ function handlerFor(repoId) {
269
+ const handler = handlers[repoId.kind];
270
+ if (handler === undefined) {
271
+ throw new Error(`no handler registered for kind: ${repoId.kind}`);
272
+ }
273
+ return handler;
274
+ }
275
+ function signerFor(repoId) {
276
+ return signingCallback === undefined ? undefined : signingCallback(repoId);
277
+ }
278
+ function repoDir(repoId) {
279
+ if (!SAFE_REPO_ID.test(repoId.id)) {
280
+ throw new Error(`repo_id_invalid: ${repoId.id}`);
281
+ }
282
+ const handler = handlerFor(repoId);
283
+ return path.join(dataDir, handler.directoryPrefix, repoId.id);
284
+ }
285
+ // Write-path reclaim. Called at the end of every successful write for an
286
+ // allowlisted kind, from inside the substrate's `withRepoLock`. The shared
287
+ // evaluator acquires the storage per-directory lock, applies the policy
288
+ // (reclaim over threshold, warn over byte budget), and logs rather than
289
+ // propagates a reclaim failure — the triggering write has already
290
+ // committed durably, so failing the caller would misreport it.
291
+ async function maybeRunGC(repoId) {
292
+ if (gc === undefined || !gc.kinds.includes(repoId.kind))
293
+ return;
294
+ await maybeGC(repoDir(repoId), gc);
295
+ }
296
+ function gateAccess(principal, repoId, ref, action) {
297
+ const verdict = authorize(principal, repoId, ref, action);
298
+ if (!verdict.allowed) {
299
+ throw new Error(`authorize_denied: ${verdict.reason}`);
300
+ }
301
+ }
302
+ function validateClearPrefix(clearPrefix) {
303
+ const malformed = clearPrefix.length === 0 ||
304
+ !clearPrefix.endsWith("/") ||
305
+ clearPrefix.startsWith("/") ||
306
+ clearPrefix.split("/").includes("..");
307
+ if (malformed) {
308
+ throw new Error(`clear_prefix_invalid: ${clearPrefix}`);
309
+ }
310
+ }
311
+ // Validate a delta write path. A put is always a file (no trailing
312
+ // slash); a delete is either an exact file or a subtree prefix
313
+ // (trailing slash allowed). Both reject empties, absolute paths, and
314
+ // any `..` traversal segment.
315
+ function validateDeltaPath(p, isDelete) {
316
+ const malformed = p.length === 0 ||
317
+ p.startsWith("/") ||
318
+ p.split("/").includes("..") ||
319
+ (!isDelete && p.endsWith("/"));
320
+ if (malformed) {
321
+ throw new Error(`delta_path_invalid: ${JSON.stringify(p)}`);
322
+ }
323
+ }
324
+ // Reject an ambiguous delta rather than silently pick a winner: a path
325
+ // that is both put and deleted, or a put that lands under a
326
+ // subtree-prefix delete. assembleTree would let the put win in both
327
+ // cases; making the contradiction loud keeps a caller from committing a
328
+ // tree that does not match its stated intent.
329
+ function assertDeltaUnambiguous(puts, deletes) {
330
+ const deleteSet = new Set(deletes);
331
+ const deletePrefixes = deletes.filter((d) => d.endsWith("/"));
332
+ for (const p of Object.keys(puts)) {
333
+ if (deleteSet.has(p)) {
334
+ throw new Error(`delta_ambiguous: path ${JSON.stringify(p)} is in both puts and deletes`);
335
+ }
336
+ for (const dp of deletePrefixes) {
337
+ if (p.startsWith(dp)) {
338
+ throw new Error(`delta_ambiguous: put ${JSON.stringify(p)} lands under deleted subtree ${JSON.stringify(dp)}`);
339
+ }
340
+ }
341
+ }
342
+ }
343
+ // When a delta declares a change scope, every put and delete must fall
344
+ // under it — otherwise a handler that scopes validation to
345
+ // `changedPathPrefixes` would skip a region the write actually mutated,
346
+ // an exactly-once hole. An undefined scope means validate-all, so there
347
+ // is nothing to under-cover.
348
+ function assertDeltaScoped(puts, deletes, changedPathPrefixes) {
349
+ if (changedPathPrefixes === undefined)
350
+ return;
351
+ const covered = (p) => {
352
+ for (const prefix of changedPathPrefixes) {
353
+ if (p === prefix || p.startsWith(prefix))
354
+ return true;
355
+ }
356
+ return false;
357
+ };
358
+ for (const p of Object.keys(puts)) {
359
+ if (!covered(p)) {
360
+ throw new Error(`delta_out_of_scope: put ${JSON.stringify(p)} is not under any changedPathPrefix`);
361
+ }
362
+ }
363
+ for (const d of deletes) {
364
+ if (!covered(d)) {
365
+ throw new Error(`delta_out_of_scope: delete ${JSON.stringify(d)} is not under any changedPathPrefix`);
366
+ }
367
+ }
368
+ }
369
+ function storageOptsFor(repoId, opts) {
370
+ const out = {};
371
+ const signer = signerFor(repoId);
372
+ if (signer !== undefined)
373
+ out.signer = signer;
374
+ if (opts?.gitignore !== undefined)
375
+ out.gitignore = opts.gitignore;
376
+ return out;
377
+ }
378
+ async function initRepo(repoId, opts) {
379
+ await storageInitRepo(repoDir(repoId), storageOptsFor(repoId, opts));
380
+ }
381
+ function getRepoDir(repoId) {
382
+ return repoDir(repoId);
383
+ }
384
+ async function listRefs(principal, repoId) {
385
+ gateAccess(principal, repoId, "*", "resolveRef");
386
+ const dir = repoDir(repoId);
387
+ const repoExists = await fs.promises
388
+ .stat(path.join(dir, ".git"))
389
+ .then(() => true)
390
+ .catch(() => false);
391
+ if (!repoExists)
392
+ return [];
393
+ const [branches, tags] = await Promise.all([
394
+ git.listBranches({ fs, dir }),
395
+ git.listTags({ fs, dir }),
396
+ ]);
397
+ const names = [];
398
+ for (const b of branches)
399
+ names.push(`refs/heads/${b}`);
400
+ for (const t of tags)
401
+ names.push(`refs/tags/${t}`);
402
+ names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
403
+ const entries = [];
404
+ for (const name of names) {
405
+ try {
406
+ const sha = await git.resolveRef({ fs, dir, ref: name });
407
+ entries.push({ name, sha });
408
+ }
409
+ catch (err) {
410
+ if (hasCode(err) && err.code === "NotFoundError")
411
+ continue;
412
+ throw err;
413
+ }
414
+ }
415
+ return entries;
416
+ }
417
+ async function resolveHead(principal, repoId) {
418
+ gateAccess(principal, repoId, "*", "resolveRef");
419
+ const dir = repoDir(repoId);
420
+ const repoExists = await fs.promises
421
+ .stat(path.join(dir, ".git"))
422
+ .then(() => true)
423
+ .catch(() => false);
424
+ if (!repoExists)
425
+ return null;
426
+ const symbolicTarget = await git.currentBranch({ fs, dir, fullname: true });
427
+ if (symbolicTarget === undefined)
428
+ return null;
429
+ const sha = await resolveRefSha(dir, symbolicTarget);
430
+ if (sha === null)
431
+ return null;
432
+ return { symbolicTarget, sha };
433
+ }
434
+ async function resolveRefSha(dir, ref) {
435
+ try {
436
+ return await git.resolveRef({ fs, dir, ref });
437
+ }
438
+ catch (err) {
439
+ if (hasCode(err) && err.code === "NotFoundError") {
440
+ return null;
441
+ }
442
+ throw err;
443
+ }
444
+ }
445
+ // Walk from a tree object's root to the tree-or-blob entry at
446
+ // `relPath`. Returns `null` when any path segment is missing, or when
447
+ // the final entry does not match `expectedType`. `relPath === ""`
448
+ // resolves to the root tree itself, and only when a tree is expected.
449
+ async function resolveTreeOid(dir, rootTreeOid, relPath, expectedType) {
450
+ if (relPath === "") {
451
+ return expectedType === "tree" ? rootTreeOid : null;
452
+ }
453
+ const segments = relPath.split("/").filter((s) => s !== "");
454
+ let currentOid = rootTreeOid;
455
+ for (let i = 0; i < segments.length; i++) {
456
+ const segment = segments[i];
457
+ if (segment === undefined)
458
+ throw new Error("unreachable");
459
+ const isLast = i === segments.length - 1;
460
+ const { tree } = await git.readTree({
461
+ fs,
462
+ dir,
463
+ cache: cacheFor(dir),
464
+ oid: currentOid,
465
+ });
466
+ const entry = tree.find((e) => e.path === segment);
467
+ if (entry === undefined)
468
+ return null;
469
+ if (isLast) {
470
+ if (entry.type !== expectedType)
471
+ return null;
472
+ return entry.oid;
473
+ }
474
+ if (entry.type !== "tree")
475
+ return null;
476
+ currentOid = entry.oid;
477
+ }
478
+ return currentOid;
479
+ }
480
+ // Walk from a commit's root tree to the tree-or-blob entry at
481
+ // `relPath`. Used to back both `priorReadBlob` and `priorListDir` so a
482
+ // handler can inspect the parent commit's tree from inside validatePush
483
+ // without each call duplicating the tree-walk.
484
+ async function resolveTreeEntry(dir, commitSha, relPath, expectedType) {
485
+ const { commit } = await git.readCommit({
486
+ fs,
487
+ dir,
488
+ cache: cacheFor(dir),
489
+ oid: commitSha,
490
+ });
491
+ return resolveTreeOid(dir, commit.tree, relPath, expectedType);
492
+ }
493
+ // Build the `(priorReadBlob, priorListDir)` pair fed into a kind
494
+ // handler's validatePush. When `commitSha` is `null`, both closures
495
+ // surface the "ref had no prior commit" state — readBlob returns
496
+ // null, listDir returns an empty array. When non-null, they read
497
+ // against that commit's tree. Read failures on a path that does
498
+ // exist (an EIO mid-walk) surface as a thrown error so a handler's
499
+ // append-only check cannot silently degrade into an accept.
500
+ function buildPriorTreeClosures(dir, commitSha) {
501
+ // Read any blob by its object id, cache-backed. Object ids are
502
+ // content addresses, so this is independent of `commitSha` and is
503
+ // available even on the no-prior-commit branch (where callers have
504
+ // no ids to read).
505
+ const readBlobByOid = async (oid) => {
506
+ const { blob } = await git.readBlob({
507
+ fs,
508
+ dir,
509
+ cache: cacheFor(dir),
510
+ oid,
511
+ });
512
+ return blob;
513
+ };
514
+ if (commitSha === null) {
515
+ return {
516
+ priorReadBlob: async () => null,
517
+ priorListDir: async () => [],
518
+ priorListDirOids: async () => [],
519
+ readBlobByOid,
520
+ };
521
+ }
522
+ const priorReadBlob = async (relPath) => {
523
+ const oid = await resolveTreeEntry(dir, commitSha, relPath, "blob");
524
+ if (oid === null)
525
+ return null;
526
+ const { blob } = await git.readBlob({
527
+ fs,
528
+ dir,
529
+ cache: cacheFor(dir),
530
+ oid,
531
+ });
532
+ return blob;
533
+ };
534
+ const priorListDir = async (relPath) => {
535
+ const oid = await resolveTreeEntry(dir, commitSha, relPath, "tree");
536
+ if (oid === null)
537
+ return [];
538
+ const { tree } = await git.readTree({
539
+ fs,
540
+ dir,
541
+ cache: cacheFor(dir),
542
+ oid,
543
+ });
544
+ return tree.map((e) => e.path);
545
+ };
546
+ // Same walk as `priorListDir` but carries each child's git object id
547
+ // out of the tree listing. A kind handler validating a large subtree
548
+ // by its per-commit delta uses the OID to prove a retained entry is
549
+ // byte-unchanged without re-reading the blob.
550
+ const priorListDirOids = async (relPath) => {
551
+ const oid = await resolveTreeEntry(dir, commitSha, relPath, "tree");
552
+ if (oid === null)
553
+ return [];
554
+ const { tree } = await git.readTree({
555
+ fs,
556
+ dir,
557
+ cache: cacheFor(dir),
558
+ oid,
559
+ });
560
+ return tree.map((e) => ({ name: e.path, oid: e.oid }));
561
+ };
562
+ return { priorReadBlob, priorListDir, priorListDirOids, readBlobByOid };
563
+ }
564
+ // Build the `(readBlob, listDir, topLevelTreePaths)` triple a kind
565
+ // handler's validatePush sees for the prospective tree of a given
566
+ // commit. Mirrors the tip-only closures the storage layer builds for
567
+ // its validateTree callback, except parameterised on the commit OID
568
+ // so the substrate can walk per-commit during a multi-commit pack.
569
+ async function buildCommitTreeClosures(dir, commitSha) {
570
+ const { commit } = await git.readCommit({
571
+ fs,
572
+ dir,
573
+ cache: cacheFor(dir),
574
+ oid: commitSha,
575
+ });
576
+ const { tree: rootTree } = await git.readTree({
577
+ fs,
578
+ dir,
579
+ cache: cacheFor(dir),
580
+ oid: commit.tree,
581
+ });
582
+ const topLevelTreePaths = rootTree.map((e) => e.path);
583
+ const readBlob = async (relPath) => {
584
+ const oid = await resolveTreeEntry(dir, commitSha, relPath, "blob");
585
+ if (oid === null) {
586
+ throw new Error(`readBlob: path ${relPath} not found in commit ${commitSha} tree`);
587
+ }
588
+ const { blob } = await git.readBlob({
589
+ fs,
590
+ dir,
591
+ cache: cacheFor(dir),
592
+ oid,
593
+ });
594
+ return blob;
595
+ };
596
+ const listDir = async (relPath) => {
597
+ if (relPath === "")
598
+ return rootTree.map((e) => e.path);
599
+ const oid = await resolveTreeEntry(dir, commitSha, relPath, "tree");
600
+ // An absent directory lists as empty, matching the writeTree-path
601
+ // `listDir` and the prior-tree closures: every `listDir` the
602
+ // substrate hands a kind handler returns `[]` for a missing path,
603
+ // so a handler that walks an optional or scoped-but-absent subtree
604
+ // (e.g. workflow-run's per-run scoped walk over a run the commit
605
+ // dropped) hits its own empty-directory guard and produces a clean
606
+ // `path_violation` reason instead of a raw substrate throw. A real
607
+ // read fault inside `git.readTree` below still bubbles.
608
+ if (oid === null)
609
+ return [];
610
+ const { tree } = await git.readTree({
611
+ fs,
612
+ dir,
613
+ cache: cacheFor(dir),
614
+ oid,
615
+ });
616
+ return tree.map((e) => e.path);
617
+ };
618
+ return { topLevelTreePaths, readBlob, listDir };
619
+ }
620
+ // Read a tree's child entries as a name->oid map. Returns null when
621
+ // the path is absent or is not a tree at the given commit, so the
622
+ // diff below treats "subtree gained/lost" uniformly with "subtree
623
+ // changed".
624
+ async function readTreeEntryMap(dir, commitSha, relPath) {
625
+ const oid = relPath === ""
626
+ ? (await git.readCommit({
627
+ fs,
628
+ dir,
629
+ cache: cacheFor(dir),
630
+ oid: commitSha,
631
+ })).commit.tree
632
+ : await resolveTreeEntry(dir, commitSha, relPath, "tree");
633
+ if (oid === null)
634
+ return null;
635
+ const { tree } = await git.readTree({ fs, dir, cache: cacheFor(dir), oid });
636
+ const out = new Map();
637
+ for (const e of tree)
638
+ out.set(e.path, e.oid);
639
+ return out;
640
+ }
641
+ // Bound the set of paths a received-pack commit may have changed
642
+ // relative to its parent, as repo-root-relative POSIX prefixes ending
643
+ // in `/`. Git is content-addressed: a subtree whose object id is
644
+ // unchanged between the two commits is byte-identical, so the diff
645
+ // only descends into entries whose oid differs. Top-level entries that
646
+ // differ are emitted as `<name>/`; the `runs/` subtree is descended
647
+ // one level further so a per-run handler can scope to the exact
648
+ // `runs/<runId>/` directories that changed rather than re-validating
649
+ // every run. `parentSha === null` (no readable parent) means the
650
+ // substrate cannot bound the change set, so it returns `undefined`
651
+ // and the handler validates the whole tree.
652
+ async function computeChangedPathPrefixes(dir, commitSha, parentSha) {
653
+ if (parentSha === null)
654
+ return undefined;
655
+ const prefixes = new Set();
656
+ const newTop = (await readTreeEntryMap(dir, commitSha, "")) ?? new Map();
657
+ const oldTop = (await readTreeEntryMap(dir, parentSha, "")) ?? new Map();
658
+ const topNames = new Set([...newTop.keys(), ...oldTop.keys()]);
659
+ for (const name of topNames) {
660
+ if (newTop.get(name) === oldTop.get(name))
661
+ continue;
662
+ if (name === "runs") {
663
+ const newRuns = (await readTreeEntryMap(dir, commitSha, "runs")) ?? new Map();
664
+ const oldRuns = (await readTreeEntryMap(dir, parentSha, "runs")) ?? new Map();
665
+ const runNames = new Set([
666
+ ...newRuns.keys(),
667
+ ...oldRuns.keys(),
668
+ ]);
669
+ for (const runId of runNames) {
670
+ if (newRuns.get(runId) === oldRuns.get(runId))
671
+ continue;
672
+ prefixes.add(`runs/${runId}/`);
673
+ }
674
+ continue;
675
+ }
676
+ prefixes.add(`${name}/`);
677
+ }
678
+ return prefixes;
679
+ }
680
+ // Enumerate every commit OID reachable from any branch or tag in
681
+ // the repo. The substrate uses this to define the set of "old"
682
+ // commits — anything reachable via an existing ref existed before
683
+ // the in-flight pack landed, so a pack-walk that reaches one of
684
+ // these has crossed the boundary between new and prior history.
685
+ // Returns an empty set when the repo's `.git` directory has no refs
686
+ // yet (a freshly-initialised repo with HEAD pointing at an unborn
687
+ // branch). Read failures bubble; a silent empty would let the walk
688
+ // re-validate commits the kind handler already accepted.
689
+ async function snapshotExistingCommits(dir) {
690
+ const out = new Set();
691
+ const visit = async (start) => {
692
+ const stack = [start];
693
+ while (stack.length > 0) {
694
+ const oid = stack.pop();
695
+ if (oid === undefined)
696
+ break;
697
+ if (out.has(oid))
698
+ continue;
699
+ let parsed;
700
+ try {
701
+ parsed = await git.readCommit({ fs, dir, cache: cacheFor(dir), oid });
702
+ }
703
+ catch (err) {
704
+ // A previously-received single-commit pack may leave the
705
+ // tip's `parent` field pointing at a SHA the receiver has
706
+ // never seen (the producer ships only the tip's tree, not
707
+ // its ancestor commits). That dangling parent is structural
708
+ // for this repo kind, not a corruption, so the walk stops
709
+ // at the missing node instead of erroring.
710
+ if (hasCode(err) && err.code === "NotFoundError")
711
+ continue;
712
+ throw err;
713
+ }
714
+ out.add(oid);
715
+ for (const parent of parsed.commit.parent) {
716
+ if (!out.has(parent))
717
+ stack.push(parent);
718
+ }
719
+ }
720
+ };
721
+ const branches = await git.listBranches({ fs, dir });
722
+ for (const b of branches) {
723
+ const sha = await resolveRefSha(dir, `refs/heads/${b}`);
724
+ if (sha !== null)
725
+ await visit(sha);
726
+ }
727
+ const tags = await git.listTags({ fs, dir });
728
+ for (const t of tags) {
729
+ const sha = await resolveRefSha(dir, `refs/tags/${t}`);
730
+ if (sha !== null)
731
+ await visit(sha);
732
+ }
733
+ return out;
734
+ }
735
+ // Walk parent links from `tipSha` back to identify the new commits
736
+ // a pack just published, returning the chain in topological order —
737
+ // oldest new commit first, tip last. A commit is considered "new"
738
+ // when it is readable from the object store, was not present before
739
+ // the pack arrived (not in `existingCommits`), and does not match
740
+ // the CAS-pinned `expectedOldSha`. When the parent's commit object
741
+ // is not in the store at all — the common case for the deploy-pack
742
+ // shape `createDeployPack` produces, which packs only the tip's
743
+ // tree and not its ancestor commits — that absence is the
744
+ // boundary: history older than the readable commit is opaque from
745
+ // this pack's perspective, so the walker stops without pushing the
746
+ // unreadable parent.
747
+ //
748
+ // The walk chases the first parent only; multi-parent merge commits
749
+ // are not produced by any current writer of repo-store-managed
750
+ // kinds, so a multi-parent commit in a received pack is a
751
+ // structural defect the substrate refuses outright. Each returned
752
+ // commit is one the substrate must validate against its predecessor
753
+ // before the ref advances.
754
+ async function collectNewCommits(dir, tipSha, expectedOldSha, existingCommits) {
755
+ const chain = [];
756
+ let current = tipSha;
757
+ while (true) {
758
+ if (current === expectedOldSha)
759
+ break;
760
+ if (existingCommits.has(current))
761
+ break;
762
+ let parsed;
763
+ try {
764
+ parsed = await git.readCommit({
765
+ fs,
766
+ dir,
767
+ cache: cacheFor(dir),
768
+ oid: current,
769
+ });
770
+ }
771
+ catch (err) {
772
+ if (hasCode(err) && err.code === "NotFoundError")
773
+ break;
774
+ throw err;
775
+ }
776
+ chain.push(current);
777
+ const parents = parsed.commit.parent;
778
+ if (parents.length === 0)
779
+ break;
780
+ if (parents.length > 1) {
781
+ throw new Error(`pack_walk_multi_parent: commit ${current} has ${String(parents.length)} parents; merge commits are not supported in repo-store packs`);
782
+ }
783
+ const next = parents[0];
784
+ if (next === undefined)
785
+ throw new Error("unreachable");
786
+ current = next;
787
+ }
788
+ return chain.reverse();
789
+ }
790
+ // Assemble a new root tree by splicing `puts` (repo-root-relative path
791
+ // -> blob oid) and `deletes` onto the parent's root tree, reusing every
792
+ // unchanged entry by oid. The result is committed directly via
793
+ // `git.commit({ tree })`, so the on-disk index is never touched.
794
+ //
795
+ // `deletes` is a set of repo-root-relative paths, and removal must
796
+ // match the base entry's type. A no-slash entry names an exact path
797
+ // and clears the blob there; deleting a path that is a directory in
798
+ // the base is rejected with `delete_type_mismatch` (there is no
799
+ // git-rm-style recursive drop). A trailing-slash entry names a
800
+ // subtree prefix and clears it (the clear-prefix shape); a
801
+ // trailing-slash delete whose leading segment is a base blob is
802
+ // rejected with `delete_type_mismatch`, unless a `put` drives the
803
+ // same descent -- that is a legitimate file-to-directory replacement
804
+ // and is accepted (the put wins). A `put` at a path overrides a
805
+ // delete of the same path. A delete of a path absent from the parent
806
+ // tree is an idempotent no-op (the entry is simply never emitted),
807
+ // matching `git rm --ignore-unmatch` and the working-tree `rm` with
808
+ // `force: true`.
809
+ //
810
+ // Recursion is scoped to the touched subtrees: a level is read and
811
+ // rewritten only when a put lands under it or a delete removes within
812
+ // it. Every entry the write does not touch — sibling subtrees AND
813
+ // sibling blobs inside a subtree being touched — is carried forward by
814
+ // its existing oid without a re-hash or a walk, so the per-commit cost
815
+ // tracks the size of the change rather than the repo. Returns the new
816
+ // tree oid, or `null` when the subtree ends up empty (the caller writes
817
+ // an empty root tree when the whole repo empties).
818
+ async function assembleTree(dir, baseTreeOid, prefix, puts, deletes) {
819
+ // A subtree-delete naming exactly this node drops the base wholesale;
820
+ // only puts under it survive.
821
+ const cleared = prefix !== "" && deletes.has(prefix);
822
+ const baseEntries = new Map();
823
+ if (baseTreeOid !== null && !cleared) {
824
+ const { tree } = await git.readTree({
825
+ fs,
826
+ dir,
827
+ cache: cacheFor(dir),
828
+ oid: baseTreeOid,
829
+ });
830
+ for (const e of tree) {
831
+ baseEntries.set(e.path, { mode: e.mode, oid: e.oid, type: e.type });
832
+ }
833
+ }
834
+ // Classify what changes at this level: direct blob puts, exact-file
835
+ // deletes here, and subtrees a put or a delete descends into.
836
+ const blobPutsHere = new Set();
837
+ const subtreeNames = new Set();
838
+ const subtreePutNames = new Set();
839
+ const fileDeletesHere = new Set();
840
+ for (const full of puts.keys()) {
841
+ if (prefix !== "" && !full.startsWith(prefix))
842
+ continue;
843
+ const rest = full.slice(prefix.length);
844
+ if (rest.length === 0)
845
+ continue;
846
+ const slash = rest.indexOf("/");
847
+ if (slash === -1)
848
+ blobPutsHere.add(rest);
849
+ else {
850
+ const child = rest.slice(0, slash);
851
+ subtreeNames.add(child);
852
+ subtreePutNames.add(child);
853
+ }
854
+ }
855
+ for (const del of deletes) {
856
+ if (prefix !== "" && !del.startsWith(prefix))
857
+ continue;
858
+ const rest = del.slice(prefix.length);
859
+ if (rest.length === 0)
860
+ continue; // del === prefix, handled by `cleared`
861
+ const slash = rest.indexOf("/");
862
+ if (slash === -1)
863
+ fileDeletesHere.add(rest);
864
+ else
865
+ subtreeNames.add(rest.slice(0, slash));
866
+ }
867
+ // A name written both as a direct blob and as a directory (a put/base
868
+ // `foo` plus a put or delete under `foo/`) is contradictory. Left
869
+ // implicit, the blob-put branch below wins and the subtree side is
870
+ // silently dropped; reject it loudly instead. This covers every
871
+ // caller — writeTree's `content.files`, writeTreePreservingPrefix's
872
+ // merge output, and writeTreeDelta's puts/deletes — since all funnel
873
+ // into this same classification.
874
+ for (const name of blobPutsHere) {
875
+ if (subtreeNames.has(name)) {
876
+ throw new Error(`tree_name_collision: ${JSON.stringify(prefix + name)} is written both as a file and as a directory`);
877
+ }
878
+ }
879
+ const names = new Set([
880
+ ...baseEntries.keys(),
881
+ ...blobPutsHere,
882
+ ...subtreeNames,
883
+ ]);
884
+ const entries = [];
885
+ for (const name of names) {
886
+ const full = prefix + name;
887
+ const putOid = puts.get(full);
888
+ if (putOid !== undefined) {
889
+ // A put overrides whatever the base held and any delete of the
890
+ // same path. Puts are always regular files (mode 100644). If a
891
+ // put ever overwrites a base entry with a different mode (an
892
+ // executable 100755 blob, a symlink 120000, or a submodule), this
893
+ // downgrades it to 100644 — the store only ever writes 100644
894
+ // content blobs today, so no caller hits it, but a future caller
895
+ // that needs to preserve an executable bit would have to carry
896
+ // the mode through `puts` rather than assume 100644.
897
+ entries.push({
898
+ mode: "100644",
899
+ path: name,
900
+ oid: putOid,
901
+ type: "blob",
902
+ });
903
+ continue;
904
+ }
905
+ if (fileDeletesHere.has(name)) {
906
+ const base = baseEntries.get(name);
907
+ if (base !== undefined && base.type === "tree") {
908
+ throw new Error(`delete_type_mismatch: ${JSON.stringify(prefix + name)} is deleted as a file but is a directory in the base tree`);
909
+ }
910
+ continue; // file removed
911
+ }
912
+ if (subtreeNames.has(name)) {
913
+ const base = baseEntries.get(name);
914
+ // A trailing-slash delete descending into a base blob
915
+ // contradicts the base type -- reject it. The
916
+ // `!subtreePutNames` carve-out lets a put-driven descent
917
+ // through: a `put` under `name/` is a file-to-directory
918
+ // replacement, not a delete mismatch, so it must not raise
919
+ // delete_type_mismatch. That replacement still dead-ends at
920
+ // working-tree materialization in writeTreeUnderLock (mkdir
921
+ // over the base file EEXISTs) -- a separate limitation the
922
+ // claim-check callers never reach, which must not be masked by
923
+ // mislabeling a put as a delete mismatch.
924
+ if (base !== undefined &&
925
+ base.type === "blob" &&
926
+ !subtreePutNames.has(name)) {
927
+ throw new Error(`delete_type_mismatch: ${JSON.stringify(prefix + name)} is deleted as a subtree but is a file in the base tree`);
928
+ }
929
+ const baseChildOid = base !== undefined && base.type === "tree" ? base.oid : null;
930
+ const childOid = await assembleTree(dir, baseChildOid, `${full}/`, puts, deletes);
931
+ if (childOid !== null) {
932
+ entries.push({
933
+ mode: "040000",
934
+ path: name,
935
+ oid: childOid,
936
+ type: "tree",
937
+ });
938
+ }
939
+ continue;
940
+ }
941
+ const base = baseEntries.get(name);
942
+ if (base === undefined)
943
+ continue;
944
+ entries.push({
945
+ mode: base.mode,
946
+ path: name,
947
+ oid: base.oid,
948
+ type: base.type,
949
+ });
950
+ }
951
+ if (entries.length === 0)
952
+ return null;
953
+ return await git.writeTree({ fs, dir, tree: entries });
954
+ }
955
+ // Prospective-side validatePush closures sourced from the assembled
956
+ // tree oid, so a handler sees exactly the tree the commit will carry.
957
+ // The blobs and trees the assembly wrote are unreferenced objects
958
+ // until the commit lands, so reading them here advances nothing.
959
+ function buildTreeReadClosures(dir, rootTreeOid) {
960
+ const topLevelTreePaths = async () => {
961
+ const { tree } = await git.readTree({
962
+ fs,
963
+ dir,
964
+ cache: cacheFor(dir),
965
+ oid: rootTreeOid,
966
+ });
967
+ return tree.map((e) => e.path);
968
+ };
969
+ const readBlob = async (relPath) => {
970
+ const oid = await resolveTreeOid(dir, rootTreeOid, relPath, "blob");
971
+ if (oid === null) {
972
+ throw new Error(`readBlob: path ${relPath} not present in prospective tree`);
973
+ }
974
+ const { blob } = await git.readBlob({
975
+ fs,
976
+ dir,
977
+ cache: cacheFor(dir),
978
+ oid,
979
+ });
980
+ return blob;
981
+ };
982
+ const listDir = async (relPath) => {
983
+ const oid = relPath === ""
984
+ ? rootTreeOid
985
+ : await resolveTreeOid(dir, rootTreeOid, relPath, "tree");
986
+ if (oid === null)
987
+ return [];
988
+ const { tree } = await git.readTree({
989
+ fs,
990
+ dir,
991
+ cache: cacheFor(dir),
992
+ oid,
993
+ });
994
+ return tree.map((e) => e.path);
995
+ };
996
+ // Same walk as `listDir` but carries each child's git object id out of
997
+ // the assembled tree's listing, mirroring the prior side's
998
+ // `priorListDirOids`. A handler validating a large retained subtree by
999
+ // its per-commit delta (workflow-run's consumed dedup index) reads the
1000
+ // prospective OID straight from here instead of re-reading and hashing
1001
+ // every retained blob.
1002
+ const listDirOids = async (relPath) => {
1003
+ const oid = relPath === ""
1004
+ ? rootTreeOid
1005
+ : await resolveTreeOid(dir, rootTreeOid, relPath, "tree");
1006
+ if (oid === null)
1007
+ return [];
1008
+ const { tree } = await git.readTree({
1009
+ fs,
1010
+ dir,
1011
+ cache: cacheFor(dir),
1012
+ oid,
1013
+ });
1014
+ return tree.map((e) => ({ name: e.path, oid: e.oid }));
1015
+ };
1016
+ return { topLevelTreePaths, readBlob, listDir, listDirOids };
1017
+ }
1018
+ // Unlocked body of writeTree. The caller is responsible for
1019
+ // acquiring the per-repo lock before invoking this and for not
1020
+ // releasing it until the returned promise settles. Extracted so
1021
+ // writeTreePreservingPrefix can run a read-then-merge step under the
1022
+ // same lock without holding two nested acquisitions.
1023
+ async function writeTreeUnderLock(principal, repoId, ref, w,
1024
+ // When present, the parent tip already resolved under this lock. The
1025
+ // delta path pins it once and hands the SAME oid to both computeDelta
1026
+ // (its dedup reads) and this assembly, so the dedup snapshot, the
1027
+ // committed tree, and validation are provably one pre-image. Absent,
1028
+ // the write resolves its own (the writeTree / preserving-prefix path,
1029
+ // which has no prior read to keep consistent with). `{ sha }` wraps
1030
+ // the value so a genuinely-null pin is distinct from "not provided".
1031
+ pinnedParent) {
1032
+ const dir = repoDir(repoId);
1033
+ await storageInitRepo(dir, storageOptsFor(repoId, undefined));
1034
+ const handler = handlerFor(repoId);
1035
+ // Assemble the commit's tree directly and commit that tree oid,
1036
+ // never staging into the on-disk index. The index is a single
1037
+ // repo-global structure shared across refs; routing writes through
1038
+ // it forced a full index rebuild on every events<->workflow-run ref
1039
+ // flip and re-serialized the whole index once per staged blob.
1040
+ // Splicing the tree from the parent's root tree — applying `w.files`
1041
+ // as puts and `w.deletes` as removals, reusing every untouched entry
1042
+ // by oid — keeps the per-commit cost tracking the change rather than
1043
+ // the accumulated history.
1044
+ // Pin the parent under the lock: the new commit's parent is the
1045
+ // ref's tip, and the splice runs against THAT commit's root tree,
1046
+ // read under the same lock, so the pre-image is race-free. A ref
1047
+ // that does not yet exist has no base tree (the splice starts from
1048
+ // empty) and parents on HEAD, matching the prior index-reset path
1049
+ // which seeded an empty index for a missing ref.
1050
+ const parentCommitSha = pinnedParent !== undefined
1051
+ ? pinnedParent.sha
1052
+ : await resolveRefSha(dir, ref);
1053
+ let baseRootTreeOid = null;
1054
+ if (parentCommitSha !== null) {
1055
+ const { commit } = await git.readCommit({
1056
+ fs,
1057
+ dir,
1058
+ cache: cacheFor(dir),
1059
+ oid: parentCommitSha,
1060
+ });
1061
+ baseRootTreeOid = commit.tree;
1062
+ }
1063
+ // Write the put blobs, then splice them and `w.deletes` onto the
1064
+ // parent root tree. writeBlob and writeTree emit unreferenced
1065
+ // objects; nothing the ref can reach moves until the commit lands.
1066
+ const puts = new Map();
1067
+ for (const [relPath, contents] of Object.entries(w.files)) {
1068
+ const bytes = typeof contents === "string"
1069
+ ? new TextEncoder().encode(contents)
1070
+ : contents;
1071
+ const oid = await git.writeBlob({ fs, dir, blob: bytes });
1072
+ puts.set(relPath, oid);
1073
+ }
1074
+ const assembled = await assembleTree(dir, baseRootTreeOid, "", puts, w.deletes);
1075
+ const newRootTreeOid = assembled ?? (await git.writeTree({ fs, dir, tree: [] }));
1076
+ // validatePush sees the full prospective tree via closures over the
1077
+ // assembled tree oid; the prior-side closures read the parent commit
1078
+ // (`parentCommitSha`) exactly as before. `w.changedPathPrefixes` is
1079
+ // the handler's scoping hint — the prefixes this write may have
1080
+ // touched — and stays undefined for an unbounded (validate-all) write.
1081
+ const { priorReadBlob, priorListDir, priorListDirOids } = buildPriorTreeClosures(dir, parentCommitSha);
1082
+ const prospective = buildTreeReadClosures(dir, newRootTreeOid);
1083
+ const changedPathPrefixes = w.changedPathPrefixes;
1084
+ const validation = await handler.validatePush({
1085
+ repoId,
1086
+ ref,
1087
+ principal,
1088
+ topLevelTreePaths: await prospective.topLevelTreePaths(),
1089
+ readBlob: prospective.readBlob,
1090
+ listDir: prospective.listDir,
1091
+ listDirOids: prospective.listDirOids,
1092
+ priorReadBlob,
1093
+ priorListDir,
1094
+ priorListDirOids,
1095
+ changedPathPrefixes,
1096
+ });
1097
+ if (!validation.ok) {
1098
+ // Nothing was staged and no ref advanced: the assembly wrote only
1099
+ // unreferenced blob and tree objects, which the next GC reclaims.
1100
+ // There is no half-applied index or working-tree state to roll
1101
+ // back, so the refusal just surfaces.
1102
+ throw new Error(`path_violation: ${validation.reason}`);
1103
+ }
1104
+ // Materialize the working tree for the paths this write touched:
1105
+ // remove each deleted path, then write each put file. The store's own
1106
+ // reads resolve through the object store, but some consumers (the
1107
+ // workflow-run claim-check processing scan) read these files straight
1108
+ // from disk, so the working tree must mirror the committed change.
1109
+ // Only the deleted paths and the put paths are touched, so this is
1110
+ // O(change), not O(repo). It runs only after validation passes, so a
1111
+ // rejected push leaves the working tree untouched — the failure
1112
+ // atomicity the old index rollback provided, now without any
1113
+ // rollback. `rm` with `force` no-ops a missing path and `recursive`
1114
+ // covers both a file delete and a subtree-prefix delete.
1115
+ for (const del of w.deletes) {
1116
+ await fs.promises.rm(path.join(dir, del), {
1117
+ recursive: true,
1118
+ force: true,
1119
+ });
1120
+ }
1121
+ for (const [relPath, contents] of Object.entries(w.files)) {
1122
+ const fullPath = path.join(dir, relPath);
1123
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
1124
+ await fs.promises.writeFile(fullPath, contents);
1125
+ }
1126
+ // The parent is the pinned tip, or HEAD for a never-written ref so a
1127
+ // first write parents on the repo's initial commit. `oldSha` is
1128
+ // precise: null only when the ref truly does not exist.
1129
+ const oldSha = parentCommitSha;
1130
+ const parentSha = parentCommitSha ?? (await git.resolveRef({ fs, dir, ref: "HEAD" }));
1131
+ const commitSha = await git.commit({
1132
+ fs,
1133
+ dir,
1134
+ cache: cacheFor(dir),
1135
+ tree: newRootTreeOid,
1136
+ message: w.message,
1137
+ author: AUTHOR,
1138
+ parent: [parentSha],
1139
+ ref,
1140
+ signingKey: "sshsig",
1141
+ onSign: async ({ payload }) => ({
1142
+ signature: await createSSHSignature(payload, signingKey.privateKey, signingKey.publicKey),
1143
+ }),
1144
+ });
1145
+ const cachedExisting = existingCommitsCache.get(indexCacheKey(repoId));
1146
+ if (cachedExisting !== undefined)
1147
+ cachedExisting.add(commitSha);
1148
+ await handler.onRefUpdated({ repoId, ref, oldSha, newSha: commitSha });
1149
+ await emitRefUpdate(repoId, ref, oldSha, commitSha);
1150
+ await maybeRunGC(repoId);
1151
+ return { commitSha, newlyTerminalRuns: validation.newlyTerminalRuns ?? [] };
1152
+ }
1153
+ // Normalize a `TreeContent` (files + optional clearPrefix) into the
1154
+ // puts/deletes/scope shape writeTreeUnderLock consumes. A clearPrefix
1155
+ // becomes a single subtree-delete and the handler's change scope; its
1156
+ // absence is a purely-additive write validated in full.
1157
+ function normalizeTreeContent(content) {
1158
+ if (content.clearPrefix !== undefined) {
1159
+ validateClearPrefix(content.clearPrefix);
1160
+ return {
1161
+ files: content.files,
1162
+ deletes: new Set([content.clearPrefix]),
1163
+ changedPathPrefixes: new Set([content.clearPrefix]),
1164
+ message: content.message,
1165
+ };
1166
+ }
1167
+ return {
1168
+ files: content.files,
1169
+ deletes: new Set(),
1170
+ changedPathPrefixes: undefined,
1171
+ message: content.message,
1172
+ };
1173
+ }
1174
+ async function writeTree(principal, repoId, ref, content) {
1175
+ gateAccess(principal, repoId, ref, "writeTree");
1176
+ // The lock spans the entire substrate body of writeTree: tree
1177
+ // assembly, validatePush, commit, and the onRefUpdated hook. Holding
1178
+ // the lock through onRefUpdated keeps post-update consumers
1179
+ // serialized against the same ref's next writer.
1180
+ return withRepoLock(repoId, () => writeTreeUnderLock(principal, repoId, ref, normalizeTreeContent(content)));
1181
+ }
1182
+ // Enumerate every blob directly under `prefix` in the tree at
1183
+ // `ref`, returning a map from repo-root-relative path (including the
1184
+ // prefix) to bytes. The empty map covers the ref-missing /
1185
+ // prefix-missing cases — both legitimate first-write states for the
1186
+ // prefix-preserving primitive.
1187
+ async function readPrefixBlobs(repoId, ref, prefix) {
1188
+ const dir = repoDir(repoId);
1189
+ const out = new Map();
1190
+ const repoExists = await fs.promises
1191
+ .stat(path.join(dir, ".git"))
1192
+ .then(() => true)
1193
+ .catch(() => false);
1194
+ if (!repoExists)
1195
+ return out;
1196
+ const commitSha = await resolveRefSha(dir, ref);
1197
+ if (commitSha === null)
1198
+ return out;
1199
+ const { commit } = await git.readCommit({
1200
+ fs,
1201
+ dir,
1202
+ cache: cacheFor(dir),
1203
+ oid: commitSha,
1204
+ });
1205
+ let currentOid = commit.tree;
1206
+ const segments = prefix
1207
+ .replace(/\/$/, "")
1208
+ .split("/")
1209
+ .filter((s) => s !== "");
1210
+ for (const segment of segments) {
1211
+ const { tree } = await git.readTree({
1212
+ fs,
1213
+ dir,
1214
+ cache: cacheFor(dir),
1215
+ oid: currentOid,
1216
+ });
1217
+ const entry = tree.find((e) => e.path === segment);
1218
+ if (entry === undefined || entry.type !== "tree") {
1219
+ return out;
1220
+ }
1221
+ currentOid = entry.oid;
1222
+ }
1223
+ const { tree } = await git.readTree({
1224
+ fs,
1225
+ dir,
1226
+ cache: cacheFor(dir),
1227
+ oid: currentOid,
1228
+ });
1229
+ // N+1 isomorphic-git round-trips: one tree read plus one
1230
+ // readBlob per blob entry. Acceptable at the current scale
1231
+ // (single-digit tarballs per registry); when a registry grows
1232
+ // to hundreds of entries this becomes the obvious optimization
1233
+ // target — readBlob can take the entry oid directly, avoiding
1234
+ // the per-call path resolution.
1235
+ for (const entry of tree) {
1236
+ if (entry.type !== "blob")
1237
+ continue;
1238
+ const { blob } = await git.readBlob({
1239
+ fs,
1240
+ dir,
1241
+ cache: cacheFor(dir),
1242
+ oid: commitSha,
1243
+ filepath: `${prefix}${entry.path}`,
1244
+ });
1245
+ out.set(`${prefix}${entry.path}`, blob);
1246
+ }
1247
+ return out;
1248
+ }
1249
+ async function writeTreePreservingPrefix(principal, repoId, ref, args) {
1250
+ gateAccess(principal, repoId, ref, "writeTree");
1251
+ validateClearPrefix(args.preservePrefix);
1252
+ return withRepoLock(repoId, async () => {
1253
+ // Reading and merging both happen inside the lock so two
1254
+ // concurrent callers targeting the same prefix observe each
1255
+ // other's commits in serial order — no lost-update window
1256
+ // between the read and the writeTree.
1257
+ await storageInitRepo(repoDir(repoId), storageOptsFor(repoId, undefined));
1258
+ const existing = await readPrefixBlobs(repoId, ref, args.preservePrefix);
1259
+ const files = await args.merge(existing);
1260
+ return writeTreeUnderLock(principal, repoId, ref, {
1261
+ files,
1262
+ deletes: new Set([args.preservePrefix]),
1263
+ changedPathPrefixes: new Set([args.preservePrefix]),
1264
+ message: args.message,
1265
+ });
1266
+ });
1267
+ }
1268
+ // Commit a targeted delta: `computeDelta` runs under the per-repo lock
1269
+ // against the pinned parent tip and returns the exact files to put and
1270
+ // paths to delete; everything else is carried forward by oid. Unlike
1271
+ // writeTreePreservingPrefix — which clears and rebuilds a whole prefix
1272
+ // from a full merge output — a delta touches only the entries it names,
1273
+ // so a caller that mutates one file in a large directory (a claim-check
1274
+ // move that adds one entry and deletes another) does not re-hash or
1275
+ // re-materialize the untouched siblings. `changedPathPrefixes` is the
1276
+ // handler's scoping hint for the touched region; the caller supplies it
1277
+ // because the delta has no single clear-prefix to derive it from.
1278
+ async function writeTreeDelta(principal, repoId, ref, args) {
1279
+ gateAccess(principal, repoId, ref, "writeTree");
1280
+ return withRepoLock(repoId, async () => {
1281
+ const dir = repoDir(repoId);
1282
+ await storageInitRepo(dir, storageOptsFor(repoId, undefined));
1283
+ // Pin the parent tip ONCE under the lock and hand the same oid to
1284
+ // computeDelta's dedup reads and to the assembly below, so the
1285
+ // pre-image the delta is computed against is exactly the pre-image
1286
+ // it is committed against — no lost-update window, no second
1287
+ // resolve that could (in principle) observe a different tip.
1288
+ const parentCommitSha = await resolveRefSha(dir, ref);
1289
+ const { priorListDirOids, readBlobByOid } = buildPriorTreeClosures(dir, parentCommitSha);
1290
+ const delta = await args.computeDelta(parentCommitSha, {
1291
+ readBlobByOid,
1292
+ listDirOids: priorListDirOids,
1293
+ });
1294
+ for (const p of Object.keys(delta.puts))
1295
+ validateDeltaPath(p, false);
1296
+ for (const d of delta.deletes)
1297
+ validateDeltaPath(d, true);
1298
+ assertDeltaUnambiguous(delta.puts, delta.deletes);
1299
+ assertDeltaScoped(delta.puts, delta.deletes, args.changedPathPrefixes);
1300
+ return writeTreeUnderLock(principal, repoId, ref, {
1301
+ files: delta.puts,
1302
+ deletes: new Set(delta.deletes),
1303
+ changedPathPrefixes: args.changedPathPrefixes,
1304
+ message: args.message,
1305
+ }, { sha: parentCommitSha });
1306
+ });
1307
+ }
1308
+ async function receivePack(principal, repoId, ref, pack, commitSha, expectedOldSha) {
1309
+ gateAccess(principal, repoId, ref, "receivePack");
1310
+ // The lock spans the entire substrate body of receivePack: the
1311
+ // packfile index, the CAS check against `expectedOldSha`, the
1312
+ // validateTree hook, the ref write, and the onRefUpdated hook.
1313
+ // `oldSha` for onRefUpdated is taken from the receivePackObjects
1314
+ // return value so the post-update hook sees the same pre-image the
1315
+ // CAS read observed, without a second resolveRef.
1316
+ return withRepoLock(repoId, async () => {
1317
+ const dir = repoDir(repoId);
1318
+ await storageInitRepo(dir, storageOptsFor(repoId, undefined));
1319
+ const handler = handlerFor(repoId);
1320
+ const transferId = crypto.randomUUID().replace(/-/g, "");
1321
+ const existingKey = indexCacheKey(repoId);
1322
+ let existingCommits = existingCommitsCache.get(existingKey);
1323
+ if (existingCommits === undefined) {
1324
+ existingCommits = await snapshotExistingCommits(dir);
1325
+ existingCommitsCache.set(existingKey, existingCommits);
1326
+ }
1327
+ const newCommitsFromPack = [];
1328
+ const oldSha = await receivePackObjects(dir, pack, ref, commitSha, transferId, expectedOldSha,
1329
+ // A pack may carry more than one new commit (e.g. supervisor
1330
+ // bootstrap that batches enqueue + dequeue before the hub has
1331
+ // the workflow-run repo bootstrapped). The kind handler's
1332
+ // prior-tree closures must point at THAT commit's parent — not
1333
+ // the ref's tip before the pack arrived — so an intra-pack
1334
+ // transition (inbox in commit N, processing in commit N+1) is
1335
+ // validated against the right pre-image. We walk the parent
1336
+ // chain from tip back to the first commit already present in
1337
+ // the pre-pack history, then call validatePush once per new
1338
+ // commit in topological (oldest-first) order. A single-commit
1339
+ // pack collapses to the same behaviour as the tip-only path.
1340
+ async () => {
1341
+ const newCommits = await collectNewCommits(dir, commitSha, expectedOldSha, existingCommits);
1342
+ newCommitsFromPack.push(...newCommits);
1343
+ for (const newCommit of newCommits) {
1344
+ const { commit: parsed } = await git.readCommit({
1345
+ fs,
1346
+ dir,
1347
+ cache: cacheFor(dir),
1348
+ oid: newCommit,
1349
+ });
1350
+ const parents = parsed.parent;
1351
+ const declaredParent = parents.length === 0 ? null : (parents[0] ?? null);
1352
+ // When the commit declares a parent the receiver does not
1353
+ // have in its object store, the substrate cannot
1354
+ // reconstruct the prior tree the kind handler would
1355
+ // validate against. For kinds whose handler reads prior
1356
+ // bytes to enforce append-only invariants (workflow-run),
1357
+ // silently degrading to "empty prior" lets a path in the
1358
+ // new commit that overwrites an immutable prior-tree
1359
+ // entry slip through unchecked — append-only enforcement
1360
+ // accepts a brand-new path, not a path that contradicts
1361
+ // an entry the handler cannot read. The workflow-run
1362
+ // `createPack` above ships the full parent chain, so this
1363
+ // branch is unreachable on the production pack-push path
1364
+ // for that kind; a producer that does ship an incomplete
1365
+ // chain is rejected outright.
1366
+ //
1367
+ // Other kinds ship deploy-shape packs (tip commit + tree
1368
+ // only) as their normal flow and their handlers do not
1369
+ // read prior bytes, so a dangling parent there is
1370
+ // structurally normal and the substrate continues to
1371
+ // collapse to the no-prior path.
1372
+ let parentSha = null;
1373
+ if (declaredParent !== null) {
1374
+ try {
1375
+ await git.readCommit({
1376
+ fs,
1377
+ dir,
1378
+ cache: cacheFor(dir),
1379
+ oid: declaredParent,
1380
+ });
1381
+ parentSha = declaredParent;
1382
+ }
1383
+ catch (err) {
1384
+ if (!hasCode(err) || err.code !== "NotFoundError")
1385
+ throw err;
1386
+ if (repoId.kind === "workflow-run") {
1387
+ throw new Error(`pack_walk_dangling_parent: commit ${newCommit} declares parent ${declaredParent} which is neither in the receiver's store nor in the pack`);
1388
+ }
1389
+ }
1390
+ }
1391
+ const { priorReadBlob, priorListDir, priorListDirOids } = buildPriorTreeClosures(dir, parentSha);
1392
+ const { topLevelTreePaths, readBlob, listDir } = await buildCommitTreeClosures(dir, newCommit);
1393
+ const changedPathPrefixes = await computeChangedPathPrefixes(dir, newCommit, parentSha);
1394
+ const result = await handler.validatePush({
1395
+ repoId,
1396
+ ref,
1397
+ principal,
1398
+ topLevelTreePaths,
1399
+ readBlob,
1400
+ listDir,
1401
+ priorReadBlob,
1402
+ priorListDir,
1403
+ priorListDirOids,
1404
+ changedPathPrefixes,
1405
+ });
1406
+ if (!result.ok) {
1407
+ logger.debug `validatePush rejected ${repoId.kind}/${repoId.id} on ${ref} at commit ${newCommit}: ${result.reason}`;
1408
+ return { ok: false, reason: result.reason };
1409
+ }
1410
+ }
1411
+ return true;
1412
+ });
1413
+ // receivePackObjects wrote new objects and advanced the ref
1414
+ // straight to disk without threading the memoization cache, so
1415
+ // drop the dir's cache; the next read rebuilds against the packed
1416
+ // objects and the new tip.
1417
+ invalidateGitCache(dir);
1418
+ for (const sha of newCommitsFromPack)
1419
+ existingCommits.add(sha);
1420
+ await handler.onRefUpdated({ repoId, ref, oldSha, newSha: commitSha });
1421
+ await emitRefUpdate(repoId, ref, oldSha, commitSha);
1422
+ await maybeRunGC(repoId);
1423
+ });
1424
+ }
1425
+ async function createPack(principal, repoId, ref) {
1426
+ gateAccess(principal, repoId, ref, "createPack");
1427
+ // `createDeployPack` packs only the tip commit + its tree, which
1428
+ // covers the deploy-pack shape every other kind ships (the
1429
+ // receiver starts from genesis and applies the tree wholesale).
1430
+ // The workflow-run kind ships incrementally and the receiver
1431
+ // (the hub) needs to validate per-commit transitions against the
1432
+ // sender's prior tree, so the pack must carry the full parent
1433
+ // chain from the supplied ref's tip. Walking the chain here keeps
1434
+ // the kind-specific branching at the substrate boundary so the
1435
+ // kind handler stays receive-side only.
1436
+ if (repoId.kind === "workflow-run") {
1437
+ const dir = repoDir(repoId);
1438
+ const commitSha = await git.resolveRef({ fs, dir, ref });
1439
+ const tipKey = lastPackedTipKey(repoId, ref);
1440
+ const stopAt = lastPackedTip.get(tipKey) ?? null;
1441
+ const oids = await collectChainReachableObjects(dir, commitSha, stopAt);
1442
+ const result = await git.packObjects({
1443
+ fs,
1444
+ dir,
1445
+ cache: cacheFor(dir),
1446
+ oids,
1447
+ write: false,
1448
+ });
1449
+ if (result.packfile === undefined) {
1450
+ throw new Error(`packObjects returned no packfile for ref "${ref}" (${commitSha})`);
1451
+ }
1452
+ // The cursor is NOT advanced here. Building a pack only produces
1453
+ // bytes; the shipped tip advances in `commitPackedTip` once the
1454
+ // receiver acks the transfer that carried these commits. A
1455
+ // transfer a reconnect cancels before its ack thus leaves the
1456
+ // cursor where it was, so the next `createPack` re-includes the
1457
+ // un-acked commits and the receiver still gets a self-consistent
1458
+ // chain instead of a pack whose base commit it never received.
1459
+ return { pack: result.packfile, commitSha, ref };
1460
+ }
1461
+ const { pack, commitSha } = await createDeployPack(repoDir(repoId), ref);
1462
+ return { pack, commitSha, ref };
1463
+ }
1464
+ // Advance the incremental-pack cursor for `(repoId, ref)` to
1465
+ // `commitSha`. Called by the pack-push pipeline once the receiver has
1466
+ // acked the transfer that shipped `commitSha`. Gating the advance on
1467
+ // the ack — rather than advancing inside `createPack` when the pack
1468
+ // bytes are produced — is what lets a cancelled-before-ack transfer
1469
+ // (a reconnect drops the in-flight push) be re-shipped cleanly: the
1470
+ // cursor stays put, so the next `createPack` re-includes the un-acked
1471
+ // commits and the receiver gets a self-consistent chain. Only the
1472
+ // `workflow-run` kind ships incremental packs; for every other kind
1473
+ // `createPack` produces a self-contained deploy pack and the cursor
1474
+ // is unused, so this is a no-op there.
1475
+ function commitPackedTip(repoId, ref, commitSha) {
1476
+ if (repoId.kind !== "workflow-run")
1477
+ return;
1478
+ lastPackedTip.set(lastPackedTipKey(repoId, ref), commitSha);
1479
+ }
1480
+ // Collect every object OID reachable from `tipSha` and from every
1481
+ // ancestor commit along its first-parent chain. Mirrors what the
1482
+ // upload-pack layer's negotiated walker produces when the requester
1483
+ // advertises no `haves` but is sized for the substrate's own use:
1484
+ // a workflow-run pack push from the supervisor needs to carry every
1485
+ // commit the hub does not yet have, and the substrate has no
1486
+ // negotiation channel to ask the hub what it already has. The walk
1487
+ // stops at the first parent whose commit object is not in the local
1488
+ // store — that commit is by definition not part of the local
1489
+ // supervisor's history and so cannot be a relevant ancestor.
1490
+ async function collectChainReachableObjects(dir, tipSha, stopAt) {
1491
+ const seen = new Set();
1492
+ let current = tipSha;
1493
+ while (current !== null) {
1494
+ if (current === stopAt)
1495
+ break;
1496
+ let perCommit = chainReachabilityCache.get(current);
1497
+ if (perCommit === undefined) {
1498
+ perCommit = await collectReachableObjects(dir, current);
1499
+ chainReachabilityCache.set(current, perCommit);
1500
+ }
1501
+ for (const o of perCommit)
1502
+ seen.add(o);
1503
+ let parsed;
1504
+ try {
1505
+ parsed = await git.readCommit({
1506
+ fs,
1507
+ dir,
1508
+ cache: cacheFor(dir),
1509
+ oid: current,
1510
+ });
1511
+ }
1512
+ catch (err) {
1513
+ if (hasCode(err) && err.code === "NotFoundError")
1514
+ break;
1515
+ throw err;
1516
+ }
1517
+ const parents = parsed.commit.parent;
1518
+ if (parents.length === 0)
1519
+ break;
1520
+ const next = parents[0];
1521
+ if (next === undefined)
1522
+ throw new Error("unreachable");
1523
+ current = next;
1524
+ }
1525
+ return Array.from(seen);
1526
+ }
1527
+ async function resolveRef(principal, repoId, ref) {
1528
+ gateAccess(principal, repoId, ref, "resolveRef");
1529
+ return resolveRefSha(repoDir(repoId), ref);
1530
+ }
1531
+ function subscribe(principal, repoId, ref, opts) {
1532
+ gateAccess(principal, repoId, ref, "resolveRef");
1533
+ const bufferLimit = opts.bufferLimit ?? DEFAULT_SUBSCRIBE_BUFFER_LIMIT;
1534
+ if (!Number.isInteger(bufferLimit) || bufferLimit <= 0) {
1535
+ throw new Error(`subscribe_buffer_limit_invalid: ${String(opts.bufferLimit)}`);
1536
+ }
1537
+ const sub = {
1538
+ bufferLimit,
1539
+ buffer: [],
1540
+ closed: false,
1541
+ error: null,
1542
+ waiter: null,
1543
+ };
1544
+ const key = refKey(repoId, ref);
1545
+ let set = subscribers.get(key);
1546
+ if (set === undefined) {
1547
+ set = new Set();
1548
+ subscribers.set(key, set);
1549
+ }
1550
+ set.add(sub);
1551
+ const removeSubscriber = () => {
1552
+ const current = subscribers.get(key);
1553
+ if (current === undefined)
1554
+ return;
1555
+ current.delete(sub);
1556
+ if (current.size === 0)
1557
+ subscribers.delete(key);
1558
+ };
1559
+ const finish = () => {
1560
+ if (sub.closed) {
1561
+ // Already closed by abort or error. Still flush any waiter
1562
+ // so the consumer's pending `next()` resolves promptly.
1563
+ }
1564
+ sub.closed = true;
1565
+ removeSubscriber();
1566
+ if (sub.waiter !== null) {
1567
+ const w = sub.waiter;
1568
+ sub.waiter = null;
1569
+ w({ value: undefined, done: true });
1570
+ }
1571
+ };
1572
+ const onAbort = () => {
1573
+ sub.closed = true;
1574
+ removeSubscriber();
1575
+ if (sub.waiter !== null) {
1576
+ const w = sub.waiter;
1577
+ sub.waiter = null;
1578
+ w({ value: undefined, done: true });
1579
+ }
1580
+ };
1581
+ if (opts.signal.aborted) {
1582
+ // Aborted before any work — return an iterator that yields
1583
+ // {done: true} immediately. The subscriber is registered then
1584
+ // removed for symmetry with the live path's cleanup.
1585
+ onAbort();
1586
+ }
1587
+ else {
1588
+ opts.signal.addEventListener("abort", onAbort, { once: true });
1589
+ }
1590
+ // Replay queue: events sourced from history that the iterator
1591
+ // surfaces before falling through to the live buffer. Filled
1592
+ // synchronously by the first `next()` call (replayHistory does
1593
+ // the git.log walk), then drained one entry per next().
1594
+ let replayQueue = null;
1595
+ let replayPrimed = false;
1596
+ async function primeReplay() {
1597
+ replayPrimed = true;
1598
+ const dir = repoDir(repoId);
1599
+ if (opts.from === "head") {
1600
+ // Seed the seq cache so the next commit on the ref carries
1601
+ // the correct seq even if no prior commit had ever populated
1602
+ // it. Subscribers that come in with `from: "head"` see only
1603
+ // new commits — there is no history to replay.
1604
+ const cached = seqCache.get(key);
1605
+ if (cached === undefined) {
1606
+ const count = await countCommits(dir, ref);
1607
+ if (count > 0)
1608
+ seqCache.set(key, count - 1);
1609
+ }
1610
+ replayQueue = [];
1611
+ return;
1612
+ }
1613
+ const all = await replayHistory(dir, ref);
1614
+ // Seed the seq cache from the replay so live deliveries
1615
+ // continue the seq sequence correctly.
1616
+ if (all.length > 0) {
1617
+ const last = all[all.length - 1];
1618
+ if (last === undefined)
1619
+ throw new Error("unreachable");
1620
+ seqCache.set(key, last.seq);
1621
+ }
1622
+ const fromSeq = opts.from.seq;
1623
+ replayQueue = all.filter((e) => e.seq >= fromSeq);
1624
+ }
1625
+ const iterator = {
1626
+ [Symbol.asyncIterator]() {
1627
+ return iterator;
1628
+ },
1629
+ async next() {
1630
+ if (!replayPrimed) {
1631
+ try {
1632
+ await primeReplay();
1633
+ }
1634
+ catch (err) {
1635
+ finish();
1636
+ throw err;
1637
+ }
1638
+ }
1639
+ if (replayQueue !== null && replayQueue.length > 0) {
1640
+ const entry = replayQueue.shift();
1641
+ if (entry === undefined)
1642
+ throw new Error("unreachable");
1643
+ return { value: entry, done: false };
1644
+ }
1645
+ if (sub.buffer.length > 0) {
1646
+ const entry = sub.buffer.shift();
1647
+ if (entry === undefined)
1648
+ throw new Error("unreachable");
1649
+ return { value: entry, done: false };
1650
+ }
1651
+ if (sub.error !== null) {
1652
+ const err = sub.error;
1653
+ sub.error = null;
1654
+ finish();
1655
+ throw err;
1656
+ }
1657
+ if (sub.closed) {
1658
+ finish();
1659
+ return { value: undefined, done: true };
1660
+ }
1661
+ return new Promise((resolve) => {
1662
+ sub.waiter = resolve;
1663
+ });
1664
+ },
1665
+ async return() {
1666
+ opts.signal.removeEventListener("abort", onAbort);
1667
+ finish();
1668
+ return { value: undefined, done: true };
1669
+ },
1670
+ async throw(err) {
1671
+ opts.signal.removeEventListener("abort", onAbort);
1672
+ finish();
1673
+ throw err;
1674
+ },
1675
+ };
1676
+ return iterator;
1677
+ }
1678
+ return {
1679
+ initRepo,
1680
+ writeTree,
1681
+ writeTreePreservingPrefix,
1682
+ writeTreeDelta,
1683
+ receivePack,
1684
+ createPack,
1685
+ commitPackedTip,
1686
+ resolveRef,
1687
+ listRefs,
1688
+ resolveHead,
1689
+ getRepoDir,
1690
+ subscribe,
1691
+ };
1692
+ }