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,303 @@
1
+ /**
2
+ * Apply Update — the Lifecycle Operation that replaces one Registration's recorded Validation
3
+ * Snapshot according to one complete Update Plan and atomically applies every disclosed
4
+ * same-scope consequence.
5
+ * See CONTEXT.md: Apply Update, Update Plan, Lifecycle Operation, Rejected as Stale, Attempt Fence.
6
+ *
7
+ * Guarantees:
8
+ * - One single atomic commit: the new snapshot becomes authoritative together with every
9
+ * updated / disabled / removed Installation — never a partial application across revisions.
10
+ * - The exact State Revision bound by the plan is re-verified under CAS; any movement rejects
11
+ * the attempt as stale without merging.
12
+ * - For local sources the candidate fingerprint is re-verified against the live tree before the
13
+ * durable mutation (a mismatch is a Blocking Finding — Stale Snapshot never substitutes).
14
+ * - It never refreshes the source and never silently changes another scope.
15
+ */
16
+
17
+ import {
18
+ acquireAttemptFence,
19
+ } from '../registration/fence.js';
20
+ import { CODE, RULE, blocking, sortFindings, type ValidationFinding } from '../registration/findings.js';
21
+ import { createReceipt, type AttemptReceipt } from '../registration/receipt.js';
22
+ import { buildGitSnapshot, buildLocalSnapshot } from '../registration/snapshot.js';
23
+ import { SourceCache } from '../cache/source-cache.js';
24
+ import { commitBridgeState, readBridgeState } from '../bridge-state/store.js';
25
+ import type { BridgeState } from '../bridge-state/types.js';
26
+ import type { LifecycleFlowOptions, UpdateCandidate } from './refresh.js';
27
+ import type { UpdatePlan } from './update-plan.js';
28
+
29
+ export type ApplyUpdateOptions = LifecycleFlowOptions;
30
+
31
+ export type ApplyUpdateOutcome =
32
+ | { status: 'completed'; receipt: AttemptReceipt; newRevision: string }
33
+ | { status: 'blocked'; findings: ValidationFinding[]; receipt: AttemptReceipt }
34
+ | { status: 'rejected-as-stale'; receipt: AttemptReceipt }
35
+ | { status: 'persistence-failed'; receipt: AttemptReceipt; isIndeterminate: boolean };
36
+
37
+ function operationFor(plan: UpdatePlan): string {
38
+ return plan.kind === 'rebind' ? 'Registration Rebind' : 'Apply Update';
39
+ }
40
+
41
+ function finding(scope: UpdatePlan['scope'], code: string, rule: string, target: ValidationFinding['target'], outcome: string): ValidationFinding {
42
+ return blocking({ code, rule, target, pointer: '', outcome, scope, phase: 'persistence' });
43
+ }
44
+
45
+ function blocked(plan: UpdatePlan, findings: ValidationFinding[], observedRevision?: string): ApplyUpdateOutcome {
46
+ return {
47
+ status: 'blocked',
48
+ findings: sortFindings(findings),
49
+ receipt: createReceipt({
50
+ operation: operationFor(plan),
51
+ scope: plan.scope,
52
+ trigger: `${plan.kind} ${plan.registrationId}`,
53
+ expectedStateRevision: plan.stateRevision,
54
+ observedStateRevision: observedRevision,
55
+ validationSnapshot: plan.candidate.snapshot.fingerprint,
56
+ summary: 'Blocked',
57
+ findings,
58
+ }),
59
+ };
60
+ }
61
+
62
+ function stale(plan: UpdatePlan, outcomeText: string, observedRevision?: string): ApplyUpdateOutcome {
63
+ return {
64
+ status: 'rejected-as-stale',
65
+ receipt: createReceipt({
66
+ operation: operationFor(plan),
67
+ scope: plan.scope,
68
+ trigger: `${plan.kind} ${plan.registrationId}`,
69
+ expectedStateRevision: plan.stateRevision,
70
+ observedStateRevision: observedRevision,
71
+ validationSnapshot: plan.candidate.snapshot.fingerprint,
72
+ summary: 'Rejected as Stale',
73
+ findings: [finding(plan.scope, CODE.REJECTED_AS_STALE, RULE.REJECTED_AS_STALE, 'attempt', outcomeText)],
74
+ stateChanged: false,
75
+ }),
76
+ };
77
+ }
78
+
79
+ /** Draft the whole disclosed consequence set in one state transform — committed once or not at all. */
80
+ function draftNextState(state: BridgeState, plan: UpdatePlan): BridgeState {
81
+ const candidate: UpdateCandidate = plan.candidate;
82
+ const registrations = state.registrations.map((registration) => {
83
+ if (registration.id !== plan.registrationId) return registration;
84
+ const next = {
85
+ ...registration,
86
+ marketplaceName: candidate.marketplaceName || registration.marketplaceName,
87
+ validationSnapshot: candidate.snapshot.fingerprint,
88
+ snapshotBinds: {
89
+ profile: candidate.snapshot.profile,
90
+ ruleset: candidate.snapshot.ruleset,
91
+ budget: candidate.snapshot.budget,
92
+ },
93
+ };
94
+ if (candidate.resolvedRevision) next.resolvedRevision = candidate.resolvedRevision;
95
+ if (plan.rebindSource) {
96
+ next.sourceKind = plan.rebindSource.sourceKind;
97
+ next.source = plan.rebindSource.source;
98
+ next.sourceKey = plan.rebindSource.sourceKey;
99
+ next.canonicalLocator = plan.rebindSource.canonicalLocator;
100
+ next.gitSelector = plan.rebindSource.gitSelector;
101
+ next.resolvedRevision = plan.rebindSource.resolvedRevision;
102
+ }
103
+ return next;
104
+ });
105
+
106
+ let installations = [...state.installations];
107
+ for (const entry of plan.entries) {
108
+ if (entry.choice === 'remove') {
109
+ installations = installations.filter((installation) => installation.id !== entry.installationId);
110
+ continue;
111
+ }
112
+ installations = installations.map((installation) => {
113
+ if (installation.id !== entry.installationId) return installation;
114
+ if (entry.choice === 'disable') return { ...installation, installationState: 'disabled' as const };
115
+ return {
116
+ ...installation,
117
+ installationState: entry.installationState,
118
+ validationSnapshot: entry.newSnapshot!.fingerprint,
119
+ snapshotBinds: {
120
+ profile: entry.newSnapshot!.profile,
121
+ ruleset: entry.newSnapshot!.ruleset,
122
+ budget: entry.newSnapshot!.budget,
123
+ },
124
+ marketplaceEntryId: entry.newMarketplaceEntryId ?? installation.marketplaceEntryId,
125
+ manifestName: entry.manifestName ?? installation.manifestName,
126
+ };
127
+ });
128
+ }
129
+
130
+ return { ...state, registrations, installations };
131
+ }
132
+
133
+ /**
134
+ * Commit one complete Update Plan atomically. The plan must come from `buildUpdatePlan`; this
135
+ * function performs no disclosure of its own — everything it will do was disclosed by the plan.
136
+ */
137
+ export async function applyUpdate(plan: UpdatePlan, opts: ApplyUpdateOptions = {}): Promise<ApplyUpdateOutcome> {
138
+ const read = await readBridgeState(plan.scope, { cwd: opts.cwd, agentDir: opts.agentDir });
139
+ if (read.status !== 'ok' && read.status !== 'missing') {
140
+ const indeterminate = true;
141
+ return {
142
+ status: 'persistence-failed',
143
+ isIndeterminate: indeterminate,
144
+ receipt: createReceipt({
145
+ operation: operationFor(plan),
146
+ scope: plan.scope,
147
+ trigger: `${plan.kind} ${plan.registrationId}`,
148
+ expectedStateRevision: plan.stateRevision,
149
+ validationSnapshot: plan.candidate.snapshot.fingerprint,
150
+ summary: 'Persistence Indeterminate',
151
+ findings: [finding(plan.scope, CODE.PERSISTENCE_INDETERMINATE, 'PERSIST-01', 'attempt', read.error ?? 'Bridge State is not readable; neither previous nor target verifiable')],
152
+ stateChanged: false,
153
+ }),
154
+ };
155
+ }
156
+ const state = read.state!;
157
+
158
+ // Project Trust gates every Project Scope mutation.
159
+ if (plan.scope === 'project' && opts.projectTrusted !== true) {
160
+ return blocked(plan, [
161
+ blocking({
162
+ code: CODE.PROJECT_TRUST_DENIED,
163
+ rule: RULE.PROJECT_TRUST_DENIED,
164
+ target: 'attempt',
165
+ pointer: '',
166
+ outcome: 'Project Trust is not granted by the Pi host; no Project Scope Lifecycle Operation may mutate Bridge State',
167
+ scope: plan.scope,
168
+ phase: 'admission',
169
+ }),
170
+ ], state.stateRevision);
171
+ }
172
+
173
+ if (!state.registrations.some((registration) => registration.id === plan.registrationId)) {
174
+ return blocked(plan, [
175
+ blocking({
176
+ code: CODE.REGISTRATION_NOT_FOUND,
177
+ rule: RULE.REGISTRATION_NOT_FOUND,
178
+ target: 'registration',
179
+ pointer: '',
180
+ outcome: `Registration '${plan.registrationId}' is no longer in ${plan.scope} Bridge State`,
181
+ scope: plan.scope,
182
+ phase: 'admission',
183
+ }),
184
+ ], state.stateRevision);
185
+ }
186
+
187
+ if (state.stateRevision !== plan.stateRevision) {
188
+ return stale(
189
+ plan,
190
+ `State Revision changed since the Update Plan was built (${plan.stateRevision} → ${state.stateRevision}); rebuild the plan after a fresh Marketplace Refresh`,
191
+ state.stateRevision,
192
+ );
193
+ }
194
+
195
+ // Fingerprint must still match before durable state mutation. Local sources are re-walked;
196
+ // Git candidates are verified against the fingerprint-addressed Source Cache entry (#22).
197
+ if (plan.candidate.snapshot.sourceKey.kind === 'local') {
198
+ const revalidated = buildLocalSnapshot(plan.candidate.snapshot.sourceKey.canonicalPath!, plan.candidate.snapshot.sourceKey, plan.scope);
199
+ if (!revalidated.ok || !revalidated.snapshot || revalidated.snapshot.fingerprint !== plan.candidate.snapshot.fingerprint) {
200
+ return stale(
201
+ plan,
202
+ 'Validation Snapshot fingerprint changed since the Update Candidate was produced (source drifted); run a fresh Marketplace Refresh and rebuild the plan',
203
+ );
204
+ }
205
+ } else {
206
+ // Git candidates verify against the fingerprint-addressed Source Cache (default-constructed
207
+ // so pin hygiene and verification never depend on caller injection). Fail-closed: an absent
208
+ // or mismatching cached tree rejects the attempt as stale — never an unverified apply.
209
+ const cache = opts.cache ?? new SourceCache({ agentDir: opts.agentDir });
210
+ const hit = await cache.hitExact(plan.candidate.snapshot.fingerprint);
211
+ if (!hit) {
212
+ return stale(
213
+ plan,
214
+ 'No cached tree verifies the Update Candidate fingerprint (evicted or never retained); run a fresh Marketplace Refresh and rebuild the plan',
215
+ );
216
+ }
217
+ const revalidated = buildGitSnapshot(hit.path, plan.candidate.snapshot.sourceKey, plan.scope, {
218
+ canonicalLocator: plan.candidate.canonicalLocator ?? plan.candidate.snapshot.canonicalLocator ?? '',
219
+ resolvedRevision: plan.candidate.resolvedRevision ?? plan.candidate.snapshot.resolvedRevision ?? '',
220
+ selectorCanonical: plan.candidate.selectorCanonical ?? plan.candidate.snapshot.selectorCanonical ?? '',
221
+ });
222
+ if (!revalidated.ok || !revalidated.snapshot || revalidated.snapshot.fingerprint !== plan.candidate.snapshot.fingerprint) {
223
+ return stale(
224
+ plan,
225
+ 'Cached Git tree no longer hashes to the Update Candidate fingerprint (source drift); run a fresh Marketplace Refresh and rebuild the plan',
226
+ );
227
+ }
228
+ }
229
+
230
+ const fence = await acquireAttemptFence(plan.scope, {
231
+ cwd: opts.cwd,
232
+ agentDir: opts.agentDir,
233
+ fenceTimeoutMs: opts.fenceTimeoutMs,
234
+ });
235
+ if (!fence.ok) return blocked(plan, [fence.finding!], plan.stateRevision);
236
+
237
+ try {
238
+ const write = await commitBridgeState(
239
+ plan.scope,
240
+ (current) => draftNextState(current, plan),
241
+ { cwd: opts.cwd, agentDir: opts.agentDir, lockTimeoutMs: opts.fenceTimeoutMs, expectedStateRevision: plan.stateRevision },
242
+ );
243
+
244
+ if (write.isStale) {
245
+ return stale(
246
+ plan,
247
+ `State Revision changed under the Attempt Fence (${plan.stateRevision} → ${write.observedRevision ?? '?'}); rebuild the plan after a fresh Marketplace Refresh`,
248
+ write.observedRevision,
249
+ );
250
+ }
251
+
252
+ if (!write.success) {
253
+ const summary = write.isIndeterminate ? 'Persistence Indeterminate' : 'Persistence Failed';
254
+ return {
255
+ status: 'persistence-failed',
256
+ isIndeterminate: write.isIndeterminate ?? false,
257
+ receipt: createReceipt({
258
+ operation: operationFor(plan),
259
+ scope: plan.scope,
260
+ trigger: `${plan.kind} ${plan.registrationId}`,
261
+ expectedStateRevision: plan.stateRevision,
262
+ targetStateRevision: '?',
263
+ validationSnapshot: plan.candidate.snapshot.fingerprint,
264
+ summary,
265
+ findings: [
266
+ finding(
267
+ plan.scope,
268
+ write.isIndeterminate ? CODE.PERSISTENCE_INDETERMINATE : CODE.PERSISTENCE_FAILED,
269
+ write.isIndeterminate ? 'PERSIST-01' : 'PERSIST-02',
270
+ 'attempt',
271
+ write.error ?? summary,
272
+ ),
273
+ ],
274
+ stateChanged: false,
275
+ }),
276
+ };
277
+ }
278
+
279
+ const newRevision = write.newRevision!;
280
+ // The Update Candidate has been applied: its pending-cache pin is no longer needed
281
+ // (the new Registration/Installation snapshots now pin the fingerprint via Bridge State).
282
+ (opts.cache ?? new SourceCache({ agentDir: opts.agentDir })).clearPendingUpdate(plan.scope, plan.registrationId);
283
+ const diagnostics = plan.entries.some((entry) => entry.choice === 'remove' || entry.currentState === 'disabled');
284
+ return {
285
+ status: 'completed',
286
+ newRevision,
287
+ receipt: createReceipt({
288
+ operation: operationFor(plan),
289
+ scope: plan.scope,
290
+ trigger: `${plan.kind} ${plan.registrationId}`,
291
+ expectedStateRevision: plan.stateRevision,
292
+ targetStateRevision: newRevision,
293
+ observedStateRevision: newRevision,
294
+ validationSnapshot: plan.candidate.snapshot.fingerprint,
295
+ summary: diagnostics ? 'Completed with diagnostics' : 'Completed',
296
+ findings: [],
297
+ stateChanged: true,
298
+ }),
299
+ };
300
+ } finally {
301
+ fence.handle!.release();
302
+ }
303
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Runtime Skill Collision — resolution over Pi's flat skill namespace by exact
3
+ * Skill Descriptor name. See CONTEXT.md: Runtime Skill Collision, Projected Skill.
4
+ *
5
+ * Pure and skill-granular only: the result never changes Plugin classification or
6
+ * Projected Plugin determination. Candidates resolve per exact name in
7
+ * `Pi → Project Scope → Global Scope` order; all same-scope Bridge colliders are
8
+ * unavailable; only a surviving higher-layer skill reserves the name, so a lower-layer
9
+ * candidate survives whenever no higher-layer skill does.
10
+ */
11
+
12
+ export type CollisionLayer = 'pi' | 'project' | 'global';
13
+
14
+ export interface SkillCandidate {
15
+ /** Namespace layer the candidate comes from. */
16
+ layer: CollisionLayer;
17
+ /** Exact Skill Descriptor name (the contested flat-namespace key). */
18
+ name: string;
19
+ /** Canonical Skill ID = Plugin ID + descriptor name. */
20
+ skillId: string;
21
+ pluginId: string;
22
+ }
23
+
24
+ /** Skill-granular denial record for one contested exact name. */
25
+ export interface SkillCollisionFindingInfo {
26
+ name: string;
27
+ /** Skill IDs denied because they collide within their own scope layer. */
28
+ unavailableSkillIds: string[];
29
+ /** The surviving candidate that reserves the name, when one exists. */
30
+ reservedBy?: { layer: CollisionLayer; skillId: string };
31
+ }
32
+
33
+ export interface CollisionResolution {
34
+ survivors: SkillCandidate[];
35
+ findings: SkillCollisionFindingInfo[];
36
+ }
37
+
38
+ function dedupeBySkillId(candidates: SkillCandidate[]): SkillCandidate[] {
39
+ const seen = new Set<string>();
40
+ return candidates.filter((item) => {
41
+ if (seen.has(item.skillId)) return false;
42
+ seen.add(item.skillId);
43
+ return true;
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Resolve every exact-name group independently under the layered rules.
49
+ * Pre-existing Pi skills are host-owned: a Pi-layer claim reserves the name for all
50
+ * Bridge candidates, and Pi-vs-Pi duplicates are outside Bridge adjudication.
51
+ */
52
+ export function resolveRuntimeSkillCollisions(candidates: SkillCandidate[]): CollisionResolution {
53
+ const byName = new Map<string, SkillCandidate[]>();
54
+ for (const item of candidates) {
55
+ const group = byName.get(item.name);
56
+ if (group) group.push(item);
57
+ else byName.set(item.name, [item]);
58
+ }
59
+
60
+ const survivors: SkillCandidate[] = [];
61
+ const findings: SkillCollisionFindingInfo[] = [];
62
+
63
+ for (const [name, group] of [...byName.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
64
+ const bridge = group.filter((item) => item.layer !== 'pi');
65
+ const piClaims = group.some((item) => item.layer === 'pi');
66
+
67
+ if (piClaims) {
68
+ // Only a surviving higher-layer skill reserves the name — the Pi layer does here.
69
+ // Every Bridge candidate below is denied at skill granularity.
70
+ const piSurvivors = group.filter((item) => item.layer === 'pi');
71
+ if (bridge.length > 0) {
72
+ findings.push({
73
+ name,
74
+ unavailableSkillIds: bridge.map((item) => item.skillId).sort((a, b) => a.localeCompare(b)),
75
+ reservedBy: piSurvivors[0] ? { layer: 'pi', skillId: piSurvivors[0].skillId } : undefined,
76
+ });
77
+ }
78
+ survivors.push(...piSurvivors);
79
+ continue;
80
+ }
81
+
82
+ let denied: string[] = [];
83
+ let projectNameUnreserved = false;
84
+
85
+ const projectCandidates = dedupeBySkillId(group.filter((item) => item.layer === 'project'));
86
+ const globalCandidates = dedupeBySkillId(group.filter((item) => item.layer === 'global'));
87
+
88
+ if (projectCandidates.length > 1) {
89
+ // All same-scope Bridge colliders are unavailable; nobody reserves the name,
90
+ // so lower-layer candidates survive.
91
+ denied.push(...projectCandidates.map((item) => item.skillId));
92
+ projectNameUnreserved = true;
93
+ } else if (projectCandidates.length === 1) {
94
+ survivors.push(projectCandidates[0]!);
95
+ if (globalCandidates.length > 0) {
96
+ findings.push({
97
+ name,
98
+ unavailableSkillIds: globalCandidates.map((item) => item.skillId).sort((a, b) => a.localeCompare(b)),
99
+ reservedBy: { layer: 'project', skillId: projectCandidates[0]!.skillId },
100
+ });
101
+ }
102
+ continue;
103
+ }
104
+
105
+ if (projectNameUnreserved || projectCandidates.length === 0) {
106
+ const survivingGlobal = dedupeBySkillId(globalCandidates);
107
+ if (survivingGlobal.length > 1) {
108
+ denied.push(...survivingGlobal.map((item) => item.skillId));
109
+ } else {
110
+ survivors.push(...survivingGlobal);
111
+ }
112
+ }
113
+
114
+ if (denied.length > 0) {
115
+ findings.push({ name, unavailableSkillIds: [...new Set(denied)].sort((a, b) => a.localeCompare(b)) });
116
+ }
117
+ }
118
+
119
+ return { survivors, findings };
120
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Effective State — computed project view of inherited Global Scope records,
3
+ * Project Scope additions, and ID-keyed Scope Overrides.
4
+ * See CONTEXT.md: Effective State, Scope Override, Project Trust, Installation State, Installed Plugin.
5
+ *
6
+ * This is a pure read-time computation over the two authoritative Bridge State documents.
7
+ * Nothing here is persisted: no merged state document exists. Only enabled Installations
8
+ * participate, an enabled project Installation of an inherited global Plugin ID takes
9
+ * precedence over the retained global Installation, and no selected record's independently
10
+ * persisted provenance is merged or mutated — each record carries exactly what its own
11
+ * scope's document holds.
12
+ */
13
+
14
+ import type { BridgeState, Installation, Registration } from '../bridge-state/types.js';
15
+
16
+ export type EffectiveSourceScope = 'global' | 'project';
17
+
18
+ export interface EffectiveRegistration extends Registration {
19
+ sourceScope: EffectiveSourceScope;
20
+ }
21
+
22
+ export interface EffectiveInstallation extends Installation {
23
+ sourceScope: EffectiveSourceScope;
24
+ }
25
+
26
+ /** Why one inherited Global Scope record does not participate in this Effective State. */
27
+ export interface SuppressedGlobalRecord {
28
+ kind: 'registration' | 'installation';
29
+ /** Canonical Registration ID or Installation ID of the suppressed record. */
30
+ targetId: string;
31
+ pluginId?: string;
32
+ reason: 'scope-override-registration' | 'scope-override-installation' | 'project-precedence';
33
+ /** For precedence suppression: the project Installation that supersedes the global one. */
34
+ supersededBy?: string;
35
+ }
36
+
37
+ /**
38
+ * Project Scope records stored in the project document but excluded from Effective State:
39
+ * Project Trust not granted, or an invalid duplicate of an inherited Registration ID.
40
+ */
41
+ export interface ExcludedProjectRecord {
42
+ kind: 'registration' | 'installation' | 'scope-override';
43
+ id: string;
44
+ reason: 'project-trust-not-granted' | 'invalid-duplicate-registration-id';
45
+ }
46
+
47
+ export interface EffectiveState {
48
+ registrations: EffectiveRegistration[];
49
+ installations: EffectiveInstallation[];
50
+ suppressed: SuppressedGlobalRecord[];
51
+ excluded: ExcludedProjectRecord[];
52
+ }
53
+
54
+ export interface EffectiveStateOptions {
55
+ /**
56
+ * Pi host-owned Project Trust. Defaults to false: project records remain stored but are
57
+ * excluded, and overrides do not participate, until the host grants trust.
58
+ */
59
+ projectTrusted?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Compute the Effective State from both scope documents. Pure — never mutates inputs and
64
+ * never persists anything; callers recompute after every read or commit.
65
+ */
66
+ export function computeEffectiveState(
67
+ globalState: BridgeState,
68
+ projectState: BridgeState,
69
+ opts: EffectiveStateOptions = {},
70
+ ): EffectiveState {
71
+ const projectTrusted = opts.projectTrusted === true;
72
+ const suppressed: SuppressedGlobalRecord[] = [];
73
+ const excluded: ExcludedProjectRecord[] = [];
74
+
75
+ // Without Project Trust every project-side record stays durable but non-participating.
76
+ if (!projectTrusted) {
77
+ for (const registration of projectState.registrations) {
78
+ excluded.push({ kind: 'registration', id: registration.id, reason: 'project-trust-not-granted' });
79
+ }
80
+ for (const installation of projectState.installations) {
81
+ excluded.push({ kind: 'installation', id: installation.id, reason: 'project-trust-not-granted' });
82
+ }
83
+ for (const override of projectState.scopeOverrides) {
84
+ const id = `${override.kind}/${override.targetId}`;
85
+ excluded.push({ kind: 'scope-override', id, reason: 'project-trust-not-granted' });
86
+ }
87
+ return {
88
+ registrations: globalState.registrations.map((registration) => ({ ...registration, sourceScope: 'global' })),
89
+ installations: globalState.installations
90
+ .filter((installation) => installation.installationState === 'enabled')
91
+ .map((installation) => ({ ...installation, sourceScope: 'global' })),
92
+ suppressed,
93
+ excluded,
94
+ };
95
+ }
96
+
97
+ // Overrides are keyed by canonical Registration ID / Installation ID and suppress only
98
+ // inherited Global Scope records.
99
+ const overriddenRegistrationIds = new Set(
100
+ projectState.scopeOverrides.filter((o) => o.kind === 'registration').map((o) => o.targetId),
101
+ );
102
+ const overriddenInstallationIds = new Set(
103
+ projectState.scopeOverrides.filter((o) => o.kind === 'installation').map((o) => o.targetId),
104
+ );
105
+
106
+ const survivingGlobalRegistrations = globalState.registrations.filter((registration) => {
107
+ if (!overriddenRegistrationIds.has(registration.id)) return true;
108
+ suppressed.push({ kind: 'registration', targetId: registration.id, reason: 'scope-override-registration' });
109
+ return false;
110
+ });
111
+
112
+ // A project record that duplicates a global Registration ID is invalid rather than an
113
+ // override; it stays durable but cannot participate in Effective State.
114
+ const globalRegistrationIds = new Set(survivingGlobalRegistrations.map((registration) => registration.id));
115
+ const projectRegistrations = projectState.registrations.filter((registration) => {
116
+ if (!globalRegistrationIds.has(registration.id)) return true;
117
+ excluded.push({ kind: 'registration', id: registration.id, reason: 'invalid-duplicate-registration-id' });
118
+ return false;
119
+ });
120
+
121
+ // A Registration Override suppresses its whole marketplace subtree: every Installation
122
+ // supplied by that Registration disappears with it.
123
+ const survivingGlobalInstallations: Installation[] = [];
124
+ for (const installation of globalState.installations) {
125
+ if (installation.registrationId && overriddenRegistrationIds.has(installation.registrationId)) {
126
+ suppressed.push({
127
+ kind: 'installation',
128
+ targetId: installation.id,
129
+ pluginId: installation.pluginId,
130
+ reason: 'scope-override-registration',
131
+ });
132
+ continue;
133
+ }
134
+ if (overriddenInstallationIds.has(installation.id)) {
135
+ suppressed.push({
136
+ kind: 'installation',
137
+ targetId: installation.id,
138
+ pluginId: installation.pluginId,
139
+ reason: 'scope-override-installation',
140
+ });
141
+ continue;
142
+ }
143
+ survivingGlobalInstallations.push(installation);
144
+ }
145
+
146
+ // Only enabled Installations ever participate; a disabled project Installation neither
147
+ // participates nor supersedes its inherited global twin.
148
+ const enabledProjectInstallations = projectState.installations.filter(
149
+ (installation) => installation.installationState === 'enabled',
150
+ );
151
+ const projectPluginIds = new Set(enabledProjectInstallations.map((installation) => installation.pluginId));
152
+
153
+ for (const installation of survivingGlobalInstallations) {
154
+ if (installation.installationState !== 'enabled') continue;
155
+ if (!projectPluginIds.has(installation.pluginId)) continue;
156
+ const supersedingId = enabledProjectInstallations.find((item) => item.pluginId === installation.pluginId)!.id;
157
+ suppressed.push({
158
+ kind: 'installation',
159
+ targetId: installation.id,
160
+ pluginId: installation.pluginId,
161
+ reason: 'project-precedence',
162
+ supersededBy: supersedingId,
163
+ });
164
+ }
165
+
166
+ const supersededIds = new Set(suppressed.filter((s) => s.reason === 'project-precedence').map((s) => s.targetId));
167
+
168
+ return {
169
+ registrations: [
170
+ ...survivingGlobalRegistrations.map((registration): EffectiveRegistration => ({ ...registration, sourceScope: 'global' })),
171
+ ...projectRegistrations.map((registration): EffectiveRegistration => ({ ...registration, sourceScope: 'project' })),
172
+ ],
173
+ installations: [
174
+ ...survivingGlobalInstallations
175
+ .filter((installation) => installation.installationState === 'enabled' && !supersededIds.has(installation.id))
176
+ .map((installation): EffectiveInstallation => ({ ...installation, sourceScope: 'global' })),
177
+ ...enabledProjectInstallations.map((installation): EffectiveInstallation => ({ ...installation, sourceScope: 'project' })),
178
+ ],
179
+ suppressed,
180
+ excluded,
181
+ };
182
+ }
@@ -0,0 +1,4 @@
1
+ export * from './effective-state.js';
2
+ export * from './collision.js';
3
+ export * from './overrides.js';
4
+ export * from './project.js';