@opengeni/interaction 0.2.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/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@opengeni/interaction",
3
+ "version": "0.2.2",
4
+ "description": "Provider-neutral browser and computer interaction controller for OpenGeni.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
9
+ "directory": "packages/interaction"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "main": "./dist/index.js",
19
+ "module": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ }
26
+ },
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "scripts": {
32
+ "build": "bun ../../scripts/build-typescript-package.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "prepublishOnly": "bash ../../scripts/prepublish-guard"
35
+ },
36
+ "dependencies": {
37
+ "@opengeni/contracts": "^0.44.1"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ }
42
+ }
@@ -0,0 +1,487 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { InteractionError, InteractionOperationState } from "@opengeni/contracts";
3
+
4
+ export type InteractionControllerErrorCode = InteractionError["code"] | "journal_full";
5
+
6
+ /** A safe, typed rejection known not to have dispatched a side effect. */
7
+ export class InteractionControllerError extends Error {
8
+ constructor(
9
+ readonly code: InteractionControllerErrorCode,
10
+ message: string,
11
+ readonly retryable = false,
12
+ ) {
13
+ super(message);
14
+ this.name = "InteractionControllerError";
15
+ }
16
+ }
17
+
18
+ /** Driver proof that a dispatched command definitively failed without an ambiguous outcome. */
19
+ export class InteractionDefiniteDriverError extends Error {
20
+ constructor(
21
+ readonly code: InteractionError["code"],
22
+ message: string,
23
+ readonly retryable = false,
24
+ ) {
25
+ super(message);
26
+ this.name = "InteractionDefiniteDriverError";
27
+ }
28
+ }
29
+
30
+ export type InteractionCoreCommand = {
31
+ operationId: string;
32
+ controllerGeneration: string;
33
+ targetId: string;
34
+ };
35
+
36
+ export type InteractionCoreTarget = {
37
+ id: string;
38
+ controllerGeneration: string;
39
+ };
40
+
41
+ export type InteractionCoreObservation<TTarget extends InteractionCoreTarget> = {
42
+ target: TTarget;
43
+ };
44
+
45
+ export type InteractionCoreReceipt<TObservation> = {
46
+ operationId: string;
47
+ controllerGeneration: string;
48
+ targetId: string;
49
+ state: InteractionOperationState;
50
+ dispatchedAt: string | null;
51
+ settledAt: string | null;
52
+ observation: TObservation | null;
53
+ error: InteractionError | null;
54
+ };
55
+
56
+ export type InteractionOperationJournalRecord<TReceipt> = {
57
+ operationId: string;
58
+ commandDigest: string;
59
+ receipt: TReceipt;
60
+ };
61
+
62
+ type InteractionDriver<
63
+ TCommand extends InteractionCoreCommand,
64
+ TTarget extends InteractionCoreTarget,
65
+ TObservation extends InteractionCoreObservation<TTarget>,
66
+ > = {
67
+ target(targetId: string): Promise<TTarget | null>;
68
+ observe(targetId: string): Promise<TObservation>;
69
+ validate?(command: TCommand, target: TTarget): Promise<void> | void;
70
+ dispatch(command: TCommand): Promise<TObservation>;
71
+ };
72
+
73
+ type InteractionCoreAdapter<
74
+ TCommand extends InteractionCoreCommand,
75
+ TTarget extends InteractionCoreTarget,
76
+ TObservation extends InteractionCoreObservation<TTarget>,
77
+ TReceipt extends InteractionCoreReceipt<TObservation>,
78
+ > = {
79
+ resourceLabel: string;
80
+ parseCommand(value: unknown): TCommand;
81
+ parseTarget(value: unknown): TTarget;
82
+ parseObservation(value: unknown): TObservation;
83
+ parseReceipt(value: unknown): TReceipt;
84
+ assertCommandAuthority(command: TCommand): void;
85
+ assertTargetAuthority(target: TTarget): void;
86
+ assertExpectedGenerations(command: TCommand, target: TTarget): void;
87
+ assertObservationAuthority(observation: TObservation, targetId: string): void;
88
+ makeReceipt(input: {
89
+ command: TCommand;
90
+ state: InteractionOperationState;
91
+ dispatchedAt: string | null;
92
+ settledAt: string | null;
93
+ observation: TObservation | null;
94
+ error: InteractionError | null;
95
+ }): TReceipt;
96
+ recoverReceipt(receipt: TReceipt, settledAt: string): TReceipt;
97
+ };
98
+
99
+ type InteractionCoreAuthority<TCommand> = {
100
+ authorizeDispatch(command: TCommand): Promise<void> | void;
101
+ };
102
+
103
+ export type InteractionControllerCoreOptions<
104
+ TCommand extends InteractionCoreCommand,
105
+ TTarget extends InteractionCoreTarget,
106
+ TObservation extends InteractionCoreObservation<TTarget>,
107
+ TReceipt extends InteractionCoreReceipt<TObservation>,
108
+ > = {
109
+ driver: InteractionDriver<TCommand, TTarget, TObservation>;
110
+ adapter: InteractionCoreAdapter<TCommand, TTarget, TObservation, TReceipt>;
111
+ authority?: InteractionCoreAuthority<TCommand>;
112
+ maxJournalEntries?: number;
113
+ now?: () => Date;
114
+ initialJournal?: readonly InteractionOperationJournalRecord<TReceipt>[];
115
+ onJournalRecord?: (record: InteractionOperationJournalRecord<TReceipt>) => Promise<void> | void;
116
+ };
117
+
118
+ type JournalEntry<TReceipt> = InteractionOperationJournalRecord<TReceipt> & {
119
+ completion: Promise<TReceipt>;
120
+ preparationPersisted: Promise<boolean>;
121
+ };
122
+
123
+ const terminalStates = new Set<InteractionOperationState>([
124
+ "completed",
125
+ "failed",
126
+ "outcome_unknown",
127
+ ]);
128
+
129
+ /**
130
+ * Resource-neutral placement mutation authority. Public Browser and Computer
131
+ * controllers supply only their schemas and generation semantics; journaling,
132
+ * idempotency, target-local serialization, and crash policy stay identical.
133
+ */
134
+ export class InteractionControllerCore<
135
+ TCommand extends InteractionCoreCommand,
136
+ TTarget extends InteractionCoreTarget,
137
+ TObservation extends InteractionCoreObservation<TTarget>,
138
+ TReceipt extends InteractionCoreReceipt<TObservation>,
139
+ > {
140
+ private readonly driver: InteractionDriver<TCommand, TTarget, TObservation>;
141
+ private readonly adapter: InteractionCoreAdapter<TCommand, TTarget, TObservation, TReceipt>;
142
+ private readonly authority: InteractionCoreAuthority<TCommand> | undefined;
143
+ private readonly maxJournalEntries: number;
144
+ private readonly now: () => Date;
145
+ private readonly onJournalRecord:
146
+ | ((record: InteractionOperationJournalRecord<TReceipt>) => Promise<void> | void)
147
+ | undefined;
148
+ private readonly journal = new Map<string, JournalEntry<TReceipt>>();
149
+ private readonly targetTails = new Map<string, Promise<void>>();
150
+
151
+ constructor(
152
+ options: InteractionControllerCoreOptions<TCommand, TTarget, TObservation, TReceipt>,
153
+ ) {
154
+ this.driver = options.driver;
155
+ this.adapter = options.adapter;
156
+ this.authority = options.authority;
157
+ this.maxJournalEntries = options.maxJournalEntries ?? 10_000;
158
+ this.now = options.now ?? (() => new Date());
159
+ this.onJournalRecord = options.onJournalRecord;
160
+ if (!Number.isSafeInteger(this.maxJournalEntries) || this.maxJournalEntries < 1) {
161
+ throw new Error("maxJournalEntries must be a positive safe integer");
162
+ }
163
+ for (const record of options.initialJournal ?? []) this.restoreJournalRecord(record);
164
+ }
165
+
166
+ async observe(targetId: string): Promise<TObservation> {
167
+ const target = await this.requireCurrentTarget(targetId);
168
+ const observed = this.adapter.parseObservation(await this.driver.observe(target.id));
169
+ this.adapter.assertObservationAuthority(observed, target.id);
170
+ return observed;
171
+ }
172
+
173
+ run(commandInput: TCommand): Promise<TReceipt> {
174
+ const command = this.adapter.parseCommand(commandInput);
175
+ this.adapter.assertCommandAuthority(command);
176
+ const commandDigest = digestJson(command);
177
+ const existing = this.journal.get(command.operationId);
178
+ if (existing) {
179
+ if (existing.commandDigest !== commandDigest) {
180
+ throw new InteractionControllerError(
181
+ "operation_conflict",
182
+ `operation id is already bound to a different ${this.adapter.resourceLabel} command`,
183
+ );
184
+ }
185
+ return existing.completion;
186
+ }
187
+
188
+ this.makeJournalSpace();
189
+ const prepared = this.makeReceipt(command, "prepared", null, null, null, null);
190
+ const entry: JournalEntry<TReceipt> = {
191
+ operationId: command.operationId,
192
+ commandDigest,
193
+ receipt: prepared,
194
+ completion: Promise.resolve(prepared),
195
+ preparationPersisted: this.publish(command.operationId, commandDigest, prepared).then(
196
+ () => true,
197
+ () => false,
198
+ ),
199
+ };
200
+ this.journal.set(command.operationId, entry);
201
+
202
+ const previous = this.targetTails.get(command.targetId) ?? Promise.resolve();
203
+ entry.completion = previous.then(async () => await this.execute(entry, command));
204
+ const tail = entry.completion.then(
205
+ () => undefined,
206
+ () => undefined,
207
+ );
208
+ this.targetTails.set(command.targetId, tail);
209
+ void tail.finally(() => {
210
+ if (this.targetTails.get(command.targetId) === tail)
211
+ this.targetTails.delete(command.targetId);
212
+ });
213
+ return entry.completion;
214
+ }
215
+
216
+ receipt(operationId: string): TReceipt | null {
217
+ return this.journal.get(operationId)?.receipt ?? null;
218
+ }
219
+
220
+ journalSnapshot(): InteractionOperationJournalRecord<TReceipt>[] {
221
+ return [...this.journal.values()].map(({ operationId, commandDigest, receipt }) => ({
222
+ operationId,
223
+ commandDigest,
224
+ receipt,
225
+ }));
226
+ }
227
+
228
+ /** Wait until every command already admitted to a target queue settles.
229
+ * Callers must fence new dispatch through `authority` before awaiting this. */
230
+ async waitForIdle(): Promise<void> {
231
+ while (this.targetTails.size > 0) await Promise.all([...this.targetTails.values()]);
232
+ }
233
+
234
+ private async execute(entry: JournalEntry<TReceipt>, command: TCommand): Promise<TReceipt> {
235
+ if (!(await entry.preparationPersisted)) {
236
+ return await this.failBeforeDispatch(
237
+ entry,
238
+ command,
239
+ `${this.adapter.resourceLabel} operation journal rejected the command before dispatch`,
240
+ );
241
+ }
242
+
243
+ try {
244
+ await this.authority?.authorizeDispatch(command);
245
+ const target = await this.requireCurrentTarget(command.targetId);
246
+ this.adapter.assertExpectedGenerations(command, target);
247
+ await this.driver.validate?.(command, target);
248
+ } catch (error) {
249
+ return await this.settle(
250
+ entry,
251
+ this.makeReceipt(
252
+ command,
253
+ "failed",
254
+ null,
255
+ this.timestamp(),
256
+ null,
257
+ safePredispatchError(error, this.adapter.resourceLabel),
258
+ ),
259
+ );
260
+ }
261
+
262
+ const dispatchedAt = this.timestamp();
263
+ const dispatched = this.makeReceipt(command, "dispatched", dispatchedAt, null, null, null);
264
+ try {
265
+ await this.publish(entry.operationId, entry.commandDigest, dispatched);
266
+ entry.receipt = dispatched;
267
+ } catch {
268
+ return await this.failBeforeDispatch(
269
+ entry,
270
+ command,
271
+ `${this.adapter.resourceLabel} operation journal became unavailable before dispatch`,
272
+ );
273
+ }
274
+
275
+ try {
276
+ const observation = this.adapter.parseObservation(await this.driver.dispatch(command));
277
+ this.adapter.assertObservationAuthority(observation, command.targetId);
278
+ const completed = this.makeReceipt(
279
+ command,
280
+ "completed",
281
+ dispatchedAt,
282
+ this.timestamp(),
283
+ observation,
284
+ null,
285
+ );
286
+ try {
287
+ await this.publish(entry.operationId, entry.commandDigest, completed);
288
+ entry.receipt = completed;
289
+ return completed;
290
+ } catch {
291
+ return await this.settle(
292
+ entry,
293
+ this.makeReceipt(command, "outcome_unknown", dispatchedAt, this.timestamp(), null, {
294
+ code: "controller_lost",
295
+ message: `${this.adapter.resourceLabel} command completed but its durable outcome could not be recorded`,
296
+ retryable: false,
297
+ }),
298
+ );
299
+ }
300
+ } catch (error) {
301
+ if (error instanceof InteractionDefiniteDriverError) {
302
+ return await this.settle(
303
+ entry,
304
+ this.makeReceipt(command, "failed", dispatchedAt, this.timestamp(), null, {
305
+ code: error.code,
306
+ message: error.message,
307
+ retryable: error.retryable,
308
+ }),
309
+ );
310
+ }
311
+ return await this.settle(
312
+ entry,
313
+ this.makeReceipt(command, "outcome_unknown", dispatchedAt, this.timestamp(), null, {
314
+ code: "controller_lost",
315
+ message: `${this.adapter.resourceLabel} command outcome is unknown after dispatch`,
316
+ retryable: false,
317
+ }),
318
+ );
319
+ }
320
+ }
321
+
322
+ private async requireCurrentTarget(targetId: string): Promise<TTarget> {
323
+ const target = await this.driver.target(targetId);
324
+ if (!target) {
325
+ throw new InteractionControllerError(
326
+ "target_not_found",
327
+ `${this.adapter.resourceLabel} target does not exist`,
328
+ );
329
+ }
330
+ const parsed = this.adapter.parseTarget(target);
331
+ this.adapter.assertTargetAuthority(parsed);
332
+ return parsed;
333
+ }
334
+
335
+ private makeReceipt(
336
+ command: TCommand,
337
+ state: InteractionOperationState,
338
+ dispatchedAt: string | null,
339
+ settledAt: string | null,
340
+ observation: TObservation | null,
341
+ error: InteractionError | null,
342
+ ): TReceipt {
343
+ return this.adapter.makeReceipt({
344
+ command,
345
+ state,
346
+ dispatchedAt,
347
+ settledAt,
348
+ observation,
349
+ error,
350
+ });
351
+ }
352
+
353
+ private async settle(entry: JournalEntry<TReceipt>, receipt: TReceipt): Promise<TReceipt> {
354
+ entry.receipt = receipt;
355
+ try {
356
+ await this.publish(entry.operationId, entry.commandDigest, receipt);
357
+ } catch {
358
+ // Controller-lifetime terminal truth remains authoritative. A restored
359
+ // dispatched receipt is always recovered as outcome_unknown, never replayed.
360
+ }
361
+ return receipt;
362
+ }
363
+
364
+ private async failBeforeDispatch(
365
+ entry: JournalEntry<TReceipt>,
366
+ command: TCommand,
367
+ message: string,
368
+ ): Promise<TReceipt> {
369
+ return await this.settle(
370
+ entry,
371
+ this.makeReceipt(command, "failed", null, this.timestamp(), null, {
372
+ code: "driver_failed",
373
+ message,
374
+ retryable: true,
375
+ }),
376
+ );
377
+ }
378
+
379
+ private async publish(
380
+ operationId: string,
381
+ commandDigest: string,
382
+ receipt: TReceipt,
383
+ ): Promise<void> {
384
+ await this.onJournalRecord?.({ operationId, commandDigest, receipt });
385
+ }
386
+
387
+ private timestamp(): string {
388
+ return this.now().toISOString();
389
+ }
390
+
391
+ private makeJournalSpace(): void {
392
+ while (this.journal.size >= this.maxJournalEntries) {
393
+ const terminal = [...this.journal.entries()].find(([, entry]) =>
394
+ terminalStates.has(entry.receipt.state),
395
+ );
396
+ if (!terminal) {
397
+ throw new InteractionControllerError(
398
+ "journal_full",
399
+ `${this.adapter.resourceLabel} operation journal has no safely evictable terminal entry`,
400
+ true,
401
+ );
402
+ }
403
+ this.journal.delete(terminal[0]);
404
+ }
405
+ }
406
+
407
+ private restoreJournalRecord(record: InteractionOperationJournalRecord<TReceipt>): void {
408
+ if (this.journal.has(record.operationId)) {
409
+ throw new Error(`duplicate restored operation id: ${record.operationId}`);
410
+ }
411
+ const parsed = this.adapter.parseReceipt(record.receipt);
412
+ if (parsed.operationId !== record.operationId) {
413
+ throw new Error(`restored operation ${record.operationId} has another receipt id`);
414
+ }
415
+ const receipt = this.adapter.recoverReceipt(parsed, this.timestamp());
416
+ const entry: JournalEntry<TReceipt> = {
417
+ operationId: record.operationId,
418
+ commandDigest: record.commandDigest,
419
+ receipt,
420
+ completion: Promise.resolve(receipt),
421
+ preparationPersisted: Promise.resolve(true),
422
+ };
423
+ this.journal.set(record.operationId, entry);
424
+ }
425
+ }
426
+
427
+ export function recoverInteractionReceipt<TReceipt extends InteractionCoreReceipt<unknown>>(
428
+ receipt: TReceipt,
429
+ settledAt: string,
430
+ resourceLabel: string,
431
+ parse: (value: unknown) => TReceipt,
432
+ ): TReceipt {
433
+ if (terminalStates.has(receipt.state)) return receipt;
434
+ return parse({
435
+ ...receipt,
436
+ state: receipt.state === "dispatched" ? "outcome_unknown" : "failed",
437
+ settledAt,
438
+ observation: null,
439
+ error:
440
+ receipt.state === "dispatched"
441
+ ? {
442
+ code: "controller_lost",
443
+ message: `${resourceLabel} controller restarted after command dispatch`,
444
+ retryable: false,
445
+ }
446
+ : {
447
+ code: "controller_lost",
448
+ message: `${resourceLabel} controller restarted before command dispatch`,
449
+ retryable: true,
450
+ },
451
+ });
452
+ }
453
+
454
+ function safePredispatchError(error: unknown, resourceLabel: string): InteractionError {
455
+ if (error instanceof InteractionControllerError && error.code !== "journal_full") {
456
+ return {
457
+ code: error.code,
458
+ message: error.message,
459
+ retryable: error.retryable,
460
+ };
461
+ }
462
+ return {
463
+ code: "driver_failed",
464
+ message: `${resourceLabel} command failed before dispatch`,
465
+ retryable: false,
466
+ };
467
+ }
468
+
469
+ function digestJson(value: unknown): string {
470
+ return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
471
+ }
472
+
473
+ function canonicalJson(value: unknown): string {
474
+ return JSON.stringify(canonicalJsonValue(value));
475
+ }
476
+
477
+ function canonicalJsonValue(value: unknown): unknown {
478
+ if (Array.isArray(value)) return value.map(canonicalJsonValue);
479
+ if (value !== null && typeof value === "object") {
480
+ return Object.fromEntries(
481
+ Object.entries(value as Record<string, unknown>)
482
+ .sort(([left], [right]) => left.localeCompare(right))
483
+ .map(([key, entry]) => [key, canonicalJsonValue(entry)]),
484
+ );
485
+ }
486
+ return value;
487
+ }