@crosshands/runtime 0.1.6 → 0.2.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 (41) hide show
  1. package/dist/broker/broker.d.ts +4 -0
  2. package/dist/broker/broker.d.ts.map +1 -1
  3. package/dist/broker/broker.js +194 -47
  4. package/dist/broker/broker.js.map +1 -1
  5. package/dist/diagnostics/paths.d.ts +5 -0
  6. package/dist/diagnostics/paths.d.ts.map +1 -0
  7. package/dist/diagnostics/paths.js +25 -0
  8. package/dist/diagnostics/paths.js.map +1 -0
  9. package/dist/diagnostics/record.d.ts +99 -0
  10. package/dist/diagnostics/record.d.ts.map +1 -0
  11. package/dist/diagnostics/record.js +154 -0
  12. package/dist/diagnostics/record.js.map +1 -0
  13. package/dist/diagnostics/writer.d.ts +27 -0
  14. package/dist/diagnostics/writer.d.ts.map +1 -0
  15. package/dist/diagnostics/writer.js +95 -0
  16. package/dist/diagnostics/writer.js.map +1 -0
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/ipc/control-transport.d.ts +6 -0
  22. package/dist/ipc/control-transport.d.ts.map +1 -1
  23. package/dist/ipc/control-transport.js +13 -1
  24. package/dist/ipc/control-transport.js.map +1 -1
  25. package/dist/ipc/endpoint.d.ts +1 -0
  26. package/dist/ipc/endpoint.d.ts.map +1 -1
  27. package/dist/ipc/endpoint.js +3 -0
  28. package/dist/ipc/endpoint.js.map +1 -1
  29. package/dist/private-directory.d.ts +2 -0
  30. package/dist/private-directory.d.ts.map +1 -0
  31. package/dist/private-directory.js +15 -0
  32. package/dist/private-directory.js.map +1 -0
  33. package/package.json +2 -2
  34. package/src/broker/broker.ts +233 -50
  35. package/src/diagnostics/paths.ts +30 -0
  36. package/src/diagnostics/record.ts +262 -0
  37. package/src/diagnostics/writer.ts +112 -0
  38. package/src/index.ts +4 -0
  39. package/src/ipc/control-transport.ts +20 -1
  40. package/src/ipc/endpoint.ts +4 -0
  41. package/src/private-directory.ts +14 -0
@@ -13,6 +13,18 @@ import {
13
13
  type TargetReference
14
14
  } from '@crosshands/contract'
15
15
 
16
+ import {
17
+ DIAGNOSTIC_RECORD_VERSION,
18
+ diagnosticError,
19
+ diagnosticPlatform,
20
+ diagnosticRequestResult,
21
+ diagnosticTarget,
22
+ emitDiagnostic,
23
+ type DiagnosticEnvelope,
24
+ type DiagnosticRecord,
25
+ type DiagnosticsSink
26
+ } from '../diagnostics/record.js'
27
+ import { graphicalSessionKey } from '../ipc/endpoint.js'
16
28
  import { assertAppAllowed, type StableAppIdentity } from '../policy/policy.js'
17
29
  import { ProviderSupervisor } from '../providers/supervisor.js'
18
30
 
@@ -51,6 +63,7 @@ export type LocalBrokerOptions = {
51
63
  input: unknown
52
64
  ) => Promise<TargetInspection | null>
53
65
  publish?: (response: BrokerResponse) => Promise<void>
66
+ diagnostics?: DiagnosticsSink
54
67
  contextTtlMs?: number
55
68
  now?: () => number
56
69
  }
@@ -73,10 +86,6 @@ class SerialQueue {
73
86
  }
74
87
  }
75
88
 
