@softize/opus 20.0.0 → 21.0.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/CHANGELOG.md +141 -0
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +3 -0
- package/bin/lib/introspect.mjs +5 -4
- package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +14 -1
- package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
- package/docs/adr/0026-reactions-apply-declared-delivery-policy.md +123 -0
- package/docs/adr/0027-reactions-publish-declared-events-with-bounded-chains.md +96 -0
- package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +127 -0
- package/docs/protocol.md +60 -24
- package/package.json +1 -1
- package/src/core/audit.ts +2 -1
- package/src/core/index.ts +1 -1
- package/src/core/reactions.ts +6 -2
- package/src/core/runtime.ts +598 -70
- package/src/core/schedules.ts +3 -2
- package/src/core/types.ts +52 -7
- package/src/testing/index.ts +25 -14
- package/src/ui/components/patterns/content-header.tsx +94 -15
- package/src/ui/components/patterns/list.tsx +2 -14
- package/src/ui/components/patterns/surface-header.tsx +58 -25
- package/src/ui/components/primitives/card.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +1 -1
- package/src/ui/components/primitives/drawer.tsx +1 -1
- package/src/ui/docs/content/action-list.md +7 -6
- package/src/ui/docs/content/actions.md +2 -1
- package/src/ui/docs/content/audit.md +10 -0
- package/src/ui/docs/content/auth.md +5 -1
- package/src/ui/docs/content/content.md +88 -20
- package/src/ui/docs/content/events.md +109 -11
- package/src/ui/docs/content/icon-tile.md +11 -11
- package/src/ui/docs/content/runtime.md +11 -2
- package/src/ui/docs/content/scheduler.md +53 -12
- package/src/ui/docs/content/testing.md +14 -2
- package/src/ui/meta.ts +1 -1
- package/src/ui/react.tsx +1 -0
package/src/core/runtime.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type { LoadSpec as DslLoadSpec } from '../dsl/loads.ts'
|
|
|
28
28
|
import type {
|
|
29
29
|
ActionContext,
|
|
30
30
|
ActionDef,
|
|
31
|
+
ActionError,
|
|
31
32
|
ActionResult,
|
|
32
33
|
Adapter,
|
|
33
34
|
AuditRecord,
|
|
@@ -136,6 +137,8 @@ export interface RuntimeSetupConfig {
|
|
|
136
137
|
validateOutputInDev?: boolean
|
|
137
138
|
warnUndeclaredEmits?: boolean
|
|
138
139
|
dedupCacheTtl?: number
|
|
140
|
+
/** Chaves concluídas que o dedup guarda por reaction; acima disso, descarta a mais antiga. Padrão 10.000. */
|
|
141
|
+
dedupCacheMaxEntries?: number
|
|
139
142
|
defaultRetry?: { attempts: number; backoff?: BackoffSpec }
|
|
140
143
|
i18n?: {
|
|
141
144
|
resolver?: (ref: I18nRef, locale?: string) => string
|
|
@@ -143,6 +146,8 @@ export interface RuntimeSetupConfig {
|
|
|
143
146
|
}
|
|
144
147
|
maxConcurrentActions?: number
|
|
145
148
|
maxConcurrentReactions?: number
|
|
149
|
+
/** Saltos de reaction que uma cadeia de eventos pode dar. Padrão 5 (ADR 0027). */
|
|
150
|
+
maxReactionDepth?: number
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
/**
|
|
@@ -157,6 +162,7 @@ export interface ResolvedRuntimeConfig {
|
|
|
157
162
|
validateOutputInDev: boolean
|
|
158
163
|
warnUndeclaredEmits: boolean
|
|
159
164
|
dedupCacheTtl: number
|
|
165
|
+
dedupCacheMaxEntries: number
|
|
160
166
|
defaultRetry: { attempts: number; backoff?: BackoffSpec }
|
|
161
167
|
i18n: {
|
|
162
168
|
resolver?: (ref: I18nRef, locale?: string) => string
|
|
@@ -164,6 +170,7 @@ export interface ResolvedRuntimeConfig {
|
|
|
164
170
|
}
|
|
165
171
|
maxConcurrentActions: number
|
|
166
172
|
maxConcurrentReactions: number
|
|
173
|
+
maxReactionDepth: number
|
|
167
174
|
}
|
|
168
175
|
|
|
169
176
|
// =============================================================================
|
|
@@ -235,6 +242,7 @@ function applyConfigDefaults(c: RuntimeSetupConfig = {}): ResolvedRuntimeConfig
|
|
|
235
242
|
validateOutputInDev: c.validateOutputInDev ?? dev,
|
|
236
243
|
warnUndeclaredEmits: c.warnUndeclaredEmits ?? dev,
|
|
237
244
|
dedupCacheTtl: c.dedupCacheTtl ?? 60_000,
|
|
245
|
+
dedupCacheMaxEntries: c.dedupCacheMaxEntries ?? 10_000,
|
|
238
246
|
defaultRetry: c.defaultRetry ?? {
|
|
239
247
|
attempts: 3,
|
|
240
248
|
backoff: { kind: 'exponential', initialMs: 1000 },
|
|
@@ -242,9 +250,44 @@ function applyConfigDefaults(c: RuntimeSetupConfig = {}): ResolvedRuntimeConfig
|
|
|
242
250
|
i18n,
|
|
243
251
|
maxConcurrentActions: c.maxConcurrentActions ?? Number.POSITIVE_INFINITY,
|
|
244
252
|
maxConcurrentReactions: c.maxConcurrentReactions ?? 10,
|
|
253
|
+
maxReactionDepth: c.maxReactionDepth ?? 5,
|
|
245
254
|
}
|
|
246
255
|
}
|
|
247
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Os limites que o runtime aplica às reactions travariam ou desligariam a entrega com um valor
|
|
259
|
+
* fora do domínio (zero vagas, TTL negativo): falhar na criação é melhor que parar em silêncio.
|
|
260
|
+
*/
|
|
261
|
+
function assertReactionLimits(config: ResolvedRuntimeConfig): void {
|
|
262
|
+
const invalid: string[] = []
|
|
263
|
+
if (!isPositiveIntegerOrInfinity(config.maxConcurrentReactions)) {
|
|
264
|
+
invalid.push(`maxConcurrentReactions (${String(config.maxConcurrentReactions)}) precisa ser um inteiro positivo`)
|
|
265
|
+
}
|
|
266
|
+
if (!isPositiveInteger(config.maxReactionDepth)) {
|
|
267
|
+
invalid.push(`maxReactionDepth (${String(config.maxReactionDepth)}) precisa ser um inteiro positivo`)
|
|
268
|
+
}
|
|
269
|
+
if (!isPositiveInteger(config.dedupCacheMaxEntries)) {
|
|
270
|
+
invalid.push(`dedupCacheMaxEntries (${String(config.dedupCacheMaxEntries)}) precisa ser um inteiro positivo`)
|
|
271
|
+
}
|
|
272
|
+
if (!(Number.isFinite(config.dedupCacheTtl) && config.dedupCacheTtl >= 0)) {
|
|
273
|
+
invalid.push(`dedupCacheTtl (${String(config.dedupCacheTtl)}) precisa ser um número de milissegundos maior ou igual a zero`)
|
|
274
|
+
}
|
|
275
|
+
if (invalid.length === 0) return
|
|
276
|
+
throw error({
|
|
277
|
+
code: 'runtime.invalid_config',
|
|
278
|
+
category: 'internal',
|
|
279
|
+
message: `A configuração do runtime tem valores inválidos: ${invalid.join('; ')}.`,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isPositiveInteger(value: number): boolean {
|
|
284
|
+
return Number.isInteger(value) && value >= 1
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isPositiveIntegerOrInfinity(value: number): boolean {
|
|
288
|
+
return value === Number.POSITIVE_INFINITY || isPositiveInteger(value)
|
|
289
|
+
}
|
|
290
|
+
|
|
248
291
|
// =============================================================================
|
|
249
292
|
// Runtime
|
|
250
293
|
// =============================================================================
|
|
@@ -294,11 +337,19 @@ export class Runtime {
|
|
|
294
337
|
*/
|
|
295
338
|
private readonly loadSpecCache = new Map<string, DslLoadSpec>()
|
|
296
339
|
|
|
340
|
+
/** Vagas globais de reaction (`config.maxConcurrentReactions`) e as de cada reaction. */
|
|
341
|
+
private readonly reactionSlots: Semaphore
|
|
342
|
+
private readonly reactionSlotsByName = new Map<string, Semaphore>()
|
|
343
|
+
/** Chaves de dedup por reaction; só existe para reactions que declaram `dedup`. */
|
|
344
|
+
private readonly reactionDedup = new Map<string, DedupCache>()
|
|
345
|
+
|
|
297
346
|
private started = false
|
|
298
347
|
private readonly disposeCallbacks: Array<() => Promise<void> | void> = []
|
|
299
348
|
|
|
300
349
|
constructor(setup: RuntimeSetup) {
|
|
301
350
|
this.config = applyConfigDefaults(setup.config)
|
|
351
|
+
assertReactionLimits(this.config)
|
|
352
|
+
this.reactionSlots = new Semaphore(this.config.maxConcurrentReactions)
|
|
302
353
|
this.logger = setup.logger
|
|
303
354
|
this.observability = setup.observability
|
|
304
355
|
this.log = setup.logger ?? new ConsoleLogger({ runtime: 'opus' })
|
|
@@ -451,7 +502,12 @@ export class Runtime {
|
|
|
451
502
|
message: `Reaction "${reaction.name}" registrada duas vezes`,
|
|
452
503
|
})
|
|
453
504
|
}
|
|
505
|
+
assertReactionPolicy(reaction)
|
|
454
506
|
this.reactions.set(reaction.name, reaction)
|
|
507
|
+
this.reactionSlotsByName.set(reaction.name, new Semaphore(reaction.concurrency ?? 1))
|
|
508
|
+
if (reaction.dedup !== undefined) {
|
|
509
|
+
this.reactionDedup.set(reaction.name, new DedupCache(this.config.dedupCacheMaxEntries))
|
|
510
|
+
}
|
|
455
511
|
}
|
|
456
512
|
}
|
|
457
513
|
|
|
@@ -485,7 +541,10 @@ export class Runtime {
|
|
|
485
541
|
// conexão aberta para trás. Sem scheduler, a checagem não roda porque nenhum schedule
|
|
486
542
|
// executaria: os adapters inicializam e o start falha depois, com
|
|
487
543
|
// `runtime.eventbus_required` se também faltar event bus, senão `runtime.scheduler_required`.
|
|
488
|
-
if (this.scheduler !== undefined)
|
|
544
|
+
if (this.scheduler !== undefined) {
|
|
545
|
+
this.assertScheduledActionsExist()
|
|
546
|
+
this.assertScheduledActionsAcceptSystem()
|
|
547
|
+
}
|
|
489
548
|
|
|
490
549
|
for (const adapter of this.allAdapters()) {
|
|
491
550
|
if (adapter.init !== undefined) await adapter.init(this)
|
|
@@ -627,6 +686,10 @@ export class Runtime {
|
|
|
627
686
|
}
|
|
628
687
|
|
|
629
688
|
const trace = normalizeTraceContext(spec.ctx.trace)
|
|
689
|
+
// O envelope já é a fonte da identidade do job (o `userId` acima). Só a marca `system`, gravada
|
|
690
|
+
// no enfileiramento a partir de `isSystemActor`, transporta a identidade de sistema; a
|
|
691
|
+
// provenance vem do chamador de `execute()` e não serve para isso (ADR 0028).
|
|
692
|
+
const system = spec.ctx.userId === null && spec.ctx.system === true
|
|
630
693
|
return this.executeAction(action, spec.input, {
|
|
631
694
|
...ctxBase,
|
|
632
695
|
tenantId: spec.ctx.tenantId,
|
|
@@ -640,15 +703,20 @@ export class Runtime {
|
|
|
640
703
|
? { originalProvenance: spec.ctx.originalProvenance }
|
|
641
704
|
: {}),
|
|
642
705
|
},
|
|
643
|
-
}, 'job', progress)
|
|
706
|
+
}, 'job', progress, system)
|
|
644
707
|
}
|
|
645
708
|
|
|
709
|
+
/**
|
|
710
|
+
* `system` só é verdadeiro quando o próprio runtime inicia a execução pelo sistema — o disparo
|
|
711
|
+
* de um schedule ou o job que ele enfileirou. `execute()` nunca o recebe de fora.
|
|
712
|
+
*/
|
|
646
713
|
private async executeAction(
|
|
647
714
|
action: ActionDef,
|
|
648
715
|
input: unknown,
|
|
649
716
|
ctxBase: ContextBase,
|
|
650
717
|
execution: 'request' | 'job',
|
|
651
718
|
progress?: ProgressReporter,
|
|
719
|
+
system = false,
|
|
652
720
|
): Promise<ActionResult<unknown>> {
|
|
653
721
|
|
|
654
722
|
const startedAt = performance.now()
|
|
@@ -664,22 +732,17 @@ export class Runtime {
|
|
|
664
732
|
ctxBase.trace,
|
|
665
733
|
async (trace) => {
|
|
666
734
|
const ctx = this.buildActionContext(action, actionId, ctxBase, trace)
|
|
735
|
+
if (system && ctx.user === null) systemContexts.add(ctx)
|
|
667
736
|
|
|
668
737
|
try {
|
|
669
738
|
// — 1. Validate input ————————————————————————————————————————————————
|
|
670
739
|
const validatedInput = await this.validate(action.input, input, 'input')
|
|
671
740
|
|
|
672
|
-
// — 2. Authenticate (public
|
|
741
|
+
// — 2. Authenticate (public, user or opted-in system) ————————————————————
|
|
673
742
|
// Antes dos loaders: eles consultam dados, e um chamador anônimo não pode nem disparar essas
|
|
674
743
|
// consultas nem distinguir, pelo erro de um loader, se um registro existe. A autenticação
|
|
675
744
|
// não depende de nada carregado; a autorização, sim, e por isso vem depois.
|
|
676
|
-
|
|
677
|
-
throw error({
|
|
678
|
-
code: 'auth.unauthenticated',
|
|
679
|
-
category: 'authentication',
|
|
680
|
-
message: 'Autenticação necessária',
|
|
681
|
-
})
|
|
682
|
-
}
|
|
745
|
+
assertAuthenticated(action, ctx)
|
|
683
746
|
|
|
684
747
|
// — 3. Load —————————————————————————————————————————————————————————
|
|
685
748
|
const loaded = await this.runLoaders(action, ctx, validatedInput)
|
|
@@ -724,6 +787,7 @@ export class Runtime {
|
|
|
724
787
|
...(ctxBase.requestId !== undefined ? { requestId: ctxBase.requestId } : {}),
|
|
725
788
|
...(trace !== undefined ? { trace } : {}),
|
|
726
789
|
originalProvenance: ctx.provenance,
|
|
790
|
+
...(isSystemActor(ctx) ? { system: true as const } : {}),
|
|
727
791
|
},
|
|
728
792
|
config: {
|
|
729
793
|
...(background.queue !== undefined ? { queue: background.queue } : {}),
|
|
@@ -1000,7 +1064,7 @@ export class Runtime {
|
|
|
1000
1064
|
return
|
|
1001
1065
|
}
|
|
1002
1066
|
|
|
1003
|
-
|
|
1067
|
+
await this.publish({
|
|
1004
1068
|
type: event,
|
|
1005
1069
|
id: generateId(),
|
|
1006
1070
|
timestamp: new Date().toISOString(),
|
|
@@ -1017,24 +1081,29 @@ export class Runtime {
|
|
|
1017
1081
|
},
|
|
1018
1082
|
...(trace !== undefined ? { trace } : {}),
|
|
1019
1083
|
...(meta !== undefined ? { meta: meta as Record<string, unknown> } : {}),
|
|
1020
|
-
}
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1021
1087
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
error: String(err),
|
|
1088
|
+
/** Publica no barramento; a falha segue `config.emitMode`, para actions e reactions. */
|
|
1089
|
+
private async publish(domainEvent: DomainEvent): Promise<void> {
|
|
1090
|
+
// Quem chama já tratou a ausência do barramento; reactions só rodam com ele.
|
|
1091
|
+
const bus = this.eventBus as EventBusAdapter
|
|
1092
|
+
try {
|
|
1093
|
+
await bus.publish(domainEvent)
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
if (this.config.emitMode === 'strict') {
|
|
1096
|
+
throw error({
|
|
1097
|
+
code: 'emit.failed',
|
|
1098
|
+
category: 'internal',
|
|
1099
|
+
message: `Falha ao publicar o evento "${domainEvent.type}"`,
|
|
1100
|
+
cause: String(err),
|
|
1036
1101
|
})
|
|
1037
1102
|
}
|
|
1103
|
+
this.log.warn('event publish failed (lenient)', {
|
|
1104
|
+
event: domainEvent.type,
|
|
1105
|
+
error: String(err),
|
|
1106
|
+
})
|
|
1038
1107
|
}
|
|
1039
1108
|
}
|
|
1040
1109
|
|
|
@@ -1264,6 +1333,38 @@ export class Runtime {
|
|
|
1264
1333
|
})
|
|
1265
1334
|
}
|
|
1266
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* Um schedule ativo cuja action não aceita o sistema falharia em todo disparo com
|
|
1338
|
+
* `auth.system_forbidden`. Como o `schedule_action_not_found`, o erro sai no boot, antes de
|
|
1339
|
+
* inicializar os adapters (ADR 0028).
|
|
1340
|
+
*/
|
|
1341
|
+
private assertScheduledActionsAcceptSystem(): void {
|
|
1342
|
+
const refused = [...this.schedules.values()].filter((schedule) => {
|
|
1343
|
+
if (schedule.enabled === false) return false
|
|
1344
|
+
const action = this.actions.get(schedule.action)
|
|
1345
|
+
return action !== undefined && action.public !== true && action.automatable !== true
|
|
1346
|
+
})
|
|
1347
|
+
if (refused.length === 0) return
|
|
1348
|
+
const list = refused
|
|
1349
|
+
.map((schedule) => `"${schedule.name}" → "${schedule.action}"`)
|
|
1350
|
+
.join(', ')
|
|
1351
|
+
throw error({
|
|
1352
|
+
code: 'runtime.schedule_action_not_automatable',
|
|
1353
|
+
category: 'internal',
|
|
1354
|
+
message:
|
|
1355
|
+
`O runtime não iniciou porque ${
|
|
1356
|
+
refused.length === 1
|
|
1357
|
+
? 'um schedule aponta para uma action que não aceita execução pelo sistema'
|
|
1358
|
+
: `${refused.length} schedules apontam para actions que não aceitam execução pelo sistema`
|
|
1359
|
+
}: ${list}. Declare \`automatable: true\` no contrato da action para que schedules possam ` +
|
|
1360
|
+
'executá-la (ela continua exigindo usuário nas demais chamadas); para manter o schedule sem ' +
|
|
1361
|
+
'executar, declare `enabled: false`.',
|
|
1362
|
+
meta: {
|
|
1363
|
+
schedules: refused.map((schedule) => ({ schedule: schedule.name, action: schedule.action })),
|
|
1364
|
+
},
|
|
1365
|
+
})
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1267
1368
|
private registerSchedule(schedule: ScheduleDef): void {
|
|
1268
1369
|
if (schedule.enabled === false) {
|
|
1269
1370
|
this.log.info('schedule disabled', { schedule: schedule.name })
|
|
@@ -1282,20 +1383,24 @@ export class Runtime {
|
|
|
1282
1383
|
typeof schedule.input === 'function'
|
|
1283
1384
|
? await (schedule.input as () => unknown | Promise<unknown>)()
|
|
1284
1385
|
: (schedule.input ?? {})
|
|
1285
|
-
const
|
|
1386
|
+
const base: ContextBase = {
|
|
1286
1387
|
user: null,
|
|
1287
1388
|
tenantId: null,
|
|
1288
1389
|
can: () => false,
|
|
1289
1390
|
provenance: { kind: 'schedule', scheduleName: schedule.name },
|
|
1290
|
-
}
|
|
1391
|
+
}
|
|
1392
|
+
const action = this.actions.get(schedule.action)
|
|
1393
|
+
// O start() recusa schedule ativo sem action; o fallback mantém o envelope de erro de sempre.
|
|
1394
|
+
const result = action === undefined
|
|
1395
|
+
? await this.execute(schedule.action, input, base)
|
|
1396
|
+
: await this.executeAction(action, input, base, 'request', undefined, true)
|
|
1291
1397
|
if (result.ok) return
|
|
1292
1398
|
// Ninguém espera o resultado de um disparo: sem este registro, a falha só existiria no
|
|
1293
1399
|
// audit da action. Action inexistente não chega aqui, porque o start() recusa o schedule.
|
|
1294
1400
|
this.log.error(
|
|
1295
1401
|
`O schedule "${schedule.name}" não concluiu a action "${schedule.action}" (${result.error.code}). ` +
|
|
1296
1402
|
'O agendamento continua ativo e o próximo disparo executa a action de novo. Confira a ' +
|
|
1297
|
-
'mensagem e o audit da action
|
|
1298
|
-
'declarar `public: true`.',
|
|
1403
|
+
'mensagem e o audit da action.',
|
|
1299
1404
|
{
|
|
1300
1405
|
schedule: schedule.name,
|
|
1301
1406
|
action: schedule.action,
|
|
@@ -1308,25 +1413,84 @@ export class Runtime {
|
|
|
1308
1413
|
}
|
|
1309
1414
|
|
|
1310
1415
|
/**
|
|
1311
|
-
* `ctx.emit` de reaction
|
|
1312
|
-
*
|
|
1416
|
+
* `ctx.emit` de reaction publica somente os eventos do `emits` da reaction e leva a causa na
|
|
1417
|
+
* cadeia; a profundidade para em `config.maxReactionDepth` (ADR 0027).
|
|
1313
1418
|
*/
|
|
1314
|
-
private buildReactionEmit(
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
)
|
|
1419
|
+
private buildReactionEmit(
|
|
1420
|
+
reaction: ReactionDef<any>,
|
|
1421
|
+
source: DomainEvent,
|
|
1422
|
+
log: Logger,
|
|
1423
|
+
trace: TraceContext | undefined,
|
|
1424
|
+
): ReactionContext['emit'] {
|
|
1425
|
+
const declared = reaction.emits ?? []
|
|
1426
|
+
return async (type, data, meta) => {
|
|
1427
|
+
if (!declared.includes(type)) {
|
|
1428
|
+
if (!this.config.warnUndeclaredEmits) return
|
|
1429
|
+
log.warn(
|
|
1430
|
+
`A reaction "${reaction.name}" chamou ctx.emit('${type}'), mas esse evento não está no ` +
|
|
1431
|
+
'`emits` da reaction: o evento foi descartado e nenhuma reaction ou integração o ' +
|
|
1432
|
+
`recebe. Para publicá-lo, acrescente '${type}' ao \`emits\` do binding da reaction.`,
|
|
1433
|
+
{ code: 'runtime.reaction_emit_ignored', reaction: reaction.name, event: type, declared },
|
|
1434
|
+
)
|
|
1435
|
+
return
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const depth = (source.cause?.depth ?? 0) + 1
|
|
1439
|
+
const maxDepth = this.config.maxReactionDepth
|
|
1440
|
+
if (depth > maxDepth) {
|
|
1441
|
+
const message =
|
|
1442
|
+
`A reaction "${reaction.name}" não publicou o evento "${type}" porque a cadeia de ` +
|
|
1443
|
+
`reactions chegaria à profundidade ${depth}, acima do limite de ${maxDepth} ` +
|
|
1444
|
+
'(`config.maxReactionDepth`). Isso costuma indicar um laço: reactions que publicam ' +
|
|
1445
|
+
'eventos que voltam a acioná-las. Confira o `on` e o `emits` das reactions da cadeia; ' +
|
|
1446
|
+
'se a cadeia é intencional, aumente o limite.'
|
|
1447
|
+
log.error(message, {
|
|
1448
|
+
code: 'runtime.reaction_chain_too_deep',
|
|
1449
|
+
reaction: reaction.name,
|
|
1450
|
+
event: type,
|
|
1451
|
+
depth,
|
|
1452
|
+
maxDepth,
|
|
1453
|
+
sourceEvent: { id: source.id, type: source.type },
|
|
1454
|
+
origin: source.source,
|
|
1455
|
+
})
|
|
1456
|
+
if (this.config.emitMode === 'strict') {
|
|
1457
|
+
throw error({
|
|
1458
|
+
code: 'runtime.reaction_chain_too_deep',
|
|
1459
|
+
category: 'internal',
|
|
1460
|
+
message,
|
|
1461
|
+
meta: { depth, maxDepth },
|
|
1462
|
+
})
|
|
1463
|
+
}
|
|
1464
|
+
return
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
await this.publish({
|
|
1468
|
+
type,
|
|
1469
|
+
id: generateId(),
|
|
1470
|
+
timestamp: new Date().toISOString(),
|
|
1471
|
+
actor: source.actor,
|
|
1472
|
+
...(source.tenant !== undefined ? { tenant: source.tenant } : {}),
|
|
1473
|
+
data,
|
|
1474
|
+
source: {
|
|
1475
|
+
...source.source,
|
|
1476
|
+
...(meta?.correlation !== undefined ? { correlation: meta.correlation } : {}),
|
|
1477
|
+
},
|
|
1478
|
+
cause: { reaction: reaction.name, eventId: source.id, eventType: source.type, depth },
|
|
1479
|
+
...(trace !== undefined ? { trace } : {}),
|
|
1480
|
+
...(meta !== undefined ? { meta: meta as Record<string, unknown> } : {}),
|
|
1481
|
+
})
|
|
1323
1482
|
}
|
|
1324
1483
|
}
|
|
1325
1484
|
|
|
1485
|
+
/**
|
|
1486
|
+
* Entrega um evento a uma reaction: dedup → vagas → authorize → handler com timeout → retry →
|
|
1487
|
+
* audit (ADR 0026). Nunca rejeita: a falha fica no log e no audit, longe de quem emitiu.
|
|
1488
|
+
*/
|
|
1326
1489
|
private async runReaction(
|
|
1327
1490
|
reaction: ReactionDef<any>,
|
|
1328
1491
|
event: DomainEvent,
|
|
1329
1492
|
): Promise<void> {
|
|
1493
|
+
const startedAt = performance.now()
|
|
1330
1494
|
const provenance: Provenance = {
|
|
1331
1495
|
kind: 'reaction',
|
|
1332
1496
|
reactionName: reaction.name,
|
|
@@ -1338,29 +1502,155 @@ export class Runtime {
|
|
|
1338
1502
|
eventType: event.type,
|
|
1339
1503
|
provenance: 'reaction',
|
|
1340
1504
|
})
|
|
1505
|
+
const delivery: ReactionDelivery = { attempts: 0, authorized: true }
|
|
1506
|
+
|
|
1507
|
+
// — (a) dedup: a chave é reservada na chegada, antes da fila de vagas ——————————
|
|
1508
|
+
const dedup = this.reactionDedup.get(reaction.name)
|
|
1509
|
+
let dedupKey: string | undefined
|
|
1510
|
+
if (dedup !== undefined) {
|
|
1511
|
+
try {
|
|
1512
|
+
dedupKey = String(reaction.dedup?.(event))
|
|
1513
|
+
} catch (thrown) {
|
|
1514
|
+
const failure = normalizeError(thrown)
|
|
1515
|
+
this.logReactionFailure(reactionLog, failure, delivery)
|
|
1516
|
+
try {
|
|
1517
|
+
await this.auditReaction(reaction, event, provenance, delivery, startedAt, event.trace, failure)
|
|
1518
|
+
} catch (auditError) {
|
|
1519
|
+
// `runReaction` nunca rejeita: quem o chama é o listener do barramento, sem `await`.
|
|
1520
|
+
const auditFailure = normalizeError(auditError)
|
|
1521
|
+
reactionLog.error('reaction audit failed', {
|
|
1522
|
+
code: auditFailure.code,
|
|
1523
|
+
message: auditFailure.message,
|
|
1524
|
+
...(auditFailure.cause !== undefined ? { cause: auditFailure.cause } : {}),
|
|
1525
|
+
})
|
|
1526
|
+
}
|
|
1527
|
+
return
|
|
1528
|
+
}
|
|
1529
|
+
if (!dedup.claim(dedupKey, Date.now())) {
|
|
1530
|
+
reactionLog.debug(
|
|
1531
|
+
`A reaction "${reaction.name}" descartou o evento "${event.id}": a chave de dedup ` +
|
|
1532
|
+
`"${dedupKey}" já foi processada ou está em processamento.`,
|
|
1533
|
+
{
|
|
1534
|
+
code: 'runtime.reaction_duplicate_skipped',
|
|
1535
|
+
reaction: reaction.name,
|
|
1536
|
+
eventId: event.id,
|
|
1537
|
+
key: dedupKey,
|
|
1538
|
+
},
|
|
1539
|
+
)
|
|
1540
|
+
return
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
let failure: ActionError | undefined
|
|
1341
1545
|
try {
|
|
1342
1546
|
await this.runObserved(
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1547
|
+
{
|
|
1548
|
+
name: reaction.name,
|
|
1549
|
+
kind: 'reaction',
|
|
1550
|
+
resultKind: 'void',
|
|
1551
|
+
...(event.trace !== undefined ? { parent: event.trace } : {}),
|
|
1552
|
+
attributes: { 'opus.event.type': event.type, 'opus.source.action_id': event.source.actionId },
|
|
1553
|
+
},
|
|
1554
|
+
event.trace,
|
|
1555
|
+
async (trace) => {
|
|
1556
|
+
try {
|
|
1557
|
+
await this.deliverReaction(reaction, event, provenance, reactionLog, trace, delivery)
|
|
1558
|
+
} catch (thrown) {
|
|
1559
|
+
// O audit fica dentro do span, com o trace da reaction; a rejeição segue para o span.
|
|
1560
|
+
await this.auditReaction(reaction, event, provenance, delivery, startedAt, trace, normalizeError(thrown))
|
|
1561
|
+
throw thrown
|
|
1562
|
+
}
|
|
1563
|
+
if (delivery.authorized) {
|
|
1564
|
+
await this.auditReaction(reaction, event, provenance, delivery, startedAt, trace)
|
|
1565
|
+
}
|
|
1566
|
+
},
|
|
1567
|
+
)
|
|
1568
|
+
} catch (thrown) {
|
|
1569
|
+
failure = normalizeError(thrown)
|
|
1570
|
+
this.logReactionFailure(reactionLog, failure, delivery)
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (dedup !== undefined && dedupKey !== undefined) {
|
|
1574
|
+
// Falha definitiva libera a chave: uma nova entrega do mesmo evento pode tentar de novo.
|
|
1575
|
+
if (failure !== undefined) dedup.release(dedupKey)
|
|
1576
|
+
else dedup.complete(dedupKey, Date.now(), this.config.dedupCacheTtl)
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** (b) vagas, (c) authorize, (d) handler com timeout e (f) retry, dentro do span da entrega. */
|
|
1581
|
+
private async deliverReaction(
|
|
1582
|
+
reaction: ReactionDef<any>,
|
|
1583
|
+
event: DomainEvent,
|
|
1584
|
+
provenance: Provenance,
|
|
1585
|
+
log: Logger,
|
|
1586
|
+
trace: TraceContext | undefined,
|
|
1587
|
+
delivery: ReactionDelivery,
|
|
1588
|
+
): Promise<void> {
|
|
1589
|
+
const maxAttempts = reaction.retry?.attempts ?? 1
|
|
1590
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1591
|
+
const controller = new AbortController()
|
|
1592
|
+
const ctx = this.buildReactionContext(reaction, event, provenance, log, trace, controller.signal)
|
|
1593
|
+
const release = await this.acquireReactionSlots(reaction)
|
|
1594
|
+
let delayMs: number
|
|
1595
|
+
try {
|
|
1596
|
+
if (attempt === 1 && reaction.authorize !== undefined) {
|
|
1597
|
+
const allowed = await reaction.authorize(ctx, event)
|
|
1598
|
+
if (!allowed) {
|
|
1599
|
+
delivery.authorized = false
|
|
1600
|
+
return
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
delivery.attempts = attempt
|
|
1604
|
+
await runWithTimeout(reaction, () => reaction.handler(ctx, event), controller)
|
|
1605
|
+
return
|
|
1606
|
+
} catch (thrown) {
|
|
1607
|
+
// Falha do authorize não é repetida: nenhuma tentativa do handler começou.
|
|
1608
|
+
if (delivery.attempts < attempt || attempt >= maxAttempts || !isRetriable(thrown)) throw thrown
|
|
1609
|
+
delayMs = backoffDelay(reaction.retry?.backoff, attempt)
|
|
1610
|
+
const failed = normalizeError(thrown)
|
|
1611
|
+
log.warn(
|
|
1612
|
+
`A reaction "${reaction.name}" falhou na tentativa ${attempt} de ${maxAttempts} ` +
|
|
1613
|
+
`(${failed.code}); a próxima tentativa começa em ${delayMs} ms.`,
|
|
1614
|
+
{
|
|
1615
|
+
code: 'runtime.reaction_retry',
|
|
1616
|
+
reaction: reaction.name,
|
|
1617
|
+
eventId: event.id,
|
|
1618
|
+
attempt,
|
|
1619
|
+
attempts: maxAttempts,
|
|
1620
|
+
delayMs,
|
|
1621
|
+
error: failed.code,
|
|
1622
|
+
...(failed.cause !== undefined ? { cause: failed.cause } : {}),
|
|
1623
|
+
},
|
|
1624
|
+
)
|
|
1625
|
+
} finally {
|
|
1626
|
+
release()
|
|
1627
|
+
}
|
|
1628
|
+
// A espera entre tentativas não ocupa vaga.
|
|
1629
|
+
if (delayMs > 0) await sleep(delayMs)
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
private buildReactionContext(
|
|
1634
|
+
reaction: ReactionDef<any>,
|
|
1635
|
+
event: DomainEvent,
|
|
1636
|
+
provenance: Provenance,
|
|
1637
|
+
log: Logger,
|
|
1638
|
+
trace: TraceContext | undefined,
|
|
1639
|
+
signal: AbortSignal,
|
|
1640
|
+
): ReactionContext {
|
|
1641
|
+
const user = event.actor.id !== null ? ({ id: event.actor.id } as User) : null
|
|
1642
|
+
return {
|
|
1643
|
+
user,
|
|
1354
1644
|
tenantId: event.tenant ?? null,
|
|
1355
1645
|
db: this.data?.contextExtension().db,
|
|
1356
|
-
log
|
|
1357
|
-
emit: this.buildReactionEmit(reaction,
|
|
1646
|
+
log,
|
|
1647
|
+
emit: this.buildReactionEmit(reaction, event, log, trace),
|
|
1358
1648
|
storage: this.storage ?? null,
|
|
1359
1649
|
cache: this.cache ?? null,
|
|
1360
1650
|
// Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
|
|
1361
1651
|
// padrão — nada de LLM disparado por evento chamando action gated sem gate explícito).
|
|
1362
1652
|
ai: this.bindAi({
|
|
1363
|
-
user
|
|
1653
|
+
user,
|
|
1364
1654
|
tenantId: event.tenant ?? null,
|
|
1365
1655
|
can: async () => false,
|
|
1366
1656
|
meta: {},
|
|
@@ -1369,24 +1659,58 @@ export class Runtime {
|
|
|
1369
1659
|
provenance,
|
|
1370
1660
|
...(trace !== undefined ? { trace } : {}),
|
|
1371
1661
|
meta: {},
|
|
1662
|
+
signal,
|
|
1372
1663
|
}
|
|
1664
|
+
}
|
|
1373
1665
|
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1666
|
+
/** Vaga da reaction primeiro, global depois: quem espera a própria vaga não segura a global. */
|
|
1667
|
+
private async acquireReactionSlots(reaction: ReactionDef<any>): Promise<() => void> {
|
|
1668
|
+
const own = this.reactionSlotsByName.get(reaction.name) as Semaphore
|
|
1669
|
+
const releaseOwn = await own.acquire()
|
|
1670
|
+
const releaseGlobal = await this.reactionSlots.acquire()
|
|
1671
|
+
return () => {
|
|
1672
|
+
releaseGlobal()
|
|
1673
|
+
releaseOwn()
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** (e) Um registro por entrega, com a identidade do evento e sem o payload (ADR 0026). */
|
|
1678
|
+
private async auditReaction(
|
|
1679
|
+
reaction: ReactionDef<any>,
|
|
1680
|
+
event: DomainEvent,
|
|
1681
|
+
provenance: Provenance,
|
|
1682
|
+
delivery: ReactionDelivery,
|
|
1683
|
+
startedAt: number,
|
|
1684
|
+
trace: TraceContext | undefined,
|
|
1685
|
+
failure?: ActionError,
|
|
1686
|
+
): Promise<void> {
|
|
1687
|
+
const user = event.actor.id !== null ? ({ id: event.actor.id } as User) : null
|
|
1688
|
+
const record: AuditRecord = {
|
|
1689
|
+
id: generateId(),
|
|
1690
|
+
timestamp: new Date().toISOString(),
|
|
1691
|
+
action: reaction.name,
|
|
1692
|
+
outcome: failure === undefined ? 'success' : 'error',
|
|
1693
|
+
durationMs: performance.now() - startedAt,
|
|
1694
|
+
provenance,
|
|
1695
|
+
actor: actorFromProvenance(provenance, user),
|
|
1696
|
+
tenant: event.tenant ?? null,
|
|
1697
|
+
input: { event: { id: event.id, type: event.type, source: event.source } },
|
|
1698
|
+
severity: failure === undefined ? 'info' : severityForError(failure),
|
|
1699
|
+
...(trace !== undefined ? { traceContext: trace } : {}),
|
|
1700
|
+
meta: { kind: 'reaction', attempts: delivery.attempts },
|
|
1389
1701
|
}
|
|
1702
|
+
if (failure !== undefined) record.error = failure
|
|
1703
|
+
await this.audit.emit(record)
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
private logReactionFailure(log: Logger, failure: ActionError, delivery: ReactionDelivery): void {
|
|
1707
|
+
log.error('reaction failed', {
|
|
1708
|
+
code: failure.code,
|
|
1709
|
+
message: failure.message,
|
|
1710
|
+
attempts: delivery.attempts,
|
|
1711
|
+
// Erro não tratado tem microcopy fixa; o texto original está em `cause`.
|
|
1712
|
+
...(failure.cause !== undefined ? { cause: failure.cause } : {}),
|
|
1713
|
+
})
|
|
1390
1714
|
}
|
|
1391
1715
|
|
|
1392
1716
|
/** Observabilidade nunca repete nem substitui o resultado do callback. */
|
|
@@ -1437,6 +1761,210 @@ async function settledSuccessfully<T>(promise: Promise<T>): Promise<boolean> {
|
|
|
1437
1761
|
|
|
1438
1762
|
// ContextBase moveu pra types.ts (compartilhado entre core e adapters).
|
|
1439
1763
|
|
|
1764
|
+
// =============================================================================
|
|
1765
|
+
// Identidade de sistema (ADR 0028)
|
|
1766
|
+
// =============================================================================
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Contextos que o próprio runtime criou para uma execução do sistema. Um conjunto interno, e não
|
|
1770
|
+
* a provenance, decide `isSystemActor`: qualquer código servidor pode montar uma provenance
|
|
1771
|
+
* `schedule`, mas só o runtime acrescenta um contexto aqui.
|
|
1772
|
+
*/
|
|
1773
|
+
const systemContexts = new WeakSet<object>()
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* Diz se a action está executando como o sistema: disparada por um schedule, ou no job que ele
|
|
1777
|
+
* enfileirou, sem usuário. Use no `authorize` de uma action `automatable` para aceitar o sistema
|
|
1778
|
+
* de forma explícita: `authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')`.
|
|
1779
|
+
* Passe o próprio `ctx` recebido: uma cópia, como `{ ...ctx }`, não é reconhecida.
|
|
1780
|
+
*/
|
|
1781
|
+
export function isSystemActor(ctx: ActionContext): boolean {
|
|
1782
|
+
return systemContexts.has(ctx)
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Marca um contexto como execução do sistema. Exportado fora do index público, só para o harness
|
|
1787
|
+
* de `testing` aplicar a mesma regra no `runAction`.
|
|
1788
|
+
*/
|
|
1789
|
+
export function markSystemActor(ctx: ActionContext): void {
|
|
1790
|
+
if (ctx.user === null) systemContexts.add(ctx)
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* O gate de autenticação do `execute` e do harness: sem usuário, a action precisa ser pública ou,
|
|
1795
|
+
* quando o sistema a executa, `automatable`. Exportado fora do index público pelo mesmo motivo.
|
|
1796
|
+
*/
|
|
1797
|
+
export function assertAuthenticated(
|
|
1798
|
+
action: { public?: boolean; automatable?: boolean },
|
|
1799
|
+
ctx: ActionContext,
|
|
1800
|
+
): void {
|
|
1801
|
+
if (action.public === true || ctx.user !== null) return
|
|
1802
|
+
if (!isSystemActor(ctx)) {
|
|
1803
|
+
throw error({
|
|
1804
|
+
code: 'auth.unauthenticated',
|
|
1805
|
+
category: 'authentication',
|
|
1806
|
+
message: 'Autenticação necessária',
|
|
1807
|
+
})
|
|
1808
|
+
}
|
|
1809
|
+
if (action.automatable !== true) {
|
|
1810
|
+
throw error({
|
|
1811
|
+
code: 'auth.system_forbidden',
|
|
1812
|
+
category: 'authorization',
|
|
1813
|
+
message: 'Esta action não aceita execução pelo sistema',
|
|
1814
|
+
})
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// =============================================================================
|
|
1819
|
+
// Política de entrega das reactions (ADR 0026)
|
|
1820
|
+
// =============================================================================
|
|
1821
|
+
|
|
1822
|
+
interface ReactionDelivery {
|
|
1823
|
+
/** Tentativas do handler que começaram. */
|
|
1824
|
+
attempts: number
|
|
1825
|
+
/** Falso quando o `authorize` recusou o evento: nada a auditar. */
|
|
1826
|
+
authorized: boolean
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** Vagas em ordem de chegada. Ao liberar, a vaga passa direto para o próximo da fila. */
|
|
1830
|
+
class Semaphore {
|
|
1831
|
+
private active = 0
|
|
1832
|
+
private readonly waiting: Array<() => void> = []
|
|
1833
|
+
|
|
1834
|
+
constructor(private readonly limit: number) {}
|
|
1835
|
+
|
|
1836
|
+
async acquire(): Promise<() => void> {
|
|
1837
|
+
if (this.active < this.limit) {
|
|
1838
|
+
this.active += 1
|
|
1839
|
+
} else {
|
|
1840
|
+
await new Promise<void>((resolve) => this.waiting.push(resolve))
|
|
1841
|
+
}
|
|
1842
|
+
let released = false
|
|
1843
|
+
return () => {
|
|
1844
|
+
if (released) return
|
|
1845
|
+
released = true
|
|
1846
|
+
const next = this.waiting.shift()
|
|
1847
|
+
if (next !== undefined) next()
|
|
1848
|
+
else this.active -= 1
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Chaves de dedup de uma reaction: ocupadas da chegada até o fim da entrega e, depois de um
|
|
1855
|
+
* sucesso, até o TTL vencer. Com TTL constante, a ordem de inserção é a ordem de expiração, e a
|
|
1856
|
+
* limpeza para na primeira chave ainda válida. As chaves concluídas têm teto
|
|
1857
|
+
* (`config.dedupCacheMaxEntries`): acima dele, a mais antiga sai antes do TTL, e um evento com essa
|
|
1858
|
+
* chave volta a ser aceito. As chaves em andamento não entram no teto.
|
|
1859
|
+
*/
|
|
1860
|
+
class DedupCache {
|
|
1861
|
+
private readonly inFlight = new Set<string>()
|
|
1862
|
+
private readonly completed = new Map<string, number>()
|
|
1863
|
+
|
|
1864
|
+
constructor(private readonly maxEntries: number) {}
|
|
1865
|
+
|
|
1866
|
+
claim(key: string, now: number): boolean {
|
|
1867
|
+
this.prune(now)
|
|
1868
|
+
if (this.inFlight.has(key) || this.completed.has(key)) return false
|
|
1869
|
+
this.inFlight.add(key)
|
|
1870
|
+
return true
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
complete(key: string, now: number, ttl: number): void {
|
|
1874
|
+
this.inFlight.delete(key)
|
|
1875
|
+
if (ttl <= 0) return
|
|
1876
|
+
this.completed.set(key, now + ttl)
|
|
1877
|
+
while (this.completed.size > this.maxEntries) {
|
|
1878
|
+
const oldest = this.completed.keys().next().value as string
|
|
1879
|
+
this.completed.delete(oldest)
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
release(key: string): void {
|
|
1884
|
+
this.inFlight.delete(key)
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
private prune(now: number): void {
|
|
1888
|
+
for (const [key, expiresAt] of this.completed) {
|
|
1889
|
+
if (expiresAt > now) return
|
|
1890
|
+
this.completed.delete(key)
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* Valores fora do domínio travariam a reaction (zero vagas) ou a desligariam (zero tentativas):
|
|
1897
|
+
* o registro falha com o nome da reaction e o campo.
|
|
1898
|
+
*/
|
|
1899
|
+
function assertReactionPolicy(reaction: ReactionDef<any>): void {
|
|
1900
|
+
const invalid: string[] = []
|
|
1901
|
+
if (reaction.concurrency !== undefined && !isPositiveInteger(reaction.concurrency)) {
|
|
1902
|
+
invalid.push(`concurrency (${String(reaction.concurrency)}) precisa ser um inteiro positivo`)
|
|
1903
|
+
}
|
|
1904
|
+
if (reaction.timeout !== undefined && !(Number.isFinite(reaction.timeout) && reaction.timeout > 0)) {
|
|
1905
|
+
invalid.push(`timeout (${String(reaction.timeout)}) precisa ser um número de milissegundos maior que zero`)
|
|
1906
|
+
}
|
|
1907
|
+
if (reaction.retry !== undefined && !isPositiveInteger(reaction.retry.attempts)) {
|
|
1908
|
+
invalid.push(`retry.attempts (${String(reaction.retry.attempts)}) precisa ser um inteiro positivo`)
|
|
1909
|
+
}
|
|
1910
|
+
if (invalid.length === 0) return
|
|
1911
|
+
throw error({
|
|
1912
|
+
code: 'runtime.invalid_reaction_policy',
|
|
1913
|
+
category: 'internal',
|
|
1914
|
+
message: `A reaction "${reaction.name}" declara uma política de entrega inválida: ${invalid.join('; ')}.`,
|
|
1915
|
+
meta: { reaction: reaction.name },
|
|
1916
|
+
})
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/** Um `ActionError` pode vetar a repetição; exceções comuns e timeout são repetidos. */
|
|
1920
|
+
function isRetriable(thrown: unknown): boolean {
|
|
1921
|
+
return isActionError(thrown) ? thrown.retriable !== false : true
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function backoffDelay(spec: BackoffSpec | undefined, failedAttempt: number): number {
|
|
1925
|
+
if (spec === undefined) return 0
|
|
1926
|
+
if (spec.kind === 'fixed') return spec.delayMs
|
|
1927
|
+
const delay = spec.initialMs * (spec.multiplier ?? 2) ** (failedAttempt - 1)
|
|
1928
|
+
return spec.maxMs !== undefined ? Math.min(delay, spec.maxMs) : delay
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function sleep(ms: number): Promise<void> {
|
|
1932
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Corre o handler contra o `timeout` da reaction. Ao vencer, aborta o sinal do contexto e rejeita
|
|
1937
|
+
* a tentativa; o handler que ignora o sinal continua em segundo plano, sem segurar a vaga.
|
|
1938
|
+
*/
|
|
1939
|
+
async function runWithTimeout(
|
|
1940
|
+
reaction: ReactionDef<any>,
|
|
1941
|
+
run: () => Promise<void> | void,
|
|
1942
|
+
controller: AbortController,
|
|
1943
|
+
): Promise<void> {
|
|
1944
|
+
const execution = Promise.resolve().then(run)
|
|
1945
|
+
const timeoutMs = reaction.timeout
|
|
1946
|
+
if (timeoutMs === undefined) return execution
|
|
1947
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1948
|
+
const expired = new Promise<never>((_, reject) => {
|
|
1949
|
+
timer = setTimeout(() => {
|
|
1950
|
+
const timeout = error({
|
|
1951
|
+
code: 'runtime.reaction_timeout',
|
|
1952
|
+
category: 'internal',
|
|
1953
|
+
retriable: true,
|
|
1954
|
+
message: `A reaction "${reaction.name}" não concluiu em ${timeoutMs} ms`,
|
|
1955
|
+
meta: { timeoutMs },
|
|
1956
|
+
})
|
|
1957
|
+
controller.abort(timeout)
|
|
1958
|
+
reject(timeout)
|
|
1959
|
+
}, timeoutMs)
|
|
1960
|
+
})
|
|
1961
|
+
try {
|
|
1962
|
+
await Promise.race([execution, expired])
|
|
1963
|
+
} finally {
|
|
1964
|
+
clearTimeout(timer)
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1440
1968
|
/**
|
|
1441
1969
|
* Resolve provenance do ContextBase, com default `{ kind: 'system', source: 'unknown' }`
|
|
1442
1970
|
* quando caller não passa. Server adapter / scheduler / runtime devem sempre
|