@crosshands/runtime 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/broker/broker.d.ts +53 -0
- package/dist/broker/broker.d.ts.map +1 -0
- package/dist/broker/broker.js +346 -0
- package/dist/broker/broker.js.map +1 -0
- package/dist/bundle-lifecycle.d.ts +75 -0
- package/dist/bundle-lifecycle.d.ts.map +1 -0
- package/dist/bundle-lifecycle.js +247 -0
- package/dist/bundle-lifecycle.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/control-transport.d.ts +59 -0
- package/dist/ipc/control-transport.d.ts.map +1 -0
- package/dist/ipc/control-transport.js +389 -0
- package/dist/ipc/control-transport.js.map +1 -0
- package/dist/ipc/endpoint.d.ts +15 -0
- package/dist/ipc/endpoint.d.ts.map +1 -0
- package/dist/ipc/endpoint.js +16 -0
- package/dist/ipc/endpoint.js.map +1 -0
- package/dist/ipc/framing.d.ts +4 -0
- package/dist/ipc/framing.d.ts.map +1 -0
- package/dist/ipc/framing.js +32 -0
- package/dist/ipc/framing.js.map +1 -0
- package/dist/ipc/unix.d.ts +17 -0
- package/dist/ipc/unix.d.ts.map +1 -0
- package/dist/ipc/unix.js +106 -0
- package/dist/ipc/unix.js.map +1 -0
- package/dist/ipc/windows.d.ts +13 -0
- package/dist/ipc/windows.d.ts.map +1 -0
- package/dist/ipc/windows.js +10 -0
- package/dist/ipc/windows.js.map +1 -0
- package/dist/policy/policy.d.ts +23 -0
- package/dist/policy/policy.d.ts.map +1 -0
- package/dist/policy/policy.js +53 -0
- package/dist/policy/policy.js.map +1 -0
- package/dist/providers/supervisor.d.ts +17 -0
- package/dist/providers/supervisor.d.ts.map +1 -0
- package/dist/providers/supervisor.js +122 -0
- package/dist/providers/supervisor.js.map +1 -0
- package/package.json +37 -0
- package/src/broker/broker.ts +439 -0
- package/src/bundle-lifecycle.ts +385 -0
- package/src/index.ts +9 -0
- package/src/ipc/control-transport.ts +517 -0
- package/src/ipc/endpoint.ts +27 -0
- package/src/ipc/framing.ts +31 -0
- package/src/ipc/unix.ts +110 -0
- package/src/ipc/windows.ts +26 -0
- package/src/policy/policy.ts +73 -0
- package/src/providers/supervisor.ts +140 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
export type BundleArtifact = {
|
|
2
|
+
name: string
|
|
3
|
+
version: string
|
|
4
|
+
expectedDigest: string
|
|
5
|
+
actualDigest: string
|
|
6
|
+
trustVerified: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type BundleCandidate = {
|
|
10
|
+
version: string
|
|
11
|
+
release: number
|
|
12
|
+
manifestDigest: string
|
|
13
|
+
requiredArtifacts: readonly string[]
|
|
14
|
+
artifacts: readonly BundleArtifact[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type LifecyclePhase =
|
|
18
|
+
| 'idle'
|
|
19
|
+
| 'running'
|
|
20
|
+
| 'draining'
|
|
21
|
+
| 'activating'
|
|
22
|
+
| 'uninstalling'
|
|
23
|
+
| 'uninstalled'
|
|
24
|
+
|
|
25
|
+
export type LifecycleErrorCode =
|
|
26
|
+
| 'bundle_incomplete'
|
|
27
|
+
| 'bundle_integrity'
|
|
28
|
+
| 'bundle_version_mismatch'
|
|
29
|
+
| 'lifecycle_busy'
|
|
30
|
+
| 'mutation_rejected'
|
|
31
|
+
| 'version_incompatible'
|
|
32
|
+
|
|
33
|
+
export class BundleLifecycleError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
readonly code: LifecycleErrorCode,
|
|
36
|
+
message: string
|
|
37
|
+
) {
|
|
38
|
+
super(message)
|
|
39
|
+
this.name = 'BundleLifecycleError'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ActivationHooks = {
|
|
44
|
+
/** Runs before the atomic active-version pointer changes. */
|
|
45
|
+
beforeCommit?: () => void | Promise<void>
|
|
46
|
+
/** Runs after the pointer changes, to model restart/interruption after commit. */
|
|
47
|
+
afterCommit?: () => void | Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ClientState = {
|
|
51
|
+
id: string
|
|
52
|
+
version: string
|
|
53
|
+
reconnectRequired: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type DispatchVersions = {
|
|
57
|
+
client: string
|
|
58
|
+
broker: string
|
|
59
|
+
provider: string
|
|
60
|
+
payload: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type InteractionContextGeneration = {
|
|
64
|
+
generation: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type InstalledResourceKind =
|
|
68
|
+
| 'broker-registration'
|
|
69
|
+
| 'ipc'
|
|
70
|
+
| 'cache'
|
|
71
|
+
| 'log'
|
|
72
|
+
| 'temporary-capture'
|
|
73
|
+
| 'export'
|
|
74
|
+
| 'configuration'
|
|
75
|
+
| 'permission'
|
|
76
|
+
|
|
77
|
+
export type InstalledResource = {
|
|
78
|
+
id: string
|
|
79
|
+
kind: InstalledResourceKind
|
|
80
|
+
owner: 'crosshands' | 'user' | 'os'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type UninstallPlan = {
|
|
84
|
+
remove: readonly InstalledResource[]
|
|
85
|
+
preserve: readonly InstalledResource[]
|
|
86
|
+
manual: readonly InstalledResource[]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const REMOVABLE_CROSSHANDS_RESOURCES = new Set<InstalledResourceKind>([
|
|
90
|
+
'broker-registration',
|
|
91
|
+
'ipc',
|
|
92
|
+
'cache',
|
|
93
|
+
'log',
|
|
94
|
+
'temporary-capture'
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
export function planUninstall(resources: readonly InstalledResource[]): UninstallPlan {
|
|
98
|
+
const remove: InstalledResource[] = []
|
|
99
|
+
const preserve: InstalledResource[] = []
|
|
100
|
+
const manual: InstalledResource[] = []
|
|
101
|
+
|
|
102
|
+
for (const resource of resources) {
|
|
103
|
+
if (resource.kind === 'permission') {
|
|
104
|
+
manual.push(resource)
|
|
105
|
+
} else if (
|
|
106
|
+
resource.owner === 'crosshands' &&
|
|
107
|
+
REMOVABLE_CROSSHANDS_RESOURCES.has(resource.kind)
|
|
108
|
+
) {
|
|
109
|
+
remove.push(resource)
|
|
110
|
+
} else {
|
|
111
|
+
preserve.push(resource)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { remove, preserve, manual }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateCandidate(candidate: BundleCandidate): BundleCandidate {
|
|
119
|
+
if (
|
|
120
|
+
candidate.version.length === 0 ||
|
|
121
|
+
!Number.isSafeInteger(candidate.release) ||
|
|
122
|
+
candidate.release < 0
|
|
123
|
+
) {
|
|
124
|
+
throw new BundleLifecycleError('bundle_version_mismatch', 'Bundle version is invalid')
|
|
125
|
+
}
|
|
126
|
+
if (candidate.manifestDigest.length === 0) {
|
|
127
|
+
throw new BundleLifecycleError('bundle_integrity', 'Bundle manifest digest is missing')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const required = new Set(candidate.requiredArtifacts)
|
|
131
|
+
const artifacts = new Map<string, BundleArtifact>()
|
|
132
|
+
if (required.size !== candidate.requiredArtifacts.length) {
|
|
133
|
+
throw new BundleLifecycleError('bundle_incomplete', 'Required artifact names must be unique')
|
|
134
|
+
}
|
|
135
|
+
for (const artifact of candidate.artifacts) {
|
|
136
|
+
if (artifacts.has(artifact.name)) {
|
|
137
|
+
throw new BundleLifecycleError('bundle_incomplete', 'Bundle artifact names must be unique')
|
|
138
|
+
}
|
|
139
|
+
if (artifact.version !== candidate.version) {
|
|
140
|
+
throw new BundleLifecycleError(
|
|
141
|
+
'bundle_version_mismatch',
|
|
142
|
+
`Artifact ${artifact.name} does not match bundle ${candidate.version}`
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
artifact.expectedDigest.length === 0 ||
|
|
147
|
+
artifact.actualDigest !== artifact.expectedDigest ||
|
|
148
|
+
!artifact.trustVerified
|
|
149
|
+
) {
|
|
150
|
+
throw new BundleLifecycleError(
|
|
151
|
+
'bundle_integrity',
|
|
152
|
+
`Artifact ${artifact.name} failed integrity verification`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
artifacts.set(artifact.name, artifact)
|
|
156
|
+
}
|
|
157
|
+
const missing = [...required].filter((name) => !artifacts.has(name))
|
|
158
|
+
if (missing.length > 0) {
|
|
159
|
+
throw new BundleLifecycleError(
|
|
160
|
+
'bundle_incomplete',
|
|
161
|
+
`Bundle is missing required artifacts: ${missing.join(', ')}`
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Object.freeze({
|
|
166
|
+
...candidate,
|
|
167
|
+
requiredArtifacts: Object.freeze([...candidate.requiredArtifacts]),
|
|
168
|
+
artifacts: Object.freeze(candidate.artifacts.map((artifact) => Object.freeze({ ...artifact })))
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Coordinates installed bundle state. Filesystem/package-manager work is deliberately
|
|
174
|
+
* supplied by callers; this class owns only verification gates and atomic state changes.
|
|
175
|
+
*/
|
|
176
|
+
export class BundleLifecycle {
|
|
177
|
+
readonly #bundles = new Map<string, BundleCandidate>()
|
|
178
|
+
readonly #clients = new Map<string, ClientState>()
|
|
179
|
+
readonly #drainWaiters = new Set<() => void>()
|
|
180
|
+
#phase: LifecyclePhase = 'idle'
|
|
181
|
+
#activeVersion: string | undefined
|
|
182
|
+
#mutationCount = 0
|
|
183
|
+
#contextGeneration = 0
|
|
184
|
+
|
|
185
|
+
get phase(): LifecyclePhase {
|
|
186
|
+
return this.#phase
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get activeVersion(): string | undefined {
|
|
190
|
+
return this.#activeVersion
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get inFlightMutations(): number {
|
|
194
|
+
return this.#mutationCount
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
stage(candidate: BundleCandidate): void {
|
|
198
|
+
this.#assertNotBusy()
|
|
199
|
+
const verified = validateCandidate(candidate)
|
|
200
|
+
const existing = this.#bundles.get(verified.version)
|
|
201
|
+
if (existing !== undefined && existing.manifestDigest !== verified.manifestDigest) {
|
|
202
|
+
throw new BundleLifecycleError(
|
|
203
|
+
'bundle_integrity',
|
|
204
|
+
`Version ${verified.version} is already staged with a different manifest`
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
this.#bundles.set(verified.version, verified)
|
|
208
|
+
if (this.#phase === 'uninstalled') this.#phase = 'idle'
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
bundle(version: string): BundleCandidate | undefined {
|
|
212
|
+
return this.#bundles.get(version)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
beginMutation(): () => void {
|
|
216
|
+
if (this.#phase !== 'running' || this.#activeVersion === undefined) {
|
|
217
|
+
throw new BundleLifecycleError(
|
|
218
|
+
'mutation_rejected',
|
|
219
|
+
`Mutations are unavailable while lifecycle is ${this.#phase}`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
this.#mutationCount += 1
|
|
223
|
+
let completed = false
|
|
224
|
+
return () => {
|
|
225
|
+
if (completed) return
|
|
226
|
+
completed = true
|
|
227
|
+
this.#mutationCount -= 1
|
|
228
|
+
if (this.#mutationCount === 0) {
|
|
229
|
+
for (const resolve of this.#drainWaiters) resolve()
|
|
230
|
+
this.#drainWaiters.clear()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async activate(version: string, hooks: ActivationHooks = {}): Promise<void> {
|
|
236
|
+
this.#assertNotBusy()
|
|
237
|
+
const target = this.#requireBundle(version)
|
|
238
|
+
this.#assertAdjacentToActive(target)
|
|
239
|
+
this.#phase = 'draining'
|
|
240
|
+
await this.#waitForMutations()
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
await hooks.beforeCommit?.()
|
|
244
|
+
this.#phase = 'activating'
|
|
245
|
+
|
|
246
|
+
// This synchronous assignment is the state-machine commit point. Callers map it
|
|
247
|
+
// to an atomic pointer/rename and must not mutate a bundle in place.
|
|
248
|
+
this.#activeVersion = target.version
|
|
249
|
+
this.#contextGeneration += 1
|
|
250
|
+
for (const [id, client] of this.#clients) {
|
|
251
|
+
this.#clients.set(id, { ...client, reconnectRequired: true })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await hooks.afterCommit?.()
|
|
255
|
+
} finally {
|
|
256
|
+
this.#phase = this.#activeVersion === undefined ? 'idle' : 'running'
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async rollback(version: string, hooks: ActivationHooks = {}): Promise<void> {
|
|
261
|
+
await this.activate(version, hooks)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
connectClient(id: string, version: string): ClientState {
|
|
265
|
+
this.#assertClientCompatible(version)
|
|
266
|
+
const client = { id, version, reconnectRequired: false }
|
|
267
|
+
this.#clients.set(id, client)
|
|
268
|
+
return { ...client }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
reconnectClient(id: string, version: string): ClientState {
|
|
272
|
+
if (!this.#clients.has(id)) {
|
|
273
|
+
throw new BundleLifecycleError('version_incompatible', `Client ${id} is not connected`)
|
|
274
|
+
}
|
|
275
|
+
return this.connectClient(id, version)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
client(id: string): ClientState | undefined {
|
|
279
|
+
const client = this.#clients.get(id)
|
|
280
|
+
return client === undefined ? undefined : { ...client }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
assertDispatchCompatible(versions: DispatchVersions): void {
|
|
284
|
+
const active = this.#requireActive()
|
|
285
|
+
for (const [role, version] of [
|
|
286
|
+
['broker', versions.broker],
|
|
287
|
+
['provider', versions.provider],
|
|
288
|
+
['payload', versions.payload]
|
|
289
|
+
] as const) {
|
|
290
|
+
if (version !== active.version) {
|
|
291
|
+
throw new BundleLifecycleError(
|
|
292
|
+
'version_incompatible',
|
|
293
|
+
`${role} ${version} must match active bundle ${active.version}`
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
this.#assertClientCompatible(versions.client)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
issueContext(): InteractionContextGeneration {
|
|
301
|
+
this.#requireActive()
|
|
302
|
+
return { generation: this.#contextGeneration }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
isContextCurrent(context: InteractionContextGeneration): boolean {
|
|
306
|
+
return this.#activeVersion !== undefined && context.generation === this.#contextGeneration
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async uninstall(
|
|
310
|
+
resources: readonly InstalledResource[],
|
|
311
|
+
remove: (resource: InstalledResource) => void | Promise<void>
|
|
312
|
+
): Promise<UninstallPlan> {
|
|
313
|
+
this.#assertNotBusy()
|
|
314
|
+
const plan = planUninstall(resources)
|
|
315
|
+
this.#phase = 'draining'
|
|
316
|
+
await this.#waitForMutations()
|
|
317
|
+
this.#phase = 'uninstalling'
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await Promise.all(plan.remove.map((resource) => remove(resource)))
|
|
321
|
+
} catch (error) {
|
|
322
|
+
this.#phase = this.#activeVersion === undefined ? 'idle' : 'running'
|
|
323
|
+
throw error
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.#bundles.clear()
|
|
327
|
+
this.#clients.clear()
|
|
328
|
+
this.#activeVersion = undefined
|
|
329
|
+
this.#contextGeneration += 1
|
|
330
|
+
this.#phase = 'uninstalled'
|
|
331
|
+
return plan
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#assertNotBusy(): void {
|
|
335
|
+
if (
|
|
336
|
+
this.#phase === 'draining' ||
|
|
337
|
+
this.#phase === 'activating' ||
|
|
338
|
+
this.#phase === 'uninstalling'
|
|
339
|
+
) {
|
|
340
|
+
throw new BundleLifecycleError('lifecycle_busy', `Lifecycle is already ${this.#phase}`)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#requireBundle(version: string): BundleCandidate {
|
|
345
|
+
const bundle = this.#bundles.get(version)
|
|
346
|
+
if (bundle === undefined) {
|
|
347
|
+
throw new BundleLifecycleError('bundle_incomplete', `Bundle ${version} is not staged`)
|
|
348
|
+
}
|
|
349
|
+
return bundle
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#requireActive(): BundleCandidate {
|
|
353
|
+
if (this.#activeVersion === undefined) {
|
|
354
|
+
throw new BundleLifecycleError('bundle_incomplete', 'No bundle is active')
|
|
355
|
+
}
|
|
356
|
+
return this.#requireBundle(this.#activeVersion)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#assertAdjacentToActive(target: BundleCandidate): void {
|
|
360
|
+
if (this.#activeVersion === undefined) return
|
|
361
|
+
const active = this.#requireActive()
|
|
362
|
+
if (Math.abs(target.release - active.release) > 1) {
|
|
363
|
+
throw new BundleLifecycleError(
|
|
364
|
+
'version_incompatible',
|
|
365
|
+
`Bundle ${target.version} is outside the one-release compatibility window of ${active.version}`
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#assertClientCompatible(version: string): void {
|
|
371
|
+
const active = this.#requireActive()
|
|
372
|
+
const client = this.#requireBundle(version)
|
|
373
|
+
if (Math.abs(client.release - active.release) > 1) {
|
|
374
|
+
throw new BundleLifecycleError(
|
|
375
|
+
'version_incompatible',
|
|
376
|
+
`Client ${version} is outside the one-release compatibility window of ${active.version}`
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async #waitForMutations(): Promise<void> {
|
|
382
|
+
if (this.#mutationCount === 0) return
|
|
383
|
+
await new Promise<void>((resolve) => this.#drainWaiters.add(resolve))
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './broker/broker.js'
|
|
2
|
+
export * from './bundle-lifecycle.js'
|
|
3
|
+
export * from './ipc/endpoint.js'
|
|
4
|
+
export * from './ipc/control-transport.js'
|
|
5
|
+
export * from './ipc/framing.js'
|
|
6
|
+
export * from './ipc/unix.js'
|
|
7
|
+
export * from './ipc/windows.js'
|
|
8
|
+
export * from './policy/policy.js'
|
|
9
|
+
export * from './providers/supervisor.js'
|