pi-codex-marketplace 0.1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/extensions/pi/git-registration.ts +138 -0
  4. package/extensions/pi/index.ts +293 -0
  5. package/extensions/pi/installation.ts +90 -0
  6. package/extensions/pi/journal.ts +80 -0
  7. package/extensions/pi/lifecycle.ts +285 -0
  8. package/extensions/pi/registration.ts +143 -0
  9. package/extensions/pi/scope-overrides.ts +170 -0
  10. package/package.json +60 -0
  11. package/src/barrier/global-barrier.ts +105 -0
  12. package/src/bridge-state/atomic.ts +237 -0
  13. package/src/bridge-state/index.ts +5 -0
  14. package/src/bridge-state/migrate.ts +261 -0
  15. package/src/bridge-state/paths.ts +75 -0
  16. package/src/bridge-state/repair.ts +185 -0
  17. package/src/bridge-state/schema.ts +70 -0
  18. package/src/bridge-state/store.ts +489 -0
  19. package/src/bridge-state/types.ts +170 -0
  20. package/src/cache/index.ts +2 -0
  21. package/src/cache/paths.ts +42 -0
  22. package/src/cache/source-cache.ts +365 -0
  23. package/src/compatibility/index.ts +1 -0
  24. package/src/compatibility/profile.ts +328 -0
  25. package/src/installation/flow.ts +443 -0
  26. package/src/installation/index.ts +1 -0
  27. package/src/installation/inspection.ts +129 -0
  28. package/src/journal/active-chains.ts +99 -0
  29. package/src/journal/index.ts +3 -0
  30. package/src/journal/journal.ts +215 -0
  31. package/src/journal/types.ts +49 -0
  32. package/src/lifecycle/index.ts +5 -0
  33. package/src/lifecycle/rebind.ts +290 -0
  34. package/src/lifecycle/refresh.ts +407 -0
  35. package/src/lifecycle/removal.ts +457 -0
  36. package/src/lifecycle/update-plan.ts +222 -0
  37. package/src/lifecycle/update.ts +303 -0
  38. package/src/projection/collision.ts +120 -0
  39. package/src/projection/effective-state.ts +182 -0
  40. package/src/projection/index.ts +4 -0
  41. package/src/projection/overrides.ts +230 -0
  42. package/src/projection/project.ts +359 -0
  43. package/src/reconciliation/startup.ts +144 -0
  44. package/src/registration/budget.ts +28 -0
  45. package/src/registration/catalog.ts +224 -0
  46. package/src/registration/contained.ts +140 -0
  47. package/src/registration/fence.ts +86 -0
  48. package/src/registration/findings.ts +188 -0
  49. package/src/registration/flow.ts +619 -0
  50. package/src/registration/git-acquisition.ts +481 -0
  51. package/src/registration/git-flow.ts +654 -0
  52. package/src/registration/git-locator.ts +380 -0
  53. package/src/registration/git-selector.ts +279 -0
  54. package/src/registration/index.ts +16 -0
  55. package/src/registration/receipt.ts +305 -0
  56. package/src/registration/registration.ts +102 -0
  57. package/src/registration/snapshot.ts +382 -0
  58. package/src/registration/source-key.ts +111 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Source Cache path helpers (#22).
3
+ * The Git-only Source Cache lives at `${getAgentDir()}/codex-marketplace/cache`.
4
+ *
5
+ * Layout:
6
+ * cache/
7
+ * entries/<fingerprint>/ — fingerprint-addressed acquired source trees
8
+ * entries/<fingerprint>.meta — { fingerprint, bytes, lastAccessMs, recordedAtMs }
9
+ * index.json — (canonicalLocator ⊕ selector) → last validated fingerprint
10
+ * pending-updates.json — fingerprints of produced-but-unapplied Update Candidates
11
+ * locks/<fingerprint>.lock — per-fingerprint mutual exclusion
12
+ */
13
+
14
+ import { join } from 'node:path';
15
+
16
+ import { getGlobalStateDir } from '../bridge-state/paths.js';
17
+
18
+ export const CACHE_ENTRIES_DIR = 'entries';
19
+ export const CACHE_INDEX_FILENAME = 'index.json';
20
+ export const CACHE_PENDING_FILENAME = 'pending-updates.json';
21
+ export const CACHE_LOCKS_DIR = 'locks';
22
+ export const CACHE_META_SUFFIX = '.meta';
23
+
24
+ export function getCacheDir(agentDir?: string): string {
25
+ return join(getGlobalStateDir(agentDir), 'cache');
26
+ }
27
+
28
+ export function getCacheEntriesDir(cacheDir: string): string {
29
+ return join(cacheDir, CACHE_ENTRIES_DIR);
30
+ }
31
+
32
+ export function getCacheIndexPath(cacheDir: string): string {
33
+ return join(cacheDir, CACHE_INDEX_FILENAME);
34
+ }
35
+
36
+ export function getCachePendingPath(cacheDir: string): string {
37
+ return join(cacheDir, CACHE_PENDING_FILENAME);
38
+ }
39
+
40
+ export function getCacheLocksDir(cacheDir: string): string {
41
+ return join(cacheDir, CACHE_LOCKS_DIR);
42
+ }
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Source Cache — Git-only, fingerprint-addressed acquisition cache (#22).
3
+ * See CONTEXT.md: Source Acquisition, Validation Snapshot, Stale Snapshot.
4
+ *
5
+ * Guarantees:
6
+ * - Entries are addressed by Validation Snapshot fingerprint under
7
+ * `${getAgentDir()}/codex-marketplace/cache/entries/<fingerprint>`.
8
+ * - Pinned set = Bridge State referenced fingerprints (registrations + installations)
9
+ * + pending Update Candidate fingerprints + in-flight pins. Pinned entries are never evicted.
10
+ * - Total budget (default 2 GiB) applies LRU eviction over unpinned entries only,
11
+ * synchronously during store/prune. No background tasks, no TTL.
12
+ * - Every entry mutation happens under a per-fingerprint lock (mutual exclusion).
13
+ * - Offline reuse is allowed only on an exact fingerprint hit; a Stale Snapshot is never
14
+ * converted into success — verification is the caller's re-hashed snapshot comparison,
15
+ * and a mismatch is a Blocking Finding (Source Drift / Stale Snapshot), never reuse.
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ rmSync,
24
+ statSync,
25
+ writeFileSync,
26
+ } from 'node:fs';
27
+ import { cpSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ import { acquireLock, releaseLock, atomicWriteFile } from '../bridge-state/atomic.js';
31
+ import { readBridgeState } from '../bridge-state/store.js';
32
+ import type { BridgeState } from '../bridge-state/types.js';
33
+ import type { Scope } from '../bridge-state/types.js';
34
+ import {
35
+ getCacheDir,
36
+ getCacheEntriesDir,
37
+ getCacheIndexPath,
38
+ getCacheLocksDir,
39
+ getCachePendingPath,
40
+ } from './paths.js';
41
+
42
+ /** Total cache budget: 2 GiB across all entries (unpinned only are evictable). */
43
+ export const CACHE_TOTAL_BUDGET_BYTES = 2 * 1024 * 1024 * 1024;
44
+
45
+ export interface CacheEntryMeta {
46
+ fingerprint: string;
47
+ bytes: number;
48
+ lastAccessMs: number;
49
+ recordedAtMs: number;
50
+ }
51
+
52
+ export interface CacheIndexRecord {
53
+ /** Last validated Validation Snapshot fingerprint for this locator+selector pair. */
54
+ fingerprint: string;
55
+ resolvedRevision: string;
56
+ canonicalLocator: string;
57
+ selectorCanonical: string;
58
+ recordedAtMs: number;
59
+ }
60
+
61
+ export interface PendingUpdateRecord {
62
+ scope: Scope;
63
+ registrationId: string;
64
+ fingerprint: string;
65
+ recordedAtMs: number;
66
+ }
67
+
68
+ export interface SourceCacheOptions {
69
+ /** Pi agent dir; defaults to getAgentDir(). */
70
+ agentDir?: string;
71
+ /** Explicit cache root override (test seam). */
72
+ root?: string;
73
+ /** Budget override for tests. */
74
+ budgetBytes?: number;
75
+ /** Injectable clock for deterministic lastAccess ordering in tests. */
76
+ clock?: () => number;
77
+ }
78
+
79
+ export interface CacheHit {
80
+ path: string;
81
+ fingerprint: string;
82
+ }
83
+
84
+ function isSafeFingerprint(fp: string): boolean {
85
+ return /^[0-9a-f]{64}$/.test(fp);
86
+ }
87
+
88
+ export class SourceCache {
89
+ readonly root: string;
90
+ private readonly budgetBytes: number;
91
+ private readonly now: () => number;
92
+ private readonly inFlight = new Map<string, number>();
93
+
94
+ constructor(opts: SourceCacheOptions = {}) {
95
+ this.root = opts.root ?? getCacheDir(opts.agentDir);
96
+ this.budgetBytes = opts.budgetBytes ?? CACHE_TOTAL_BUDGET_BYTES;
97
+ this.now = opts.clock ?? Date.now;
98
+ }
99
+
100
+ entryPath(fingerprint: string): string {
101
+ return join(getCacheEntriesDir(this.root), fingerprint);
102
+ }
103
+
104
+ metaPath(fingerprint: string): string {
105
+ return `${this.entryPath(fingerprint)}.meta`;
106
+ }
107
+
108
+ lockPath(fingerprint: string): string {
109
+ return join(getCacheLocksDir(this.root), `${fingerprint}.lock`);
110
+ }
111
+
112
+ /**
113
+ * Per-fingerprint mutual exclusion around store/prune/touch for that entry.
114
+ * Concurrent operations on the same fingerprint serialize here.
115
+ */
116
+ async withFingerprintLock<T>(fingerprint: string, fn: () => Promise<T> | T, timeoutMs = 5000): Promise<T> {
117
+ const lockPath = this.lockPath(fingerprint);
118
+ const fd = await acquireLock(lockPath, timeoutMs);
119
+ try {
120
+ return await fn();
121
+ } finally {
122
+ releaseLock(fd, lockPath);
123
+ }
124
+ }
125
+
126
+ /** Register an in-flight pin (reference-counted); returns its release function. In-flight entries are never evicted. */
127
+ pinInFlight(fingerprint: string): () => void {
128
+ this.inFlight.set(fingerprint, (this.inFlight.get(fingerprint) ?? 0) + 1);
129
+ let released = false;
130
+ return () => {
131
+ if (released) return;
132
+ released = true;
133
+ const n = (this.inFlight.get(fingerprint) ?? 1) - 1;
134
+ if (n <= 0) this.inFlight.delete(fingerprint);
135
+ else this.inFlight.set(fingerprint, n);
136
+ };
137
+ }
138
+
139
+ /** Exact fingerprint hit: entry directory + matching meta must exist. Touches lastAccess. */
140
+ async hitExact(fingerprint: string): Promise<CacheHit | null> {
141
+ if (!isSafeFingerprint(fingerprint)) return null;
142
+ const dir = this.entryPath(fingerprint);
143
+ const meta = this.readMeta(fingerprint);
144
+ if (!existsSync(dir) || !meta || meta.fingerprint !== fingerprint) return null;
145
+ await this.withFingerprintLock(fingerprint, () => {
146
+ this.touchMeta(fingerprint);
147
+ });
148
+ return { path: dir, fingerprint };
149
+ }
150
+
151
+ /**
152
+ * Store an acquired source tree under its fingerprint. The stored fingerprint is pinned
153
+ * for the duration of the store ("in flight"), so its own prune can never evict it.
154
+ */
155
+ async storeTree(root: string, fingerprint: string): Promise<{ stored: boolean; path: string }> {
156
+ const releasePin = this.pinInFlight(fingerprint);
157
+ try {
158
+ return await this.withFingerprintLock(fingerprint, async () => {
159
+ mkdirSync(getCacheEntriesDir(this.root), { recursive: true });
160
+ const dest = this.entryPath(fingerprint);
161
+ if (existsSync(dest)) {
162
+ this.touchMeta(fingerprint);
163
+ return { stored: false, path: dest };
164
+ }
165
+ const bytes = treeBytes(root);
166
+ // Copy to a temp name first so readers never observe a partial tree.
167
+ const tmp = `${dest}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
168
+ cpSync(root, tmp, { recursive: true, verbatimSymlinks: true });
169
+ // Replace any pre-existing (possibly tampered) entry wholesale so a stored
170
+ // fingerprint always hashes true.
171
+ rmSync(dest, { recursive: true, force: true });
172
+ copyTree(tmp, dest);
173
+ this.writeMeta(fingerprint, bytes);
174
+ await this.prune();
175
+ return { stored: true, path: dest };
176
+ });
177
+ } finally {
178
+ releasePin();
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Synchronous LRU prune over unpinned entries only. Pinned set =
184
+ * Bridge State references + pending Update Candidates + in-flight pins + extraPinned.
185
+ * No background tasks; called inline after stores.
186
+ */
187
+ async prune(extraPinned: Iterable<string> = []): Promise<void> {
188
+ const pinned = new Set<string>(extraPinned);
189
+ for (const fp of this.inFlight.keys()) pinned.add(fp);
190
+ for (const rec of this.pendingUpdates()) pinned.add(rec.fingerprint);
191
+ for (const fp of await this.statePinnedFingerprints()) pinned.add(fp);
192
+
193
+ type Cand = { fingerprint: string; bytes: number; lastAccessMs: number };
194
+ const cands: Cand[] = [];
195
+ const entriesDir = getCacheEntriesDir(this.root);
196
+ if (!existsSync(entriesDir)) return;
197
+ let total = 0;
198
+ for (const name of readdirSync(entriesDir)) {
199
+ // Skip in-progress temp copies.
200
+ if (name.includes('.tmp-')) continue;
201
+ const meta = this.readMeta(name);
202
+ if (!meta || !isSafeFingerprint(name)) continue;
203
+ total += meta.bytes;
204
+ if (!pinned.has(name)) cands.push({ fingerprint: name, bytes: meta.bytes, lastAccessMs: meta.lastAccessMs });
205
+ }
206
+ // LRU over unpinned only, ascending lastAccess.
207
+ cands.sort((a, b) => a.lastAccessMs - b.lastAccessMs);
208
+ for (const cand of cands) {
209
+ if (total <= this.budgetBytes) break;
210
+ rmSync(this.entryPath(cand.fingerprint), { recursive: true, force: true });
211
+ rmSync(this.metaPath(cand.fingerprint), { force: true });
212
+ total -= cand.bytes;
213
+ }
214
+ }
215
+
216
+ /** Fingerprints referenced by authoritative Bridge State (both scopes). Never evicted. */
217
+ async statePinnedFingerprints(): Promise<Set<string>> {
218
+ const pinned = new Set<string>();
219
+ for (const scope of ['global', 'project'] as const) {
220
+ const read = await readBridgeState(scope, {});
221
+ collectPinned(read.state, pinned);
222
+ }
223
+ return pinned;
224
+ }
225
+
226
+ /** Collect pinned fingerprints from already-read state documents (test seam, no I/O). */
227
+ static pinnedFromStates(states: (BridgeState | undefined)[]): Set<string> {
228
+ const pinned = new Set<string>();
229
+ for (const s of states) collectPinned(s, pinned);
230
+ return pinned;
231
+ }
232
+
233
+ // ---- Pending Update Candidate registry ---------------------------------
234
+
235
+ recordPendingUpdate(rec: Omit<PendingUpdateRecord, 'recordedAtMs'>): void {
236
+ const records = this.pendingUpdates().filter((r) => !(r.scope === rec.scope && r.registrationId === rec.registrationId));
237
+ records.push({ ...rec, recordedAtMs: this.now() });
238
+ atomicWriteFile(getCachePendingPath(this.root), JSON.stringify(records));
239
+ }
240
+
241
+ clearPendingUpdate(scope: Scope, registrationId: string): void {
242
+ const records = this.pendingUpdates().filter((r) => !(r.scope === scope && r.registrationId === registrationId));
243
+ atomicWriteFile(getCachePendingPath(this.root), JSON.stringify(records));
244
+ }
245
+
246
+ pendingUpdates(): PendingUpdateRecord[] {
247
+ const p = getCachePendingPath(this.root);
248
+ if (!existsSync(p)) return [];
249
+ try {
250
+ const parsed: unknown = JSON.parse(readFileSync(p, 'utf-8'));
251
+ return Array.isArray(parsed) ? (parsed as PendingUpdateRecord[]) : [];
252
+ } catch {
253
+ return [];
254
+ }
255
+ }
256
+
257
+ // ---- Locator+selector → fingerprint index ------------------------------
258
+
259
+ recordIndex(rec: Omit<CacheIndexRecord, 'recordedAtMs'>): void {
260
+ const index = this.readIndex();
261
+ index[indexKey(rec.canonicalLocator, rec.selectorCanonical)] = { ...rec, recordedAtMs: this.now() };
262
+ atomicWriteFile(getCacheIndexPath(this.root), JSON.stringify(index));
263
+ }
264
+
265
+ readIndex(): Record<string, CacheIndexRecord> {
266
+ const p = getCacheIndexPath(this.root);
267
+ if (!existsSync(p)) return {};
268
+ try {
269
+ const parsed: unknown = JSON.parse(readFileSync(p, 'utf-8'));
270
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, CacheIndexRecord>) : {};
271
+ } catch {
272
+ return {};
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Offline reuse seam: only an exact fingerprint hit counts. Returns the cached tree only when
278
+ * the index records exactly `expectedFingerprint` for this locator+selector AND that exact
279
+ * entry is present in the cache. The hit refreshes LRU recency. Callers must still verify by
280
+ * re-hashing the tree; any mismatch is a Blocking Finding and never success.
281
+ */
282
+ async offlineHit(canonicalLocator: string, selectorCanonical: string, expectedFingerprint: string): Promise<CacheHit | null> {
283
+ if (!expectedFingerprint) return null;
284
+ const rec = this.readIndex()[indexKey(canonicalLocator, selectorCanonical)];
285
+ if (!rec || rec.fingerprint !== expectedFingerprint) return null;
286
+ const dir = this.entryPath(expectedFingerprint);
287
+ const meta = this.readMeta(expectedFingerprint);
288
+ if (!existsSync(dir) || !meta || meta.fingerprint !== expectedFingerprint) return null;
289
+ await this.withFingerprintLock(expectedFingerprint, () => {
290
+ this.touchMeta(expectedFingerprint);
291
+ });
292
+ return { path: dir, fingerprint: expectedFingerprint };
293
+ }
294
+
295
+ // ---- internals ----------------------------------------------------------
296
+
297
+ private readMeta(fingerprint: string): CacheEntryMeta | null {
298
+ const p = this.metaPath(fingerprint);
299
+ if (!existsSync(p)) return null;
300
+ try {
301
+ const parsed: unknown = JSON.parse(readFileSync(p, 'utf-8'));
302
+ if (parsed && typeof parsed === 'object' && typeof (parsed as CacheEntryMeta).fingerprint === 'string') {
303
+ return parsed as CacheEntryMeta;
304
+ }
305
+ return null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ private touchMeta(fingerprint: string): void {
312
+ const meta = this.readMeta(fingerprint);
313
+ if (!meta) return;
314
+ meta.lastAccessMs = this.now();
315
+ this.writeMeta(fingerprint, meta.bytes, meta.recordedAtMs);
316
+ }
317
+
318
+ private writeMeta(fingerprint: string, bytes: number, recordedAtMs?: number): void {
319
+ const at = this.now();
320
+ atomicWriteFile(
321
+ this.metaPath(fingerprint),
322
+ JSON.stringify({ fingerprint, bytes, lastAccessMs: at, recordedAtMs: recordedAtMs ?? at } satisfies CacheEntryMeta),
323
+ );
324
+ }
325
+ }
326
+
327
+ /** Canonical index key for a locator+selector pair. */
328
+ function indexKey(canonicalLocator: string, selectorCanonical: string): string {
329
+ return `${canonicalLocator}\u001f${selectorCanonical}`;
330
+ }
331
+
332
+ /** Move a fully-written temp copy into place; a vanished source is a no-op. */
333
+ function copyTree(from: string, to: string): void {
334
+ try {
335
+ statSync(from);
336
+ cpSync(from, to, { recursive: true, verbatimSymlinks: true });
337
+ rmSync(from, { recursive: true, force: true });
338
+ } catch {}
339
+ }
340
+
341
+ function collectPinned(state: BridgeState | undefined, pinned: Set<string>): void {
342
+ if (!state) return;
343
+ for (const r of state.registrations ?? []) if (r.validationSnapshot) pinned.add(r.validationSnapshot);
344
+ for (const i of state.installations ?? []) if (i.validationSnapshot) pinned.add(i.validationSnapshot);
345
+ }
346
+
347
+ /** Recursive byte size of a tree (files only). */
348
+ export function treeBytes(root: string): number {
349
+ let total = 0;
350
+ const walk = (dir: string): void => {
351
+ let stats;
352
+ try {
353
+ stats = statSync(dir);
354
+ } catch {
355
+ return;
356
+ }
357
+ if (!stats.isDirectory()) {
358
+ total += stats.size;
359
+ return;
360
+ }
361
+ for (const name of readdirSync(dir)) walk(join(dir, name));
362
+ };
363
+ walk(root);
364
+ return total;
365
+ }
@@ -0,0 +1 @@
1
+ export * from './profile.js';