76
- function isMutation(operation: ComputerOperationName): boolean {
77
- return COMPUTER_OPERATIONS[operation].mutation
78
- }
79
-
80
89
  function assertedInputApp(input: unknown): void {
81
90
  if (input === null || typeof input !== 'object') return
82
91
  const app = (input as { app?: unknown }).app
@@ -169,6 +178,31 @@ function contextToken(input: unknown): string | undefined {
169
178
  return typeof token === 'string' ? token : undefined
170
179
  }
171
180
 
181
+ function observationApp(input: unknown): string | undefined {
182
+ if (input === null || typeof input !== 'object') return undefined
183
+ const app = (input as Record<string, unknown>).app
184
+ return typeof app === 'string' && app.length > 0 ? app : undefined
185
+ }
186
+
187
+ function bindGetAppStateInput(
188
+ input: unknown,
189
+ resolve: (token: string) => InteractionContext
190
+ ): unknown {
191
+ if (input === null || typeof input !== 'object') return input
192
+ const record = input as Record<string, unknown>
193
+ const token = contextToken(record)
194
+ if (token === undefined || observationApp(record) !== undefined) return input
195
+ const context = resolve(token)
196
+ return {
197
+ app: context.appId,
198
+ window: { id: context.window.id },
199
+ ...(typeof record.captureScreenshot === 'boolean'
200
+ ? { captureScreenshot: record.captureScreenshot }
201
+ : {}),
202
+ ...(typeof record.restoreWindow === 'boolean' ? { restoreWindow: record.restoreWindow } : {})
203
+ }
204
+ }
205
+
172
206
  function computerError(cause: unknown): { code?: string; message: string; toJSON?: () => unknown } {
173
207
  if (cause instanceof Error) return cause
174
208
  return { message: 'Unknown provider failure' }
@@ -225,21 +259,26 @@ export class BrokerClient {
225
259
  export class LocalBroker {
226
260
  readonly generation: string
227
261
  readonly #identity: BrokerPeer
262
+ readonly #session: string
228
263
  readonly #supervisor: ProviderSupervisor
229
264
  readonly #contexts: InteractionContextStore
230
265
  readonly #inspectTarget: LocalBrokerOptions['inspectTarget']
231
266
  readonly #publish: LocalBrokerOptions['publish']
267
+ readonly #diagnostics: DiagnosticsSink | undefined
232
268
  readonly #now: () => number
233
269
  readonly #queue = new SerialQueue()
234
270
  #desktopEpoch = 0
235
271
  #requestSequence = 0
272
+ #closed = false
236
273
 
237
274
  constructor(options: LocalBrokerOptions) {
238
275
  this.generation = options.generation ?? `broker-${randomUUID()}`
239
276
  this.#identity = options.identity
277
+ this.#session = graphicalSessionKey(options.identity.graphicalSessionId)
240
278
  this.#inspectTarget = options.inspectTarget
241
279
  this.#publish = options.publish
242
280
  this.#now = options.now ?? Date.now
281
+ this.#diagnostics = options.diagnostics
243
282
  this.#contexts = new InteractionContextStore({
244
283
  ...(options.contextTtlMs === undefined ? {} : { ttlMs: options.contextTtlMs }),
245
284
  now: this.#now
@@ -254,6 +293,20 @@ export class LocalBroker {
254
293
  return this.#desktopEpoch
255
294
  }
256
295
 
296
+ diagnosticEnvelope(): DiagnosticEnvelope {
297
+ return {
298
+ v: DIAGNOSTIC_RECORD_VERSION,
299
+ ts: new Date(this.#now()).toISOString(),
300
+ session: this.#session,
301
+ brokerGeneration: this.generation,
302
+ ...(this.#supervisor.generation === undefined
303
+ ? {}
304
+ : { providerGeneration: this.#supervisor.generation }),
305
+ desktopEpoch: this.#desktopEpoch,
306
+ platform: diagnosticPlatform()
307
+ }
308
+ }
309
+
257
310
  async connect(handshake: {
258
311
  peer: BrokerPeer
259
312
  versions: ContractVersions
@@ -274,9 +327,18 @@ export class LocalBroker {
274
327
  const versions = negotiateVersionHandshake(handshake.versions)
275
328
  if (!versions.ok) throw versions.error
276
329
  await this.#supervisor.start()
330
+ this.#emit({ kind: 'helper.start', ...this.diagnosticEnvelope() })
277
331
  return new BrokerClient(this)
278
332
  }
279
333
 
334
+ async close(reason = 'closed'): Promise<void> {
335
+ if (this.#closed) return
336
+ this.#closed = true
337
+ await this.#supervisor.close()
338
+ this.#emit({ kind: 'broker.stop', reason, ...this.diagnosticEnvelope() })
339
+ await this.#diagnostics?.close()
340
+ }
341
+
280
342
  issueContext(bindings: ReferenceBindings): InteractionContext {
281
343
  return this.#contexts.issue({
282
344
  ...bindings,
@@ -292,59 +354,78 @@ export class LocalBroker {
292
354
  }
293
355
 
294
356
  request(request: BrokerRequest): Promise<BrokerResponse> {
295
- return this.#queue.run(() => this.#requestLocked(request))
357
+ const enqueuedAt = this.#now()
358
+ return this.#queue.run(() => this.#requestLocked(request, enqueuedAt))
296
359
  }
297
360
 
298
- async #requestLocked(request: BrokerRequest): Promise<BrokerResponse> {
361
+ async #requestLocked(request: BrokerRequest, enqueuedAt: number): Promise<BrokerResponse> {
362
+ const startedAt = this.#now()
299
363
  const requestId = `broker-${++this.#requestSequence}`
300
364
  const deadlineAt = this.#now() + (request.deadlineMs ?? 30_000)
301
- const mutation = isMutation(request.operation)
302
- assertedInputApp(request.input)
303
- let context: InteractionContext | undefined
304
- let inspectionInput = request.input
305
- if (mutation) {
306
- const token = contextToken(request.input)
307
- if (token === undefined) {
308
- throw createComputerError(
309
- 'invalid_argument',
310
- 'Mutation requires a context token and target'
311
- )
312
- }
313
- context = this.#contexts.resolve(token)
314
- // Context-window and index shorthand do not carry an identity until the
315
- // broker binds them. Inspect the bound form so native providers can
316
- // re-resolve the exact process/window before dispatch.
317
- inspectionInput = normalizeMutationInput(request.input, context, context)
365
+ const mutation = COMPUTER_OPERATIONS[request.operation].mutation
366
+ let inspectMs = 0
367
+ let dispatchMs = 0
368
+ let bindings: ReferenceBindings | undefined
369
+ let dispatched: boolean | undefined
370
+ let result: unknown
371
+ let cause: unknown
372
+ let input = request.input
373
+ if (request.operation === 'getAppState') {
374
+ input = bindGetAppStateInput(input, (token) => this.#contexts.resolve(token))
318
375
  }
319
- const inspection = await this.#inspectTarget?.(request.operation, inspectionInput)
320
- if (inspection !== undefined && inspection !== null) assertAppAllowed(inspection.appIdentity)
321
-
322
- let providerInput = request.input
323
- if (mutation) {
324
- if (inspection === undefined || inspection === null) {
325
- throw createComputerError('stale_target', 'Target identity could not be re-resolved')
376
+ assertedInputApp(input)
377
+ let context: InteractionContext | undefined
378
+ let inspectionInput = input
379
+ try {
380
+ if (mutation) {
381
+ const token = contextToken(request.input)
382
+ if (token === undefined) {
383
+ throw createComputerError(
384
+ 'invalid_argument',
385
+ 'Mutation requires a context token and target'
386
+ )
387
+ }
388
+ context = this.#contexts.resolve(token)
389
+ // Context-window and index shorthand do not carry an identity until the
390
+ // broker binds them. Inspect the bound form so native providers can
391
+ // re-resolve the exact process/window before dispatch.
392
+ inspectionInput = normalizeMutationInput(request.input, context, context)
326
393
  }
327
- providerInput = normalizeMutationInput(request.input, context!, inspection.bindings)
328
- const references = mutationReferences(providerInput)
329
- if (references.length === 0) {
330
- throw createComputerError('invalid_argument', 'Mutation requires a target selector')
394
+ const inspectStarted = this.#now()
395
+ const inspection = await this.#inspectTarget?.(request.operation, inspectionInput)
396
+ inspectMs = this.#now() - inspectStarted
397
+ if (inspection !== undefined && inspection !== null) {
398
+ assertAppAllowed(inspection.appIdentity)
399
+ bindings = inspection.bindings
331
400
  }
332
- for (const reference of references) {
333
- assertFreshReference(reference, context!, inspection.bindings, this.#now())
401
+
402
+ let providerInput = input
403
+ if (mutation) {
404
+ if (inspection === undefined || inspection === null) {
405
+ throw createComputerError('stale_target', 'Target identity could not be re-resolved')
406
+ }
407
+ providerInput = normalizeMutationInput(request.input, context!, inspection.bindings)
408
+ const references = mutationReferences(providerInput)
409
+ if (references.length === 0) {
410
+ throw createComputerError('invalid_argument', 'Mutation requires a target selector')
411
+ }
412
+ for (const reference of references) {
413
+ assertFreshReference(reference, context!, inspection.bindings, this.#now())
414
+ }
334
415
  }
335
- }
336
416
 
337
- try {
417
+ const dispatchStarted = this.#now()
338
418
  const providerResponse = await this.#supervisor.dispatch({
339
419
  requestId,
340
420
  operation: request.operation,
341
421
  input: providerInput,
342
422
  deadlineAt
343
423
  })
424
+ dispatchMs = this.#now() - dispatchStarted
425
+ dispatched = providerResponse.dispatched
344
426
  if (mutation) {
345
- const dispatched = providerResponse.dispatched
346
427
  if (dispatched) this.#desktopEpoch += 1
347
- const result =
428
+ const mutationResult =
348
429
  'error' in providerResponse
349
430
  ? {
350
431
  outcome: dispatched
@@ -354,43 +435,108 @@ export class LocalBroker {
354
435
  : providerResponse.result
355
436
  const response = {
356
437
  requestId,
357
- result: this.#publicResult(request.operation, result),
438
+ result: this.#publicResult(request.operation, mutationResult),
358
439
  desktopEpoch: this.#desktopEpoch,
359
440
  providerGeneration: this.#supervisor.generation ?? 'unknown'
360
441
  }
442
+ result = response.result
361
443
  await this.#publish?.(response)
362
444
  return response
363
445
  }
364
- return this.#observationResponse(requestId, request.operation, providerResponse.result)
365
- } catch (cause) {
366
- const error = computerError(cause)
367
- if (!mutation && error.code === 'provider_crashed') {
446
+ if ('error' in providerResponse) {
447
+ // Why: observations have no outcome envelope, so surface the provider's
448
+ // coded error directly. Validating the absent result instead would mask
449
+ // app_not_found and friends as an opaque schema failure.
450
+ throw createComputerError(
451
+ providerResponse.error.code,
452
+ providerResponse.error.message,
453
+ ...(providerResponse.error.details === undefined ? [] : [providerResponse.error.details])
454
+ )
455
+ }
456
+ const response = this.#observationResponse(
457
+ requestId,
458
+ request.operation,
459
+ providerResponse.result
460
+ )
461
+ result = response.result
462
+ return response
463
+ } catch (error) {
464
+ cause = error
465
+ const failed = computerError(error)
466
+ if (!mutation && failed.code === 'provider_crashed') {
467
+ this.#emit({
468
+ kind: 'helper.crash',
469
+ ...this.diagnosticEnvelope(),
470
+ requestId,
471
+ operation: request.operation,
472
+ error: diagnosticError(error)
473
+ })
474
+ const previousGeneration = this.#supervisor.generation
368
475
  await this.#supervisor.restart()
369
476
  this.#contexts.invalidateAll()
477
+ this.#emit({
478
+ kind: 'helper.restart',
479
+ ...this.diagnosticEnvelope(),
480
+ ...(previousGeneration === undefined ? {} : { previousGeneration })
481
+ })
482
+ const retryStarted = this.#now()
370
483
  const retry = await this.#supervisor.dispatch({
371
484
  requestId: `${requestId}-retry`,
372
485
  operation: request.operation,
373
486
  input: request.input,
374
487
  deadlineAt
375
488
  })
376
- if ('error' in retry) throw Object.assign(new Error(retry.error.message), retry.error)
377
- return this.#observationResponse(requestId, request.operation, retry.result)
489
+ dispatchMs += this.#now() - retryStarted
490
+ if ('error' in retry) {
491
+ cause = createComputerError(retry.error.code, retry.error.message, retry.error.details)
492
+ throw cause
493
+ }
494
+ dispatched = retry.dispatched
495
+ const response = this.#observationResponse(requestId, request.operation, retry.result)
496
+ cause = undefined
497
+ result = response.result
498
+ return response
378
499
  }
379
- if (mutation && (error.code === 'provider_crashed' || error.code === 'timeout')) {
500
+ if (mutation && (failed.code === 'provider_crashed' || failed.code === 'timeout')) {
501
+ if (failed.code === 'provider_crashed') {
502
+ this.#emit({
503
+ kind: 'helper.crash',
504
+ ...this.diagnosticEnvelope(),
505
+ requestId,
506
+ operation: request.operation,
507
+ error: diagnosticError(error)
508
+ })
509
+ }
380
510
  this.#desktopEpoch += 1
381
511
  this.#contexts.invalidateAll()
512
+ dispatched = true
382
513
  const response = {
383
514
  requestId,
384
515
  result: this.#publicResult(request.operation, {
385
- outcome: { state: 'indeterminate', reason: error.message }
516
+ outcome: { state: 'indeterminate', reason: failed.message }
386
517
  }),
387
518
  desktopEpoch: this.#desktopEpoch,
388
519
  providerGeneration: this.#supervisor.generation ?? 'unknown'
389
520
  }
521
+ cause = undefined
522
+ result = response.result
390
523
  await this.#publish?.(response)
391
524
  return response
392
525
  }
393
- throw cause
526
+ throw error
527
+ } finally {
528
+ this.#emitRequest({
529
+ request,
530
+ requestId,
531
+ enqueuedAt,
532
+ startedAt,
533
+ inspectMs,
534
+ dispatchMs,
535
+ bindings,
536
+ dispatched,
537
+ result,
538
+ cause
539
+ })
394
540
  }
395
541
  }
396
542
 
@@ -436,4 +582,41 @@ export class LocalBroker {
436
582
  }
437
583
  return parseOperationOutput(operation, publicResult)
438
584
  }
585
+
586
+ #emit(record: DiagnosticRecord): void {
587
+ emitDiagnostic(this.#diagnostics, record)
588
+ }
589
+
590
+ #emitRequest(entry: {
591
+ request: BrokerRequest
592
+ requestId: string
593
+ enqueuedAt: number
594
+ startedAt: number
595
+ inspectMs: number
596
+ dispatchMs: number
597
+ bindings: ReferenceBindings | undefined
598
+ dispatched: boolean | undefined
599
+ result: unknown
600
+ cause: unknown
601
+ }): void {
602
+ const target = diagnosticTarget(entry.request.input, entry.bindings)
603
+ this.#emit({
604
+ kind: 'request',
605
+ ...this.diagnosticEnvelope(),
606
+ requestId: entry.requestId,
607
+ operation: entry.request.operation,
608
+ mutation: COMPUTER_OPERATIONS[entry.request.operation].mutation,
609
+ ms: {
610
+ queue: Math.max(0, entry.startedAt - entry.enqueuedAt),
611
+ inspect: entry.inspectMs,
612
+ dispatch: entry.dispatchMs,
613
+ total: Math.max(0, this.#now() - entry.enqueuedAt)
614
+ },
615
+ ...(target === undefined ? {} : { target }),
616
+ result:
617
+ entry.cause !== undefined
618
+ ? { type: 'error', ...diagnosticError(entry.cause) }
619
+ : diagnosticRequestResult(entry.request.operation, entry.result, entry.dispatched)
620
+ })
621
+ }
439
622
  }
@@ -0,0 +1,30 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ import { graphicalSessionKey } from '../ipc/endpoint.js'
5
+
6
+ export function diagnosticsDirectory(
7
+ graphicalSessionId: string,
8
+ options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {}
9
+ ): string {
10
+ const env = options.env ?? process.env
11
+ const override = env.CROSSHANDS_DIAGNOSTICS_DIR
12
+ if (typeof override === 'string' && override.length > 0) return override
13
+ const sessionKey = graphicalSessionKey(graphicalSessionId)
14
+ const platform = options.platform ?? process.platform
15
+ if (platform === 'darwin') {
16
+ return join(homedir(), 'Library', 'Logs', 'CrossHands', sessionKey)
17
+ }
18
+ if (platform === 'linux') {
19
+ const stateHome =
20
+ typeof env.XDG_STATE_HOME === 'string' && env.XDG_STATE_HOME.length > 0
21
+ ? env.XDG_STATE_HOME
22
+ : join(homedir(), '.local', 'state')
23
+ return join(stateHome, 'crosshands', sessionKey)
24
+ }
25
+ const localAppData =
26
+ typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA.length > 0
27
+ ? env.LOCALAPPDATA
28
+ : join(homedir(), 'AppData', 'Local')
29
+ return join(localAppData, 'CrossHands', 'logs', sessionKey)
30
+ }