@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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/broker/broker.d.ts +53 -0
  3. package/dist/broker/broker.d.ts.map +1 -0
  4. package/dist/broker/broker.js +346 -0
  5. package/dist/broker/broker.js.map +1 -0
  6. package/dist/bundle-lifecycle.d.ts +75 -0
  7. package/dist/bundle-lifecycle.d.ts.map +1 -0
  8. package/dist/bundle-lifecycle.js +247 -0
  9. package/dist/bundle-lifecycle.js.map +1 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +10 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/ipc/control-transport.d.ts +59 -0
  15. package/dist/ipc/control-transport.d.ts.map +1 -0
  16. package/dist/ipc/control-transport.js +389 -0
  17. package/dist/ipc/control-transport.js.map +1 -0
  18. package/dist/ipc/endpoint.d.ts +15 -0
  19. package/dist/ipc/endpoint.d.ts.map +1 -0
  20. package/dist/ipc/endpoint.js +16 -0
  21. package/dist/ipc/endpoint.js.map +1 -0
  22. package/dist/ipc/framing.d.ts +4 -0
  23. package/dist/ipc/framing.d.ts.map +1 -0
  24. package/dist/ipc/framing.js +32 -0
  25. package/dist/ipc/framing.js.map +1 -0
  26. package/dist/ipc/unix.d.ts +17 -0
  27. package/dist/ipc/unix.d.ts.map +1 -0
  28. package/dist/ipc/unix.js +106 -0
  29. package/dist/ipc/unix.js.map +1 -0
  30. package/dist/ipc/windows.d.ts +13 -0
  31. package/dist/ipc/windows.d.ts.map +1 -0
  32. package/dist/ipc/windows.js +10 -0
  33. package/dist/ipc/windows.js.map +1 -0
  34. package/dist/policy/policy.d.ts +23 -0
  35. package/dist/policy/policy.d.ts.map +1 -0
  36. package/dist/policy/policy.js +53 -0
  37. package/dist/policy/policy.js.map +1 -0
  38. package/dist/providers/supervisor.d.ts +17 -0
  39. package/dist/providers/supervisor.d.ts.map +1 -0
  40. package/dist/providers/supervisor.js +122 -0
  41. package/dist/providers/supervisor.js.map +1 -0
  42. package/package.json +37 -0
  43. package/src/broker/broker.ts +439 -0
  44. package/src/bundle-lifecycle.ts +385 -0
  45. package/src/index.ts +9 -0
  46. package/src/ipc/control-transport.ts +517 -0
  47. package/src/ipc/endpoint.ts +27 -0
  48. package/src/ipc/framing.ts +31 -0
  49. package/src/ipc/unix.ts +110 -0
  50. package/src/ipc/windows.ts +26 -0
  51. package/src/policy/policy.ts +73 -0
  52. 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'