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,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removal lifecycle — Registration Removal and Installation Removal.
|
|
3
|
+
* See CONTEXT.md: Registration Removal, Installation Removal, Lifecycle Operation, Attempt Fence.
|
|
4
|
+
*
|
|
5
|
+
* Registration Removal deletes one scope-local Registration together with ALL of its same-scope
|
|
6
|
+
* Installations as one disclosed atomic effect; other scopes are never mutated (references left
|
|
7
|
+
* elsewhere fail closed as unavailable and surface diagnostics until repaired or removed).
|
|
8
|
+
*
|
|
9
|
+
* Installation Removal deletes exactly one scope-local Installation while retaining its
|
|
10
|
+
* Registration, and its disclosure identifies any inherited Installation that becomes effective
|
|
11
|
+
* afterward (Effective State precedence: project over global for the same Plugin ID).
|
|
12
|
+
*
|
|
13
|
+
* Both follow the preflight → explicit confirm pattern with the Attempt Fence held between them,
|
|
14
|
+
* confirmation bound to the observed State Revision, Default No.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { commitBridgeState, readBridgeState } from '../bridge-state/store.js';
|
|
18
|
+
import type { Installation, Scope } from '../bridge-state/types.js';
|
|
19
|
+
import { SourceCache } from '../cache/source-cache.js';
|
|
20
|
+
import { acquireAttemptFence, type AttemptFenceHandle } from '../registration/fence.js';
|
|
21
|
+
import { CODE, RULE, blocking, sortFindings, type ValidationFinding } from '../registration/findings.js';
|
|
22
|
+
import { createReceipt, type AttemptReceipt } from '../registration/receipt.js';
|
|
23
|
+
import type { LifecycleFlowOptions } from './refresh.js';
|
|
24
|
+
|
|
25
|
+
export interface RemovalFlowOptions extends LifecycleFlowOptions {
|
|
26
|
+
/** Host-owned Project Trust decision; omitted means NOT granted (fail-closed). */
|
|
27
|
+
projectTrusted?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RegistrationRemovalPreflight {
|
|
31
|
+
scope: Scope;
|
|
32
|
+
registrationId: string;
|
|
33
|
+
registrationSource?: string;
|
|
34
|
+
/** Every same-scope Installation removed by the single atomic commit. */
|
|
35
|
+
affectedInstallations: Installation[];
|
|
36
|
+
stateRevision: string;
|
|
37
|
+
fence: AttemptFenceHandle;
|
|
38
|
+
terminal: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InstallationRemovalPreflight {
|
|
42
|
+
scope: Scope;
|
|
43
|
+
installation: Installation;
|
|
44
|
+
registrationId?: string;
|
|
45
|
+
/** Inherited Installations that become effective after this removal (disclosed, not mutated). */
|
|
46
|
+
resumingInheritedInstallations: Installation[];
|
|
47
|
+
stateRevision: string;
|
|
48
|
+
fence: AttemptFenceHandle;
|
|
49
|
+
terminal: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type RemovalOutcome =
|
|
53
|
+
| { status: 'completed'; receipt: AttemptReceipt; newRevision: string }
|
|
54
|
+
| { status: 'declined'; receipt: AttemptReceipt }
|
|
55
|
+
| { status: 'blocked'; findings: ValidationFinding[]; receipt: AttemptReceipt }
|
|
56
|
+
| { status: 'rejected-as-stale'; receipt: AttemptReceipt }
|
|
57
|
+
| { status: 'persistence-failed'; receipt: AttemptReceipt; isIndeterminate: boolean };
|
|
58
|
+
|
|
59
|
+
export type RemovalPreflightResult<P> =
|
|
60
|
+
| { ok: true; preflight: P }
|
|
61
|
+
| {
|
|
62
|
+
ok: false;
|
|
63
|
+
outcome:
|
|
64
|
+
| { status: 'blocked'; findings: ValidationFinding[]; receipt: AttemptReceipt }
|
|
65
|
+
| { status: 'persistence-failed'; receipt: AttemptReceipt; isIndeterminate: boolean };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function trustFinding(scope: Scope): ValidationFinding | undefined {
|
|
69
|
+
if (scope !== 'project') return undefined;
|
|
70
|
+
return blocking({
|
|
71
|
+
code: CODE.PROJECT_TRUST_DENIED,
|
|
72
|
+
rule: RULE.PROJECT_TRUST_DENIED,
|
|
73
|
+
target: 'installation',
|
|
74
|
+
pointer: '',
|
|
75
|
+
outcome: 'Project Trust is not granted by the Pi host; project records remain stored but excluded from Effective State, and no Project Scope Lifecycle Operation may mutate them',
|
|
76
|
+
scope,
|
|
77
|
+
phase: 'admission',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function persistenceBlocked(scope: Scope, operation: string, trigger: string, error?: string): RemovalPreflightResult<never> {
|
|
82
|
+
const receiptFindings: ValidationFinding[] = [
|
|
83
|
+
blocking({ code: CODE.PERSISTENCE_INDETERMINATE, rule: 'PERSIST-01', target: 'attempt', pointer: '', outcome: error ?? 'Bridge State is not readable; neither previous nor target verifiable', scope, phase: 'persistence' }),
|
|
84
|
+
];
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
outcome: {
|
|
88
|
+
status: 'persistence-failed',
|
|
89
|
+
isIndeterminate: true,
|
|
90
|
+
receipt: createReceipt({ operation, scope, trigger, expectedStateRevision: '?', summary: 'Persistence Indeterminate', findings: receiptFindings }),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function staleReceipt(operation: string, scope: Scope, trigger: string, expected: string, observed?: string): RemovalOutcome {
|
|
96
|
+
return {
|
|
97
|
+
status: 'rejected-as-stale',
|
|
98
|
+
receipt: createReceipt({
|
|
99
|
+
operation,
|
|
100
|
+
scope,
|
|
101
|
+
trigger,
|
|
102
|
+
expectedStateRevision: expected,
|
|
103
|
+
observedStateRevision: observed,
|
|
104
|
+
summary: 'Rejected as Stale',
|
|
105
|
+
findings: [blocking({ code: CODE.REJECTED_AS_STALE, rule: RULE.REJECTED_AS_STALE, target: 'attempt', pointer: '', outcome: `State Revision changed (${expected} → ${observed ?? '?'}); re-run preflight and confirmation — no automatic merge`, scope, phase: 'persistence' })],
|
|
106
|
+
stateChanged: false,
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function persistenceFailure(operation: string, scope: Scope, trigger: string, expected: string, write: { isIndeterminate?: boolean; error?: string }): RemovalOutcome {
|
|
112
|
+
const summary = write.isIndeterminate ? 'Persistence Indeterminate' : 'Persistence Failed';
|
|
113
|
+
return {
|
|
114
|
+
status: 'persistence-failed',
|
|
115
|
+
isIndeterminate: write.isIndeterminate ?? false,
|
|
116
|
+
receipt: createReceipt({
|
|
117
|
+
operation,
|
|
118
|
+
scope,
|
|
119
|
+
trigger,
|
|
120
|
+
expectedStateRevision: expected,
|
|
121
|
+
summary,
|
|
122
|
+
findings: [blocking({ code: write.isIndeterminate ? CODE.PERSISTENCE_INDETERMINATE : CODE.PERSISTENCE_FAILED, rule: write.isIndeterminate ? 'PERSIST-01' : 'PERSIST-02', target: 'attempt', pointer: '', outcome: write.error ?? summary, scope, phase: 'persistence' })],
|
|
123
|
+
stateChanged: false,
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Preflight a Registration Removal: disclose every same-scope Installation the single atomic
|
|
130
|
+
* commit will delete. Other scopes are never listed nor mutated.
|
|
131
|
+
*/
|
|
132
|
+
export async function preflightRegistrationRemoval(
|
|
133
|
+
scope: Scope,
|
|
134
|
+
registrationId: string,
|
|
135
|
+
opts: RemovalFlowOptions = {},
|
|
136
|
+
): Promise<RemovalPreflightResult<RegistrationRemovalPreflight>> {
|
|
137
|
+
const OPERATION = 'Registration Removal';
|
|
138
|
+
const trigger = `remove registration ${registrationId}`;
|
|
139
|
+
const read = await readBridgeState(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
|
|
140
|
+
if (read.status !== 'ok' && read.status !== 'missing') return persistenceBlocked(scope, OPERATION, trigger, read.error);
|
|
141
|
+
const state = read.state!;
|
|
142
|
+
|
|
143
|
+
const trust = trustFinding(scope);
|
|
144
|
+
if (trust && opts.projectTrusted !== true) {
|
|
145
|
+
const findings: ValidationFinding[] = [trust];
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
outcome: {
|
|
149
|
+
status: 'blocked',
|
|
150
|
+
findings,
|
|
151
|
+
receipt: createReceipt({ operation: OPERATION, scope, trigger, expectedStateRevision: state.stateRevision, summary: 'Blocked', findings }),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const registration = state.registrations.find((item) => item.id === registrationId);
|
|
157
|
+
if (!registration) {
|
|
158
|
+
const findings: ValidationFinding[] = [
|
|
159
|
+
blocking({ code: CODE.REGISTRATION_NOT_FOUND, rule: RULE.REGISTRATION_NOT_FOUND, target: 'registration', pointer: '', outcome: `Registration '${registrationId}' is not in ${scope} Bridge State`, scope, phase: 'admission' }),
|
|
160
|
+
];
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
outcome: {
|
|
164
|
+
status: 'blocked',
|
|
165
|
+
findings,
|
|
166
|
+
receipt: createReceipt({ operation: OPERATION, scope, trigger, expectedStateRevision: state.stateRevision, summary: 'Blocked', findings }),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const fence = await acquireAttemptFence(scope, { cwd: opts.cwd, agentDir: opts.agentDir, fenceTimeoutMs: opts.fenceTimeoutMs });
|
|
172
|
+
if (!fence.ok) {
|
|
173
|
+
const findings = [fence.finding!];
|
|
174
|
+
return { ok: false, outcome: { status: 'blocked', findings, receipt: createReceipt({ operation: OPERATION, scope, trigger, expectedStateRevision: state.stateRevision, summary: 'Blocked', findings }) } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
preflight: {
|
|
180
|
+
scope,
|
|
181
|
+
registrationId,
|
|
182
|
+
registrationSource: registration.source,
|
|
183
|
+
affectedInstallations: state.installations.filter((installation) => installation.registrationId === registrationId),
|
|
184
|
+
stateRevision: state.stateRevision,
|
|
185
|
+
fence: fence.handle!,
|
|
186
|
+
terminal: false,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Full cascade disclosure for the confirmation surface. */
|
|
192
|
+
export function registrationRemovalDisclosure(preflight: RegistrationRemovalPreflight): string {
|
|
193
|
+
const lines = [
|
|
194
|
+
`Scope: ${preflight.scope}`,
|
|
195
|
+
`Registration: ${preflight.registrationId.slice(0, 8)}…${preflight.registrationSource ? ` · ${JSON.stringify(preflight.registrationSource)}` : ''}`,
|
|
196
|
+
`State Revision: ${preflight.stateRevision}`,
|
|
197
|
+
`Same-scope Installations removed atomically: ${preflight.affectedInstallations.length}`,
|
|
198
|
+
];
|
|
199
|
+
for (const installation of preflight.affectedInstallations) {
|
|
200
|
+
lines.push(` ${JSON.stringify(installation.id)} · ${JSON.stringify(installation.pluginId)} · ${installation.installationState}`);
|
|
201
|
+
}
|
|
202
|
+
lines.push('Other scopes are never mutated; references left there fail closed as unavailable until repaired or removed.');
|
|
203
|
+
return lines.join('\n');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function terminalGuard<P extends { terminal: boolean; fence: AttemptFenceHandle; stateRevision: string; scope: Scope }>(
|
|
207
|
+
preflight: P,
|
|
208
|
+
operation: string,
|
|
209
|
+
trigger: string,
|
|
210
|
+
): RemovalOutcome | undefined {
|
|
211
|
+
if (!preflight.terminal) return undefined;
|
|
212
|
+
preflight.fence.release();
|
|
213
|
+
const findings: ValidationFinding[] = [
|
|
214
|
+
blocking({ code: CODE.ATTEMPT_IN_PROGRESS, rule: RULE.ATTEMPT_IN_PROGRESS, target: 'attempt', pointer: '', outcome: 'attempt already reached a terminal outcome', scope: preflight.scope, phase: 'admission' }),
|
|
215
|
+
];
|
|
216
|
+
return {
|
|
217
|
+
status: 'blocked',
|
|
218
|
+
findings,
|
|
219
|
+
receipt: createReceipt({ operation, scope: preflight.scope, trigger, expectedStateRevision: preflight.stateRevision, summary: 'Blocked', findings }),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Confirm the disclosed Registration Removal. yes=false declines with no state mutation. */
|
|
224
|
+
export async function confirmRegistrationRemoval(
|
|
225
|
+
preflight: RegistrationRemovalPreflight,
|
|
226
|
+
yes: boolean,
|
|
227
|
+
opts: RemovalFlowOptions = {},
|
|
228
|
+
): Promise<RemovalOutcome> {
|
|
229
|
+
const OPERATION = 'Registration Removal';
|
|
230
|
+
const trigger = `remove registration ${preflight.registrationId}`;
|
|
231
|
+
const early = terminalGuard(preflight, OPERATION, trigger);
|
|
232
|
+
if (early) return early;
|
|
233
|
+
preflight.terminal = true;
|
|
234
|
+
|
|
235
|
+
if (!yes) {
|
|
236
|
+
const receipt = createReceipt({
|
|
237
|
+
operation: OPERATION,
|
|
238
|
+
scope: preflight.scope,
|
|
239
|
+
trigger,
|
|
240
|
+
expectedStateRevision: preflight.stateRevision,
|
|
241
|
+
validationSnapshot: undefined,
|
|
242
|
+
summary: 'Declined',
|
|
243
|
+
findings: [],
|
|
244
|
+
stateChanged: false,
|
|
245
|
+
});
|
|
246
|
+
preflight.fence.release();
|
|
247
|
+
return { status: 'declined', receipt };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Re-verify exact State Revision under the held confirmation binding.
|
|
251
|
+
const fresh = await readBridgeState(preflight.scope, { cwd: opts.cwd, agentDir: opts.agentDir });
|
|
252
|
+
if (fresh.status !== 'ok' && fresh.status !== 'missing') {
|
|
253
|
+
preflight.fence.release();
|
|
254
|
+
const findings: ValidationFinding[] = [
|
|
255
|
+
blocking({ code: CODE.PERSISTENCE_INDETERMINATE, rule: 'PERSIST-01', target: 'attempt', pointer: '', outcome: fresh.error ?? 'Bridge State is not readable; neither previous nor target verifiable', scope: preflight.scope, phase: 'persistence' }),
|
|
256
|
+
];
|
|
257
|
+
return { status: 'persistence-failed', isIndeterminate: true, receipt: createReceipt({ operation: OPERATION, scope: preflight.scope, trigger, expectedStateRevision: preflight.stateRevision, summary: 'Persistence Indeterminate', findings, stateChanged: false }) };
|
|
258
|
+
}
|
|
259
|
+
if (fresh.state!.stateRevision !== preflight.stateRevision) {
|
|
260
|
+
const outcome = staleReceipt(OPERATION, preflight.scope, trigger, preflight.stateRevision, fresh.state!.stateRevision);
|
|
261
|
+
preflight.fence.release();
|
|
262
|
+
return outcome;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const write = await commitBridgeState(
|
|
266
|
+
preflight.scope,
|
|
267
|
+
(current) => ({
|
|
268
|
+
...current,
|
|
269
|
+
registrations: current.registrations.filter((r) => r.id !== preflight.registrationId),
|
|
270
|
+
installations: current.installations.filter((i) => i.registrationId !== preflight.registrationId),
|
|
271
|
+
}),
|
|
272
|
+
{ cwd: opts.cwd, agentDir: opts.agentDir, lockTimeoutMs: opts.fenceTimeoutMs, expectedStateRevision: preflight.stateRevision },
|
|
273
|
+
);
|
|
274
|
+
preflight.fence.release();
|
|
275
|
+
if (write.isStale) return staleReceipt(OPERATION, preflight.scope, trigger, preflight.stateRevision, write.observedRevision);
|
|
276
|
+
if (!write.success) return persistenceFailure(OPERATION, preflight.scope, trigger, preflight.stateRevision, write);
|
|
277
|
+
|
|
278
|
+
const newRevision = write.newRevision!;
|
|
279
|
+
// A removed Registration's pending Update Candidate pin is no longer meaningful (#22).
|
|
280
|
+
if (preflight.registrationId) {
|
|
281
|
+
const cache = opts.cache ?? new SourceCache({ agentDir: opts.agentDir });
|
|
282
|
+
cache.clearPendingUpdate(preflight.scope, preflight.registrationId);
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
status: 'completed',
|
|
286
|
+
newRevision,
|
|
287
|
+
receipt: createReceipt({
|
|
288
|
+
operation: OPERATION,
|
|
289
|
+
scope: preflight.scope,
|
|
290
|
+
trigger,
|
|
291
|
+
expectedStateRevision: preflight.stateRevision,
|
|
292
|
+
targetStateRevision: newRevision,
|
|
293
|
+
observedStateRevision: newRevision,
|
|
294
|
+
summary: 'Completed',
|
|
295
|
+
stateChanged: true,
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Inherited Installations that become effective once this one is removed. */
|
|
301
|
+
async function resumingInherited(scope: Scope, installation: Installation, opts: RemovalFlowOptions): Promise<Installation[]> {
|
|
302
|
+
if (scope !== 'project') return [];
|
|
303
|
+
const [global, project] = await Promise.all([
|
|
304
|
+
readBridgeState('global', { cwd: opts.cwd, agentDir: opts.agentDir }),
|
|
305
|
+
readBridgeState('project', { cwd: opts.cwd, agentDir: opts.agentDir }),
|
|
306
|
+
]);
|
|
307
|
+
if (global.status !== 'ok' && global.status !== 'missing') return [];
|
|
308
|
+
// Suppressions live in the PROJECT document and reveal/hide inherited records by ID.
|
|
309
|
+
const projectOverrides = project.state?.scopeOverrides ?? [];
|
|
310
|
+
const suppressedInstallations = new Set(projectOverrides.filter((o) => o.kind === 'installation').map((o) => o.targetId));
|
|
311
|
+
const suppressedRegistrations = new Set(projectOverrides.filter((o) => o.kind === 'registration').map((o) => o.targetId));
|
|
312
|
+
return (global.state?.installations ?? []).filter(
|
|
313
|
+
(candidate) =>
|
|
314
|
+
candidate.pluginId === installation.pluginId &&
|
|
315
|
+
candidate.installationState === 'enabled' &&
|
|
316
|
+
!suppressedInstallations.has(candidate.id) &&
|
|
317
|
+
!(candidate.registrationId && suppressedRegistrations.has(candidate.registrationId)),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Preflight an Installation Removal with its inherited-resumption disclosure. */
|
|
322
|
+
export async function preflightInstallationRemoval(
|
|
323
|
+
scope: Scope,
|
|
324
|
+
installationId: string,
|
|
325
|
+
opts: RemovalFlowOptions = {},
|
|
326
|
+
): Promise<RemovalPreflightResult<InstallationRemovalPreflight>> {
|
|
327
|
+
const OPERATION = 'Installation Removal';
|
|
328
|
+
const trigger = `remove installation ${installationId}`;
|
|
329
|
+
const read = await readBridgeState(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
|
|
330
|
+
if (read.status !== 'ok' && read.status !== 'missing') return persistenceBlocked(scope, OPERATION, trigger, read.error);
|
|
331
|
+
const state = read.state!;
|
|
332
|
+
|
|
333
|
+
const trust = trustFinding(scope);
|
|
334
|
+
if (trust && opts.projectTrusted !== true) {
|
|
335
|
+
const findings = [trust];
|
|
336
|
+
return { ok: false, outcome: { status: 'blocked', findings, receipt: createReceipt({ operation: OPERATION, scope, trigger, expectedStateRevision: state.stateRevision, summary: 'Blocked', findings }) } };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const installation = state.installations.find((item) => item.id === installationId);
|
|
340
|
+
if (!installation) {
|
|
341
|
+
const findings: ValidationFinding[] = [
|
|
342
|
+
blocking({ code: CODE.INSTALLATION_NOT_FOUND, rule: RULE.INSTALLATION_NOT_FOUND, target: 'installation', pointer: '', outcome: `Installation '${installationId}' is not in ${scope} Bridge State`, scope, phase: 'admission' }),
|
|
343
|
+
];
|
|
344
|
+
return { ok: false, outcome: { status: 'blocked', findings, receipt: createReceipt({ operation: OPERATION, scope, trigger, expectedStateRevision: state.stateRevision, summary: 'Blocked', findings }) } };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const fence = await acquireAttemptFence(scope, { cwd: opts.cwd, agentDir: opts.agentDir, fenceTimeoutMs: opts.fenceTimeoutMs });
|
|
348
|
+
if (!fence.ok) {
|
|
349
|
+
const findings = [fence.finding!];
|
|
350
|
+
return { ok: false, outcome: { status: 'blocked', findings, receipt: createReceipt({ operation: OPERATION, scope, trigger, expectedStateRevision: state.stateRevision, summary: 'Blocked', findings }) } };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const resuming = await resumingInherited(scope, installation, opts);
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
preflight: {
|
|
358
|
+
scope,
|
|
359
|
+
installation,
|
|
360
|
+
registrationId: installation.registrationId,
|
|
361
|
+
resumingInheritedInstallations: resuming,
|
|
362
|
+
stateRevision: state.stateRevision,
|
|
363
|
+
fence: fence.handle!,
|
|
364
|
+
terminal: false,
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Full disclosure for the confirmation surface. */
|
|
370
|
+
export function installationRemovalDisclosure(preflight: InstallationRemovalPreflight): string {
|
|
371
|
+
const lines = [
|
|
372
|
+
`Scope: ${preflight.scope}`,
|
|
373
|
+
`Installation: ${JSON.stringify(preflight.installation.id)} · ${JSON.stringify(preflight.installation.pluginId)} · ${preflight.installation.installationState}`,
|
|
374
|
+
`Registration retained: ${preflight.registrationId ? preflight.registrationId.slice(0, 8) + '…' : '(none)'}`,
|
|
375
|
+
`State Revision: ${preflight.stateRevision}`,
|
|
376
|
+
];
|
|
377
|
+
if (preflight.resumingInheritedInstallations.length > 0) {
|
|
378
|
+
lines.push('Inherited Installations that become effective afterward:');
|
|
379
|
+
for (const inherited of preflight.resumingInheritedInstallations) {
|
|
380
|
+
lines.push(` ${JSON.stringify(inherited.id)} · ${JSON.stringify(inherited.pluginId)}`);
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
lines.push('No inherited Installation becomes effective afterward.');
|
|
384
|
+
}
|
|
385
|
+
return lines.join('\n');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Confirm the disclosed Installation Removal. yes=false declines with no state mutation. */
|
|
389
|
+
export async function confirmInstallationRemoval(
|
|
390
|
+
preflight: InstallationRemovalPreflight,
|
|
391
|
+
yes: boolean,
|
|
392
|
+
opts: RemovalFlowOptions = {},
|
|
393
|
+
): Promise<RemovalOutcome> {
|
|
394
|
+
const OPERATION = 'Installation Removal';
|
|
395
|
+
const trigger = `remove installation ${preflight.installation.id}`;
|
|
396
|
+
const early = terminalGuard(preflight, OPERATION, trigger);
|
|
397
|
+
if (early) return early;
|
|
398
|
+
preflight.terminal = true;
|
|
399
|
+
|
|
400
|
+
if (!yes) {
|
|
401
|
+
const receipt = createReceipt({
|
|
402
|
+
operation: OPERATION,
|
|
403
|
+
scope: preflight.scope,
|
|
404
|
+
trigger,
|
|
405
|
+
expectedStateRevision: preflight.stateRevision,
|
|
406
|
+
summary: 'Declined',
|
|
407
|
+
findings: [],
|
|
408
|
+
stateChanged: false,
|
|
409
|
+
});
|
|
410
|
+
preflight.fence.release();
|
|
411
|
+
return { status: 'declined', receipt };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const fresh = await readBridgeState(preflight.scope, { cwd: opts.cwd, agentDir: opts.agentDir });
|
|
415
|
+
if (fresh.status !== 'ok' && fresh.status !== 'missing') {
|
|
416
|
+
preflight.fence.release();
|
|
417
|
+
const findings: ValidationFinding[] = [
|
|
418
|
+
blocking({ code: CODE.PERSISTENCE_INDETERMINATE, rule: 'PERSIST-01', target: 'attempt', pointer: '', outcome: fresh.error ?? 'Bridge State is not readable; neither previous nor target verifiable', scope: preflight.scope, phase: 'persistence' }),
|
|
419
|
+
];
|
|
420
|
+
return { status: 'persistence-failed', isIndeterminate: true, receipt: createReceipt({ operation: OPERATION, scope: preflight.scope, trigger, expectedStateRevision: preflight.stateRevision, summary: 'Persistence Indeterminate', findings, stateChanged: false }) };
|
|
421
|
+
}
|
|
422
|
+
if (fresh.state!.stateRevision !== preflight.stateRevision || !fresh.state!.installations.some((i) => i.id === preflight.installation.id)) {
|
|
423
|
+
const outcome = staleReceipt(OPERATION, preflight.scope, trigger, preflight.stateRevision, fresh.state!.stateRevision);
|
|
424
|
+
preflight.fence.release();
|
|
425
|
+
return outcome;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const write = await commitBridgeState(
|
|
429
|
+
preflight.scope,
|
|
430
|
+
(current) => ({
|
|
431
|
+
...current,
|
|
432
|
+
installations: current.installations.filter((i) => i.id !== preflight.installation.id),
|
|
433
|
+
}),
|
|
434
|
+
{ cwd: opts.cwd, agentDir: opts.agentDir, lockTimeoutMs: opts.fenceTimeoutMs, expectedStateRevision: preflight.stateRevision },
|
|
435
|
+
);
|
|
436
|
+
preflight.fence.release();
|
|
437
|
+
if (write.isStale) return staleReceipt(OPERATION, preflight.scope, trigger, preflight.stateRevision, write.observedRevision);
|
|
438
|
+
if (!write.success) return persistenceFailure(OPERATION, preflight.scope, trigger, preflight.stateRevision, write);
|
|
439
|
+
|
|
440
|
+
const newRevision = write.newRevision!;
|
|
441
|
+
// Installation Removal does not invalidate the pending Update Candidate: the Registration and
|
|
442
|
+
// its candidate survive, so the pin must remain (fail-closed, #22).
|
|
443
|
+
return {
|
|
444
|
+
status: 'completed',
|
|
445
|
+
newRevision,
|
|
446
|
+
receipt: createReceipt({
|
|
447
|
+
operation: OPERATION,
|
|
448
|
+
scope: preflight.scope,
|
|
449
|
+
trigger,
|
|
450
|
+
expectedStateRevision: preflight.stateRevision,
|
|
451
|
+
targetStateRevision: newRevision,
|
|
452
|
+
observedStateRevision: newRevision,
|
|
453
|
+
summary: 'Completed',
|
|
454
|
+
stateChanged: true,
|
|
455
|
+
}),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update Plan — the Validation Snapshot- and State Revision-bound set of explicit outcomes
|
|
3
|
+
* required before Apply Update / Registration Rebind can replace one Registration's source state.
|
|
4
|
+
* See CONTEXT.md: Update Plan, Apply Update, Registration Rebind, Activation Confirmation,
|
|
5
|
+
* Registration Confirmation.
|
|
6
|
+
*
|
|
7
|
+
* A plan requires:
|
|
8
|
+
* - a fresh Registration Confirmation bound to the candidate snapshot + observed State Revision;
|
|
9
|
+
* - an update / disablement / removal outcome for every existing Installation;
|
|
10
|
+
* - an Activation Confirmation for every enabled Installation that remains enabled;
|
|
11
|
+
* - Installations without a Compatible candidate in the new snapshot must be disabled or
|
|
12
|
+
* removed, or the plan is abandoned.
|
|
13
|
+
*
|
|
14
|
+
* The plan itself commits nothing; `applyUpdate` performs the single atomic commit.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Installation, Registration, Scope } from '../bridge-state/types.js';
|
|
18
|
+
import type { CompatiblePlugin } from '../compatibility/profile.js';
|
|
19
|
+
import { CODE, RULE, blocking, type ValidationFinding } from '../registration/findings.js';
|
|
20
|
+
import type { ValidationSnapshot } from '../registration/snapshot.js';
|
|
21
|
+
import type { UpdateCandidate } from './refresh.js';
|
|
22
|
+
|
|
23
|
+
export type InstallationChoice = 'update' | 'disable' | 'remove';
|
|
24
|
+
|
|
25
|
+
export interface PlanEntry {
|
|
26
|
+
installationId: string;
|
|
27
|
+
pluginId: string;
|
|
28
|
+
choice: InstallationChoice;
|
|
29
|
+
/** Durable state before the plan commits. */
|
|
30
|
+
currentState: 'enabled' | 'disabled';
|
|
31
|
+
/** Durable state after the plan commits. */
|
|
32
|
+
installationState: 'enabled' | 'disabled';
|
|
33
|
+
/** Activation-bound snapshot under the candidate tree (updated installations only). */
|
|
34
|
+
newSnapshot?: ValidationSnapshot;
|
|
35
|
+
/** New snapshot-scoped Marketplace Entry pointer (updated installations only). */
|
|
36
|
+
newMarketplaceEntryId?: string;
|
|
37
|
+
manifestName?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UpdatePlan {
|
|
41
|
+
kind: 'apply-update' | 'rebind';
|
|
42
|
+
scope: Scope;
|
|
43
|
+
registrationId: string;
|
|
44
|
+
/** State Revision observed while the plan was built; Apply Update re-verifies it under CAS. */
|
|
45
|
+
stateRevision: string;
|
|
46
|
+
candidate: UpdateCandidate;
|
|
47
|
+
entries: PlanEntry[];
|
|
48
|
+
/** Rebind-only: the complete replacement source attributes preserving the Registration ID. */
|
|
49
|
+
rebindSource?: RebindSourceAttributes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Replacement locator/selector/revision attributes for a Registration Rebind. */
|
|
53
|
+
export interface RebindSourceAttributes {
|
|
54
|
+
sourceKind: 'local' | 'git';
|
|
55
|
+
source: string;
|
|
56
|
+
sourceKey: UpdateCandidate['sourceKey'];
|
|
57
|
+
canonicalLocator?: string;
|
|
58
|
+
gitSelector?: Registration['gitSelector'];
|
|
59
|
+
resolvedRevision?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface UpdatePlanInput {
|
|
63
|
+
/** Fresh Registration Confirmation bound to the candidate snapshot + State Revision (Default No). */
|
|
64
|
+
registrationConfirmed: boolean;
|
|
65
|
+
/** Explicit outcome per existing Installation ID — never batched, never defaulted. */
|
|
66
|
+
choices: Record<string, InstallationChoice>;
|
|
67
|
+
/** Per-Installation Activation Confirmations for enabled installations that remain enabled. */
|
|
68
|
+
activationConfirmations?: Record<string, boolean>;
|
|
69
|
+
kind?: 'apply-update' | 'rebind';
|
|
70
|
+
/** Rebind-only: validated replacement source attributes. */
|
|
71
|
+
rebindSource?: RebindSourceAttributes;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type UpdatePlanResult =
|
|
75
|
+
| { ok: true; plan: UpdatePlan }
|
|
76
|
+
| { ok: false; problems: ValidationFinding[] };
|
|
77
|
+
|
|
78
|
+
function planProblem(scope: Scope, target: ValidationFinding['target'], pointer: string, code: string, outcome: string): ValidationFinding {
|
|
79
|
+
return blocking({ code, rule: RULE.UPDATE_PLAN_INCOMPLETE, target, pointer, outcome, scope, phase: 'admission' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Plugin IDs with a Compatible candidate in the new Validation Snapshot — the identity an
|
|
84
|
+
* Installation keeps across updates. Shared by plan validation and the TUI checklist so the
|
|
85
|
+
* disclosure surface and the commit rules can never diverge.
|
|
86
|
+
*/
|
|
87
|
+
export function compatibleCandidateIds(candidate: UpdateCandidate): Set<string> {
|
|
88
|
+
const ids = new Set<string>();
|
|
89
|
+
for (const item of candidate.inspection.entries) {
|
|
90
|
+
if (!item.plugin || item.unavailableReason) continue;
|
|
91
|
+
if (item.findings.some((f) => f.classification === 'blocking')) continue;
|
|
92
|
+
ids.add(item.plugin.id);
|
|
93
|
+
}
|
|
94
|
+
return ids;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Compatible candidates keyed by Plugin ID. */
|
|
98
|
+
function compatibleById(candidate: UpdateCandidate): Map<string, { plugin: CompatiblePlugin; snapshot: ValidationSnapshot }> {
|
|
99
|
+
const map = new Map<string, { plugin: CompatiblePlugin; snapshot: ValidationSnapshot }>();
|
|
100
|
+
// Every updated installation binds the same marketplace-wide activation-bound snapshot
|
|
101
|
+
// (catalog captures folded in by inspection) that a fresh installation under the candidate
|
|
102
|
+
// tree would receive — the same convention Plugin Installation has used since #19.
|
|
103
|
+
const activationSnapshot = candidate.inspection.snapshot ?? candidate.snapshot;
|
|
104
|
+
for (const item of candidate.inspection.entries) {
|
|
105
|
+
if (!compatibleCandidateIds(candidate).has(item.plugin?.id ?? '')) continue;
|
|
106
|
+
map.set(item.plugin!.id, { plugin: item.plugin!, snapshot: activationSnapshot });
|
|
107
|
+
}
|
|
108
|
+
return map;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Assemble and validate an Update Plan. Any missing consent or outcome abandons the plan with
|
|
113
|
+
* structured Blocking findings — nothing partial is ever committable.
|
|
114
|
+
*/
|
|
115
|
+
export function buildUpdatePlan(
|
|
116
|
+
candidate: UpdateCandidate,
|
|
117
|
+
installations: Installation[],
|
|
118
|
+
stateRevision: string,
|
|
119
|
+
input: UpdatePlanInput,
|
|
120
|
+
): UpdatePlanResult {
|
|
121
|
+
const scope = candidate.scope;
|
|
122
|
+
const problems: ValidationFinding[] = [];
|
|
123
|
+
const kind = input.kind ?? 'apply-update';
|
|
124
|
+
|
|
125
|
+
if (kind === 'rebind' && !input.rebindSource) {
|
|
126
|
+
problems.push(planProblem(scope, 'attempt', '', CODE.UPDATE_PLAN_INCOMPLETE, 'Registration Rebind requires validated replacement source attributes'));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!input.registrationConfirmed) {
|
|
130
|
+
problems.push(
|
|
131
|
+
planProblem(
|
|
132
|
+
scope,
|
|
133
|
+
'registration',
|
|
134
|
+
candidate.registrationId,
|
|
135
|
+
CODE.UPDATE_PLAN_INCOMPLETE,
|
|
136
|
+
'a fresh Registration Confirmation bound to the candidate Validation Snapshot and State Revision is required before Apply Update',
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const candidates = compatibleById(candidate);
|
|
142
|
+
const entries: PlanEntry[] = [];
|
|
143
|
+
|
|
144
|
+
for (const installation of installations) {
|
|
145
|
+
const choice = input.choices[installation.id];
|
|
146
|
+
if (!choice) {
|
|
147
|
+
problems.push(
|
|
148
|
+
planProblem(scope, 'installation', installation.id, CODE.UPDATE_PLAN_INCOMPLETE, `Installation '${installation.id}' has no update/disable/remove outcome; the plan cannot commit without an explicit choice`),
|
|
149
|
+
);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (choice !== 'update' && choice !== 'disable' && choice !== 'remove') {
|
|
153
|
+
problems.push(planProblem(scope, 'installation', installation.id, CODE.UPDATE_PLAN_INCOMPLETE, `unknown outcome '${String(choice)}'`));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (choice === 'update') {
|
|
158
|
+
const match = candidates.get(installation.pluginId);
|
|
159
|
+
if (!match) {
|
|
160
|
+
problems.push(
|
|
161
|
+
planProblem(
|
|
162
|
+
scope,
|
|
163
|
+
'installation',
|
|
164
|
+
installation.id,
|
|
165
|
+
CODE.UPDATE_PLAN_INCOMPLETE,
|
|
166
|
+
`no Compatible candidate for Plugin '${installation.pluginId}' exists in the new Validation Snapshot; the Installation must be disabled or removed, or the plan abandoned`,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const remainsEnabled = installation.installationState === 'enabled';
|
|
172
|
+
if (remainsEnabled && input.activationConfirmations?.[installation.id] !== true) {
|
|
173
|
+
problems.push(
|
|
174
|
+
blocking({
|
|
175
|
+
code: CODE.ACTIVATION_CONFIRMATION_REQUIRED,
|
|
176
|
+
rule: RULE.ACTIVATION_CONFIRMATION_REQUIRED,
|
|
177
|
+
target: 'installation',
|
|
178
|
+
pointer: installation.id,
|
|
179
|
+
outcome: 'an enabled Installation that remains enabled needs its own Activation Confirmation bound to the new Validation Snapshot (default No)',
|
|
180
|
+
scope,
|
|
181
|
+
phase: 'admission',
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
entries.push({
|
|
187
|
+
installationId: installation.id,
|
|
188
|
+
pluginId: installation.pluginId,
|
|
189
|
+
choice,
|
|
190
|
+
currentState: installation.installationState,
|
|
191
|
+
installationState: remainsEnabled ? 'enabled' : 'disabled',
|
|
192
|
+
newSnapshot: match.snapshot,
|
|
193
|
+
newMarketplaceEntryId: match.plugin.marketplaceEntryId,
|
|
194
|
+
manifestName: match.plugin.manifestName,
|
|
195
|
+
});
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
entries.push({
|
|
200
|
+
installationId: installation.id,
|
|
201
|
+
pluginId: installation.pluginId,
|
|
202
|
+
choice,
|
|
203
|
+
currentState: installation.installationState,
|
|
204
|
+
installationState: 'disabled',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (problems.length > 0) return { ok: false, problems };
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
plan: {
|
|
213
|
+
kind,
|
|
214
|
+
scope,
|
|
215
|
+
registrationId: candidate.registrationId,
|
|
216
|
+
stateRevision,
|
|
217
|
+
candidate,
|
|
218
|
+
entries,
|
|
219
|
+
rebindSource: input.rebindSource,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|