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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/extensions/pi/git-registration.ts +138 -0
- package/extensions/pi/index.ts +293 -0
- package/extensions/pi/installation.ts +90 -0
- package/extensions/pi/journal.ts +80 -0
- package/extensions/pi/lifecycle.ts +285 -0
- package/extensions/pi/registration.ts +143 -0
- package/extensions/pi/scope-overrides.ts +170 -0
- package/package.json +60 -0
- package/src/barrier/global-barrier.ts +105 -0
- package/src/bridge-state/atomic.ts +237 -0
- package/src/bridge-state/index.ts +5 -0
- package/src/bridge-state/migrate.ts +261 -0
- package/src/bridge-state/paths.ts +75 -0
- package/src/bridge-state/repair.ts +185 -0
- package/src/bridge-state/schema.ts +70 -0
- package/src/bridge-state/store.ts +489 -0
- package/src/bridge-state/types.ts +170 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/paths.ts +42 -0
- package/src/cache/source-cache.ts +365 -0
- package/src/compatibility/index.ts +1 -0
- package/src/compatibility/profile.ts +328 -0
- package/src/installation/flow.ts +443 -0
- package/src/installation/index.ts +1 -0
- package/src/installation/inspection.ts +129 -0
- package/src/journal/active-chains.ts +99 -0
- package/src/journal/index.ts +3 -0
- package/src/journal/journal.ts +215 -0
- package/src/journal/types.ts +49 -0
- package/src/lifecycle/index.ts +5 -0
- package/src/lifecycle/rebind.ts +290 -0
- package/src/lifecycle/refresh.ts +407 -0
- package/src/lifecycle/removal.ts +457 -0
- package/src/lifecycle/update-plan.ts +222 -0
- package/src/lifecycle/update.ts +303 -0
- package/src/projection/collision.ts +120 -0
- package/src/projection/effective-state.ts +182 -0
- package/src/projection/index.ts +4 -0
- package/src/projection/overrides.ts +230 -0
- package/src/projection/project.ts +359 -0
- package/src/reconciliation/startup.ts +144 -0
- package/src/registration/budget.ts +28 -0
- package/src/registration/catalog.ts +224 -0
- package/src/registration/contained.ts +140 -0
- package/src/registration/fence.ts +86 -0
- package/src/registration/findings.ts +188 -0
- package/src/registration/flow.ts +619 -0
- package/src/registration/git-acquisition.ts +481 -0
- package/src/registration/git-flow.ts +654 -0
- package/src/registration/git-locator.ts +380 -0
- package/src/registration/git-selector.ts +279 -0
- package/src/registration/index.ts +16 -0
- package/src/registration/receipt.ts +305 -0
- package/src/registration/registration.ts +102 -0
- package/src/registration/snapshot.ts +382 -0
- package/src/registration/source-key.ts +111 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge State Store — dual-document atomic persistence.
|
|
3
|
+
*
|
|
4
|
+
* Each scope (global/project) has its own file:
|
|
5
|
+
* global: {getAgentDir()}/codex-marketplace/state.json
|
|
6
|
+
* project: {cwd}/.pi/codex-marketplace/state.json
|
|
7
|
+
*
|
|
8
|
+
* Guarantees:
|
|
9
|
+
* - State Revision monotonic per scope (opaque numeric string)
|
|
10
|
+
* - Atomic write: temp → fsync → rename + dir fsync
|
|
11
|
+
* - File lock protects RMW races
|
|
12
|
+
* - Read-after-verify after every write
|
|
13
|
+
* - Closed corruption handling: corrupted / incompatible => not auto-rollback, caller sees Indeterminate/incompatible
|
|
14
|
+
*
|
|
15
|
+
* Only authoritative fields are persisted; Effective State etc are derived at read time (not stored).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { atomicWriteFile, acquireLock, releaseLock } from './atomic.js';
|
|
22
|
+
import { commitMigratedState, migrateForward, recoverWalIfNeeded } from './migrate.js';
|
|
23
|
+
import { getLockPath, getStatePath } from './paths.js';
|
|
24
|
+
import { parseJson, validateSchema } from './schema.js';
|
|
25
|
+
import {
|
|
26
|
+
CURRENT_SCHEMA_VERSION,
|
|
27
|
+
createEmptyState,
|
|
28
|
+
nextRevision,
|
|
29
|
+
type BridgeState,
|
|
30
|
+
type ReadResult,
|
|
31
|
+
type Scope,
|
|
32
|
+
type WriteResult,
|
|
33
|
+
} from './types.js';
|
|
34
|
+
|
|
35
|
+
export interface StoreOptions {
|
|
36
|
+
cwd?: string;
|
|
37
|
+
agentDir?: string;
|
|
38
|
+
/** lock timeout ms (default 5000) */
|
|
39
|
+
lockTimeoutMs?: number;
|
|
40
|
+
/** Refuse the atomic mutation unless this is still the current State Revision under lock. */
|
|
41
|
+
expectedStateRevision?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read a scope's Bridge State with closed handling of missing/corrupted/incompatible + WAL forward migration. */
|
|
45
|
+
export async function readBridgeState(scope: Scope, opts: StoreOptions = {}): Promise<ReadResult> {
|
|
46
|
+
const statePath = getStatePath(scope, opts);
|
|
47
|
+
|
|
48
|
+
if (!existsSync(statePath)) {
|
|
49
|
+
// WAL recovery may have materialized a file after a prior crash — attempt lock-protected replay
|
|
50
|
+
const lockPath = getLockPath(statePath);
|
|
51
|
+
if (!existsSync(lockPath)) {
|
|
52
|
+
const recovered = recoverWalIfNeeded(statePath, null);
|
|
53
|
+
if (recovered.recovered && recovered.state) {
|
|
54
|
+
return { status: 'ok', state: recovered.state };
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
// WAL replay is deferred while another process holds the migration lock; treat as missing and let holder commit
|
|
58
|
+
const walPath = statePath + '.wal';
|
|
59
|
+
if (!existsSync(walPath)) return { status: 'missing', state: createEmptyState(), isEmptyInit: true };
|
|
60
|
+
}
|
|
61
|
+
return { status: 'missing', state: createEmptyState(), isEmptyInit: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let content: string;
|
|
65
|
+
try {
|
|
66
|
+
content = readFileSync(statePath, 'utf-8');
|
|
67
|
+
} catch (e) {
|
|
68
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
69
|
+
return { status: 'corrupted', error: `Failed to read ${statePath}: ${msg}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (content.trim().length === 0) {
|
|
73
|
+
return { status: 'corrupted', error: 'Empty file (corrupted)', raw: content };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const parsed = parseJson(content);
|
|
77
|
+
if (!parsed.ok) {
|
|
78
|
+
return { status: 'corrupted', error: parsed.error, raw: content };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Attempt WAL recovery before schema validation — lock-protected to avoid racing a concurrent commitMigratedState
|
|
82
|
+
const preState = parsed.value as BridgeState;
|
|
83
|
+
const hasVersion = typeof (preState as unknown as Record<string, unknown>).schemaVersion === 'number';
|
|
84
|
+
if (hasVersion) {
|
|
85
|
+
const lockPath = getLockPath(statePath);
|
|
86
|
+
if (!existsSync(lockPath)) {
|
|
87
|
+
const walRecovered = recoverWalIfNeeded(statePath, preState);
|
|
88
|
+
if (walRecovered.recovered && walRecovered.state) {
|
|
89
|
+
return { status: 'ok', state: walRecovered.state };
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
// Migration lock held — skip WAL replay, let holder finish; orphan WAL will be cleaned after commit
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const validation = validateSchema(parsed.value);
|
|
97
|
+
if (!validation.ok) {
|
|
98
|
+
if (validation.code === 'INCOMPATIBLE_SCHEMA_VERSION') {
|
|
99
|
+
return {
|
|
100
|
+
status: 'incompatible',
|
|
101
|
+
error: validation.error,
|
|
102
|
+
raw: parsed.value,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { status: 'corrupted', error: validation.error, raw: parsed.value };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let state = parsed.value as BridgeState;
|
|
109
|
+
|
|
110
|
+
// WAL forward migration: if schemaVersion < CURRENT, attempt known forward chain atomically.
|
|
111
|
+
if (state.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
112
|
+
const migration = migrateForward(state);
|
|
113
|
+
if (!migration.ok) {
|
|
114
|
+
// Unknown older version (no path) => incompatible; newer => incompatible. Never auto-mutate.
|
|
115
|
+
return {
|
|
116
|
+
status: migration.code === 'INCOMPATIBLE_NEWER' ? 'incompatible' : 'corrupted',
|
|
117
|
+
error: migration.error,
|
|
118
|
+
raw: parsed.value,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (migration.migrated && migration.state) {
|
|
122
|
+
// Acquire lock to commit migrated state atomically with WAL; if lock unavailable, surface as incompatible/corrupted per fail-closed.
|
|
123
|
+
try {
|
|
124
|
+
const lockPath = getLockPath(statePath);
|
|
125
|
+
const fd = await acquireLock(lockPath, 1000);
|
|
126
|
+
try {
|
|
127
|
+
// Re-read under lock to avoid racing with a concurrent writer
|
|
128
|
+
let fresh: BridgeState | null = null;
|
|
129
|
+
try {
|
|
130
|
+
const freshContent = readFileSync(statePath, 'utf-8');
|
|
131
|
+
const freshParsed = parseJson(freshContent);
|
|
132
|
+
if (freshParsed.ok) {
|
|
133
|
+
const v = validateSchema(freshParsed.value);
|
|
134
|
+
// If fresh already migrated by another process, use it
|
|
135
|
+
if (v.ok) fresh = freshParsed.value as BridgeState;
|
|
136
|
+
// If fresh is now at CURRENT, no need to migrate
|
|
137
|
+
if (fresh && fresh.schemaVersion === CURRENT_SCHEMA_VERSION) {
|
|
138
|
+
state = fresh;
|
|
139
|
+
} else {
|
|
140
|
+
const ok = commitMigratedState(statePath, migration.state, state.schemaVersion, state.stateRevision);
|
|
141
|
+
if (ok) state = migration.state;
|
|
142
|
+
else return { status: 'corrupted', error: 'Migration WAL commit failed — treated as Persistence Indeterminate', raw: parsed.value };
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
const ok = commitMigratedState(statePath, migration.state, state.schemaVersion, state.stateRevision);
|
|
146
|
+
if (ok) state = migration.state;
|
|
147
|
+
else return { status: 'corrupted', error: 'Migration WAL commit failed — treated as Persistence Indeterminate', raw: parsed.value };
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
const ok = commitMigratedState(statePath, migration.state, state.schemaVersion, state.stateRevision);
|
|
151
|
+
if (ok) state = migration.state;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
releaseLock(fd, lockPath);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Lock contention — fail-closed as corrupted so callers retry after holder releases
|
|
158
|
+
return { status: 'corrupted', error: migration.error ?? 'Migration pending — lock contention', raw: parsed.value };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { status: 'ok', state };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Synchronously read (for extension startup / tests). Same closed semantics; WAL migration is async, so sync path never auto-migrates — it surfaces incompatible/corrupted to the caller who must use the async read for migration. Downgrade never writes back. */
|
|
167
|
+
export function readBridgeStateSync(scope: Scope, opts: StoreOptions = {}): ReadResult {
|
|
168
|
+
const statePath = getStatePath(scope, opts);
|
|
169
|
+
if (!existsSync(statePath)) {
|
|
170
|
+
return { status: 'missing', state: createEmptyState(), isEmptyInit: true };
|
|
171
|
+
}
|
|
172
|
+
let content: string;
|
|
173
|
+
try {
|
|
174
|
+
content = readFileSync(statePath, 'utf-8');
|
|
175
|
+
} catch (e) {
|
|
176
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
177
|
+
return { status: 'corrupted', error: `Failed to read ${statePath}: ${msg}` };
|
|
178
|
+
}
|
|
179
|
+
if (content.trim().length === 0) {
|
|
180
|
+
return { status: 'corrupted', error: 'Empty file (corrupted)', raw: content };
|
|
181
|
+
}
|
|
182
|
+
const parsed = parseJson(content);
|
|
183
|
+
if (!parsed.ok) return { status: 'corrupted', error: parsed.error, raw: content };
|
|
184
|
+
const validation = validateSchema(parsed.value);
|
|
185
|
+
if (!validation.ok) {
|
|
186
|
+
if (validation.code === 'INCOMPATIBLE_SCHEMA_VERSION')
|
|
187
|
+
return { status: 'incompatible', error: validation.error, raw: parsed.value };
|
|
188
|
+
return { status: 'corrupted', error: validation.error, raw: parsed.value };
|
|
189
|
+
}
|
|
190
|
+
const state = parsed.value as BridgeState;
|
|
191
|
+
// Sync path: if WAL exists but not yet applied, do not mutate; async read will handle WAL. Enforce downgrade guard via message only.
|
|
192
|
+
if (state.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
193
|
+
const mig = migrateForward(state);
|
|
194
|
+
if (!mig.ok) {
|
|
195
|
+
return {
|
|
196
|
+
status: mig.code === 'INCOMPATIBLE_NEWER' ? 'incompatible' : 'corrupted',
|
|
197
|
+
error: mig.error,
|
|
198
|
+
raw: parsed.value,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (mig.migrated) {
|
|
202
|
+
// Sync path cannot take the migration lock; surface as requires async migration.
|
|
203
|
+
return {
|
|
204
|
+
status: 'corrupted',
|
|
205
|
+
error: `Schema migration required: ${state.schemaVersion} → ${CURRENT_SCHEMA_VERSION} — async read will WAL-migrate (no implicit activation)`,
|
|
206
|
+
raw: parsed.value,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { status: 'ok', state };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Commit a mutation atomically with lock + revision bump + verify.
|
|
215
|
+
* updater receives current state (or empty if missing) and returns next state *without* needing to set revision.
|
|
216
|
+
* The store bumps stateRevision monotonically and writes atomically.
|
|
217
|
+
* If the file was corrupted/incompatible, commit is rejected as Indeterminate (fail-closed).
|
|
218
|
+
*/
|
|
219
|
+
export async function commitBridgeState(
|
|
220
|
+
scope: Scope,
|
|
221
|
+
updater: (current: BridgeState) => BridgeState,
|
|
222
|
+
opts: StoreOptions = {},
|
|
223
|
+
): Promise<WriteResult> {
|
|
224
|
+
const statePath = getStatePath(scope, opts);
|
|
225
|
+
const lockPath = getLockPath(statePath);
|
|
226
|
+
const timeout = opts.lockTimeoutMs ?? 5000;
|
|
227
|
+
|
|
228
|
+
// Acquire lock for the entire RMW
|
|
229
|
+
let lockFd: number | undefined;
|
|
230
|
+
try {
|
|
231
|
+
lockFd = await acquireLock(lockPath, timeout);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
234
|
+
return { success: false, error: `Failed to acquire lock: ${msg}` };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
// Re-read under lock to get current revision (avoid lost update)
|
|
239
|
+
let currentResult: ReadResult;
|
|
240
|
+
// inline sync read under lock
|
|
241
|
+
if (!existsSync(statePath)) {
|
|
242
|
+
currentResult = { status: 'missing', state: createEmptyState(), isEmptyInit: true };
|
|
243
|
+
} else {
|
|
244
|
+
try {
|
|
245
|
+
const content = readFileSync(statePath, 'utf-8');
|
|
246
|
+
if (content.trim().length === 0) {
|
|
247
|
+
releaseLock(lockFd, lockPath);
|
|
248
|
+
return {
|
|
249
|
+
success: false,
|
|
250
|
+
error: 'Persistence Indeterminate: empty file, neither previous nor target verifiable',
|
|
251
|
+
isIndeterminate: true,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const parsed = parseJson(content);
|
|
255
|
+
if (!parsed.ok) {
|
|
256
|
+
releaseLock(lockFd, lockPath);
|
|
257
|
+
return {
|
|
258
|
+
success: false,
|
|
259
|
+
error: `Persistence Indeterminate: corrupted JSON — ${parsed.error}`,
|
|
260
|
+
isIndeterminate: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const validation = validateSchema(parsed.value);
|
|
264
|
+
if (!validation.ok) {
|
|
265
|
+
if (validation.code === 'INCOMPATIBLE_SCHEMA_VERSION') {
|
|
266
|
+
releaseLock(lockFd, lockPath);
|
|
267
|
+
return {
|
|
268
|
+
success: false,
|
|
269
|
+
error: `Incompatible schemaVersion — ${validation.error}`,
|
|
270
|
+
isIndeterminate: false,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
releaseLock(lockFd, lockPath);
|
|
274
|
+
return {
|
|
275
|
+
success: false,
|
|
276
|
+
error: `Persistence Indeterminate: invalid schema — ${validation.error}`,
|
|
277
|
+
isIndeterminate: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
currentResult = { status: 'ok', state: parsed.value as BridgeState };
|
|
281
|
+
} catch (e) {
|
|
282
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
283
|
+
releaseLock(lockFd, lockPath);
|
|
284
|
+
return { success: false, error: `Failed to read under lock: ${msg}`, isIndeterminate: true };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const current = currentResult.state!;
|
|
289
|
+
if (opts.expectedStateRevision !== undefined && current.stateRevision !== opts.expectedStateRevision) {
|
|
290
|
+
releaseLock(lockFd, lockPath);
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
isStale: true,
|
|
294
|
+
observedRevision: current.stateRevision,
|
|
295
|
+
error: `Rejected as Stale: expected State Revision ${opts.expectedStateRevision}, observed ${current.stateRevision}`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const draft = updater(structuredClone(current));
|
|
299
|
+
|
|
300
|
+
// Ensure draft has correct schemaVersion and scopeOverrides shape
|
|
301
|
+
draft.schemaVersion = CURRENT_SCHEMA_VERSION;
|
|
302
|
+
if (!Array.isArray(draft.registrations)) draft.registrations = [];
|
|
303
|
+
if (!Array.isArray(draft.installations)) draft.installations = [];
|
|
304
|
+
if (!Array.isArray(draft.scopeOverrides)) draft.scopeOverrides = [];
|
|
305
|
+
// Global scope must not persist overrides (but we allow empty)
|
|
306
|
+
if (scope === 'global' && draft.scopeOverrides.length > 0) {
|
|
307
|
+
// For scaffold we keep but warn — spec says overrides are project-only; we normalize to empty for global
|
|
308
|
+
draft.scopeOverrides = [];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Bump revision monotonically
|
|
312
|
+
const newRevision = nextRevision(current.stateRevision);
|
|
313
|
+
draft.stateRevision = newRevision;
|
|
314
|
+
|
|
315
|
+
const data = JSON.stringify(draft, null, 2) + '\n';
|
|
316
|
+
|
|
317
|
+
// Ensure dir exists
|
|
318
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
319
|
+
|
|
320
|
+
const result = atomicWriteFile(statePath, data);
|
|
321
|
+
if (!result.success) {
|
|
322
|
+
// Classify as Persistence Failed vs Indeterminate by checking if previous is still readable
|
|
323
|
+
let previousStillOk = false;
|
|
324
|
+
try {
|
|
325
|
+
const prevContent = readFileSync(statePath, 'utf-8');
|
|
326
|
+
const prevParsed = parseJson(prevContent);
|
|
327
|
+
if (prevParsed.ok) {
|
|
328
|
+
const v = validateSchema(prevParsed.value);
|
|
329
|
+
if (v.ok) {
|
|
330
|
+
const prev = prevParsed.value as BridgeState;
|
|
331
|
+
if (prev.stateRevision === current.stateRevision) previousStillOk = true;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
previousStillOk = false;
|
|
336
|
+
}
|
|
337
|
+
releaseLock(lockFd, lockPath);
|
|
338
|
+
if (previousStillOk) {
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
error: `Persistence Failed: ${result.error} — previous revision ${current.stateRevision} still verified`,
|
|
342
|
+
isIndeterminate: false,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
success: false,
|
|
347
|
+
error: `Persistence Indeterminate: ${result.error}`,
|
|
348
|
+
isIndeterminate: true,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Verify written revision matches expected
|
|
353
|
+
try {
|
|
354
|
+
const verifyContent = readFileSync(statePath, 'utf-8');
|
|
355
|
+
const verifyParsed = parseJson(verifyContent);
|
|
356
|
+
if (!verifyParsed.ok || !validateSchema(verifyParsed.value).ok) {
|
|
357
|
+
releaseLock(lockFd, lockPath);
|
|
358
|
+
return {
|
|
359
|
+
success: false,
|
|
360
|
+
error: 'Persistence Indeterminate: written file not verifiable after commit',
|
|
361
|
+
isIndeterminate: true,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const verified = verifyParsed.value as BridgeState;
|
|
365
|
+
if (verified.stateRevision !== newRevision) {
|
|
366
|
+
releaseLock(lockFd, lockPath);
|
|
367
|
+
return {
|
|
368
|
+
success: false,
|
|
369
|
+
error: `Persistence Indeterminate: revision mismatch after write (expected ${newRevision}, got ${verified.stateRevision})`,
|
|
370
|
+
isIndeterminate: true,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
} catch (e) {
|
|
374
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
375
|
+
releaseLock(lockFd, lockPath);
|
|
376
|
+
return { success: false, error: `Persistence Indeterminate: verify failed — ${msg}`, isIndeterminate: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
releaseLock(lockFd, lockPath);
|
|
380
|
+
return { success: true, newRevision };
|
|
381
|
+
} catch (e) {
|
|
382
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
383
|
+
try {
|
|
384
|
+
if (lockFd !== undefined) releaseLock(lockFd, lockPath);
|
|
385
|
+
} catch {}
|
|
386
|
+
return { success: false, error: msg };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Low-level direct write (for tests / migration). Caller must ensure revision monotonicity.
|
|
392
|
+
* Still uses lock + atomic + verify.
|
|
393
|
+
*/
|
|
394
|
+
export async function writeBridgeState(
|
|
395
|
+
scope: Scope,
|
|
396
|
+
state: BridgeState,
|
|
397
|
+
opts: StoreOptions = {},
|
|
398
|
+
): Promise<WriteResult> {
|
|
399
|
+
const statePath = getStatePath(scope, opts);
|
|
400
|
+
const lockPath = getLockPath(statePath);
|
|
401
|
+
const timeout = opts.lockTimeoutMs ?? 5000;
|
|
402
|
+
|
|
403
|
+
let lockFd: number | undefined;
|
|
404
|
+
try {
|
|
405
|
+
lockFd = await acquireLock(lockPath, timeout);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
408
|
+
return { success: false, error: `Failed to acquire lock: ${msg}` };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
// Closed handling: if existing file is corrupted/incompatible, fail-closed as Indeterminate/incompatible
|
|
413
|
+
// Also enforce downgrade guard: never overwrite a newer schemaVersion with an older one (no write-back).
|
|
414
|
+
if (existsSync(statePath)) {
|
|
415
|
+
try {
|
|
416
|
+
const curContent = readFileSync(statePath, 'utf-8');
|
|
417
|
+
const curParsed = parseJson(curContent);
|
|
418
|
+
if (!curParsed.ok) {
|
|
419
|
+
releaseLock(lockFd, lockPath);
|
|
420
|
+
return {
|
|
421
|
+
success: false,
|
|
422
|
+
error: `Persistence Indeterminate: existing file corrupted — ${curParsed.error}`,
|
|
423
|
+
isIndeterminate: true,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const curVal = validateSchema(curParsed.value);
|
|
427
|
+
if (!curVal.ok) {
|
|
428
|
+
if (curVal.code === 'INCOMPATIBLE_SCHEMA_VERSION') {
|
|
429
|
+
releaseLock(lockFd, lockPath);
|
|
430
|
+
return { success: false, error: curVal.error, isIndeterminate: false };
|
|
431
|
+
}
|
|
432
|
+
releaseLock(lockFd, lockPath);
|
|
433
|
+
return {
|
|
434
|
+
success: false,
|
|
435
|
+
error: `Persistence Indeterminate: existing file invalid — ${curVal.error}`,
|
|
436
|
+
isIndeterminate: true,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const curState = curParsed.value as BridgeState;
|
|
440
|
+
// Downgrade guard: target schemaVersion must not be older than durable
|
|
441
|
+
if (state.schemaVersion < curState.schemaVersion) {
|
|
442
|
+
releaseLock(lockFd, lockPath);
|
|
443
|
+
return {
|
|
444
|
+
success: false,
|
|
445
|
+
error: `Downgrade blocked: durable schemaVersion ${curState.schemaVersion} > target ${state.schemaVersion} — update Bridge Package instead (never write back to older version)`,
|
|
446
|
+
isIndeterminate: false,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
} catch (e) {
|
|
450
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
451
|
+
// If we already handled downgrade case, propagate; otherwise generic indeterminate
|
|
452
|
+
if (msg.includes('Downgrade blocked')) {
|
|
453
|
+
releaseLock(lockFd, lockPath);
|
|
454
|
+
return { success: false, error: msg, isIndeterminate: false };
|
|
455
|
+
}
|
|
456
|
+
releaseLock(lockFd, lockPath);
|
|
457
|
+
return { success: false, error: `Persistence Indeterminate: ${msg}`, isIndeterminate: true };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const data = JSON.stringify(state, null, 2) + '\n';
|
|
462
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
463
|
+
const result = atomicWriteFile(statePath, data);
|
|
464
|
+
if (!result.success) {
|
|
465
|
+
releaseLock(lockFd, lockPath);
|
|
466
|
+
return { success: false, error: result.error, isIndeterminate: true };
|
|
467
|
+
}
|
|
468
|
+
releaseLock(lockFd, lockPath);
|
|
469
|
+
return { success: true, newRevision: state.stateRevision };
|
|
470
|
+
} catch (e) {
|
|
471
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
472
|
+
try {
|
|
473
|
+
if (lockFd !== undefined) releaseLock(lockFd, lockPath);
|
|
474
|
+
} catch {}
|
|
475
|
+
return { success: false, error: msg };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Convenience: read both scopes (global + project) */
|
|
480
|
+
export async function readBothStates(opts: StoreOptions = {}): Promise<{
|
|
481
|
+
global: ReadResult;
|
|
482
|
+
project: ReadResult;
|
|
483
|
+
}> {
|
|
484
|
+
const [global, project] = await Promise.all([
|
|
485
|
+
readBridgeState('global', opts),
|
|
486
|
+
readBridgeState('project', opts),
|
|
487
|
+
]);
|
|
488
|
+
return { global, project };
|
|
489
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge State — Authoritative durable desired state.
|
|
3
|
+
* See CONTEXT.md: Bridge State, State Revision, Registration ID, Installation ID, Scope Override
|
|
4
|
+
*
|
|
5
|
+
* Persistence is split into two scope-local documents (global + project).
|
|
6
|
+
* Only authoritative fields are persisted; Effective State, catalogs, compatibility
|
|
7
|
+
* results, diagnostics are recomputed at read time.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
/** Opaque monotonic identifier per scope. Stored as decimal string, incremented on each successful commit. */
|
|
13
|
+
export type StateRevision = string;
|
|
14
|
+
|
|
15
|
+
/** Minimal shape for scaffold — later tickets extend with Source Key, Validation Snapshot, etc. */
|
|
16
|
+
export interface Registration {
|
|
17
|
+
/** Immutable lowercase UUIDv4, allocated before preflight */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Scope-local alias, derived from marketplace name */
|
|
20
|
+
alias?: string;
|
|
21
|
+
/** Declared marketplace name (kebab-case) */
|
|
22
|
+
marketplaceName?: string;
|
|
23
|
+
/** Source kind for duplicate detection */
|
|
24
|
+
sourceKind?: 'local' | 'git';
|
|
25
|
+
/** Canonical source locator (credential-free) */
|
|
26
|
+
source?: string;
|
|
27
|
+
/** Typed Source Key for duplicate detection / repeated registration (not identity) */
|
|
28
|
+
sourceKey?: {
|
|
29
|
+
kind: 'local' | 'git';
|
|
30
|
+
key: string;
|
|
31
|
+
canonicalPath?: string;
|
|
32
|
+
/** Canonical Git URL (for git kind) */
|
|
33
|
+
canonicalUrl?: string;
|
|
34
|
+
/** Canonical selector string (for git kind), e.g. refs/heads/main or 40 hex */
|
|
35
|
+
selector?: string;
|
|
36
|
+
/** Resolved full commit at confirmation time (for git) */
|
|
37
|
+
resolvedRevision?: string;
|
|
38
|
+
};
|
|
39
|
+
/** Canonical Git Locator (credential-free) — git-only, same as source for git kind */
|
|
40
|
+
canonicalLocator?: string;
|
|
41
|
+
/** Normalized Git Selector (git-only) */
|
|
42
|
+
gitSelector?: {
|
|
43
|
+
kind: 'default' | 'branch' | 'tag' | 'commit';
|
|
44
|
+
/** Canonical selector value: 'default' | 'refs/heads/*' | 'refs/tags/*' | lower 40/64 hex */
|
|
45
|
+
canonical: string;
|
|
46
|
+
/** Original display value before canonicalization */
|
|
47
|
+
raw?: string;
|
|
48
|
+
};
|
|
49
|
+
/** Resolved Revision: full commit bound to validation (git-only) */
|
|
50
|
+
resolvedRevision?: string;
|
|
51
|
+
/** Validation Snapshot fingerprint bound to Registration Confirmation */
|
|
52
|
+
validationSnapshot?: string;
|
|
53
|
+
/** Bound Compatibility Profile / Ruleset / Budget ids at confirmation time */
|
|
54
|
+
snapshotBinds?: { profile?: string; ruleset?: string; budget?: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Installation {
|
|
58
|
+
/** Canonical Installation ID = scope + Plugin ID (stable across version/path changes) */
|
|
59
|
+
id: string;
|
|
60
|
+
/** Canonical Plugin ID = Marketplace ID + manifest name */
|
|
61
|
+
pluginId: string;
|
|
62
|
+
/** Durable enabled/disabled condition */
|
|
63
|
+
installationState: 'enabled' | 'disabled';
|
|
64
|
+
/** Registration that supplied this Plugin; retained for source provenance and revalidation. */
|
|
65
|
+
registrationId?: string;
|
|
66
|
+
/** Snapshot-scoped Marketplace Entry identity that selected this Plugin. */
|
|
67
|
+
marketplaceEntryId?: string;
|
|
68
|
+
/** Validation Snapshot fingerprint accepted for this Installation. */
|
|
69
|
+
validationSnapshot?: string;
|
|
70
|
+
/** Compatibility Profile / Ruleset / Budget bound during installation. */
|
|
71
|
+
snapshotBinds?: { profile?: string; ruleset?: string; budget?: string };
|
|
72
|
+
/** Exact manifest name, retained independently of the Marketplace Entry display name. */
|
|
73
|
+
manifestName?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ScopeOverride {
|
|
77
|
+
/** Project-only suppression of inherited global record */
|
|
78
|
+
kind: 'registration' | 'installation';
|
|
79
|
+
/** Canonical Registration ID or Installation ID being suppressed */
|
|
80
|
+
targetId: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface BridgeState {
|
|
84
|
+
/** Versioned JSON schema */
|
|
85
|
+
schemaVersion: number;
|
|
86
|
+
/** Opaque monotonic per-scope revision */
|
|
87
|
+
stateRevision: StateRevision;
|
|
88
|
+
/** Scope-local registrations */
|
|
89
|
+
registrations: Registration[];
|
|
90
|
+
/** Scope-local installations (with Installation State) */
|
|
91
|
+
installations: Installation[];
|
|
92
|
+
/** Project-only — empty for global scope */
|
|
93
|
+
scopeOverrides: ScopeOverride[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type Scope = 'global' | 'project';
|
|
97
|
+
|
|
98
|
+
export type ReadStatus = 'ok' | 'corrupted' | 'incompatible' | 'missing';
|
|
99
|
+
|
|
100
|
+
export interface ReadResult {
|
|
101
|
+
status: ReadStatus;
|
|
102
|
+
/** Present when status is ok or missing (missing returns empty state) */
|
|
103
|
+
state?: BridgeState;
|
|
104
|
+
/** Human-readable diagnostic for corrupted/incompatible */
|
|
105
|
+
error?: string;
|
|
106
|
+
/** Raw parsed content when available (for diagnostics) */
|
|
107
|
+
raw?: unknown;
|
|
108
|
+
/** Whether state was reconstructed as empty due to missing file */
|
|
109
|
+
isEmptyInit?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface WriteResult {
|
|
113
|
+
success: boolean;
|
|
114
|
+
/** The new State Revision after successful commit */
|
|
115
|
+
newRevision?: StateRevision;
|
|
116
|
+
error?: string;
|
|
117
|
+
/** Whether the file was previously corrupted/incompatible and write was rejected as indeterminate */
|
|
118
|
+
isIndeterminate?: boolean;
|
|
119
|
+
/** The commit was safely refused because the caller's exact revision was no longer current. */
|
|
120
|
+
isStale?: boolean;
|
|
121
|
+
/** Current revision observed under the atomic store lock when a CAS refusal occurs. */
|
|
122
|
+
observedRevision?: StateRevision;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Findings-like error codes for store diagnostics (closed set for scaffold) */
|
|
126
|
+
export type StoreErrorCode =
|
|
127
|
+
| 'CORRUPTED_JSON'
|
|
128
|
+
| 'INVALID_SCHEMA'
|
|
129
|
+
| 'INCOMPATIBLE_SCHEMA_VERSION'
|
|
130
|
+
| 'PERSISTENCE_INDETERMINATE'
|
|
131
|
+
| 'PERSISTENCE_FAILED';
|
|
132
|
+
|
|
133
|
+
/** Create an empty state for a scope at revision "0" */
|
|
134
|
+
export function createEmptyState(): BridgeState {
|
|
135
|
+
return {
|
|
136
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
137
|
+
stateRevision: '0',
|
|
138
|
+
registrations: [],
|
|
139
|
+
installations: [],
|
|
140
|
+
scopeOverrides: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Check if a value looks like a BridgeState (structural) */
|
|
145
|
+
export function isBridgeState(value: unknown): value is BridgeState {
|
|
146
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
147
|
+
const o = value as Record<string, unknown>;
|
|
148
|
+
return (
|
|
149
|
+
typeof o.schemaVersion === 'number' &&
|
|
150
|
+
typeof o.stateRevision === 'string' &&
|
|
151
|
+
Array.isArray(o.registrations) &&
|
|
152
|
+
Array.isArray(o.installations) &&
|
|
153
|
+
Array.isArray(o.scopeOverrides)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Increment an opaque revision string numerically (monotonic). "0" -> "1" -> "2" ... */
|
|
158
|
+
export function nextRevision(current: StateRevision): StateRevision {
|
|
159
|
+
const n = BigInt(current);
|
|
160
|
+
return (n + 1n).toString();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Compare revisions as numeric opaque values: -1 if a<b, 0 if equal, 1 if a>b */
|
|
164
|
+
export function compareRevision(a: StateRevision, b: StateRevision): number {
|
|
165
|
+
const an = BigInt(a);
|
|
166
|
+
const bn = BigInt(b);
|
|
167
|
+
if (an < bn) return -1;
|
|
168
|
+
if (an > bn) return 1;
|
|
169
|
+
return 0;
|
|
170
|
+
}
|