@softize/opus 18.3.0 → 18.4.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 +8 -0
- package/README.md +1 -0
- package/docs/adr/0022-execution-monitoring-is-a-projection.md +39 -0
- package/package.json +21 -1
- package/src/executions/drivers/memory.ts +39 -0
- package/src/executions/drivers/pg-changes.ts +104 -0
- package/src/executions/drivers/pg.ts +74 -0
- package/src/executions/index.ts +129 -0
- package/src/executions/stream.ts +123 -0
- package/src/ui/docs/content/executions.md +157 -0
- package/src/ui/docs/registry.tsx +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 18.4.0 — 2026-09-21
|
|
11
|
+
|
|
12
|
+
`executions` acrescenta infraestrutura experimental de acompanhamento: snapshots versionados,
|
|
13
|
+
store PostgreSQL com aviso transacional, assinante LISTEN com reconexão e SSE autenticado de
|
|
14
|
+
invalidação. Não há instrumentação automática do runtime, nova fila nem controles de negócio.
|
|
15
|
+
O consumidor precisa adaptar seus executores, persistir a revisão, autorizar leituras/avisos e
|
|
16
|
+
reconciliar a projeção. Nenhuma migration ou conexão é criada ao importar o módulo.
|
|
17
|
+
|
|
10
18
|
## 18.3.0 — 2026-09-17
|
|
11
19
|
|
|
12
20
|
`ActionList` passa a oferecer composição previsível para barras densas: filtros inline de valor
|
package/README.md
CHANGED
|
@@ -33,6 +33,7 @@ Um pacote (`@softize/opus`), uma versão. Cada categoria abaixo é um **subpath
|
|
|
33
33
|
| `@softize/opus/audit` | `/console`, `/pg` | Destinos de auditoria |
|
|
34
34
|
| `@softize/opus/log` | `/pino` | Adapter de log (console é o default no core) |
|
|
35
35
|
| `@softize/opus/queue` | `/bullmq` | Execução de jobs em segundo plano |
|
|
36
|
+
| `@softize/opus/executions` | `/memory`, `/pg`, `/pg-changes`, `/stream` | Projeção e SSE de acompanhamento (experimental, opt-in) |
|
|
36
37
|
| `@softize/opus/events` | `/mitt` | EventBus em processo |
|
|
37
38
|
| `@softize/opus/scheduler` | `/node-cron` | Adapter de agendamento (ação iniciada por tempo) |
|
|
38
39
|
| `@softize/opus/storage` | `/fs`, `/s3` | Armazenamento de arquivos (experimental) |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ADR 0022 — Acompanhamento de execução é uma projeção, não um motor
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita para candidato local.
|
|
4
|
+
- **Data:** 2026-09-21.
|
|
5
|
+
|
|
6
|
+
## Contexto
|
|
7
|
+
|
|
8
|
+
O runtime já oferece fila, scheduler e progresso. Consumidores também executam trabalho fora
|
|
9
|
+
dele. Uma interface comum de acompanhamento não deve obrigar a migração desses motores nem
|
|
10
|
+
centralizar regras de negócio, retry ou autorização no SDK.
|
|
11
|
+
|
|
12
|
+
## Decisão
|
|
13
|
+
|
|
14
|
+
Adicionar uma fronteira `executions`: snapshots operacionais versionados, store de projeção e
|
|
15
|
+
avisos de invalidação. O executor fornece ID e revisão estáveis; o store só aceita revisão
|
|
16
|
+
maior e preserva tenant e operação. A integração é explícita: não inferir cobertura de cron,
|
|
17
|
+
filas ou processos arbitrários só por instalar o pacote.
|
|
18
|
+
|
|
19
|
+
O snapshot não transporta input/output do job, erro livre, contatos ou credenciais. Ausência de
|
|
20
|
+
heartbeat é distinta de heartbeat vencido. Nenhum desses estados dispara execução ou retry.
|
|
21
|
+
Drivers concretos ficam fora do core. PostgreSQL persiste a projeção e publica NOTIFY na mesma
|
|
22
|
+
transação; LISTEN e SSE são avisos transitórios, recuperados por leitura após reconexão.
|
|
23
|
+
|
|
24
|
+
O transporte exige autorização do consumidor, limita o buffer e não envia snapshot pelo SSE.
|
|
25
|
+
Não há nova fila, API de controle universal, tabela de notificações nem substituição de audit
|
|
26
|
+
ou tracing. O consumidor trata falha de projeção fora da política de retry do negócio.
|
|
27
|
+
|
|
28
|
+
## Alternativas
|
|
29
|
+
|
|
30
|
+
Estender `JobHandle` para substituir todos os diários confundiria observação com autoridade.
|
|
31
|
+
Um event bus em memória não atende workers em processos distintos. Replicar polling e stores
|
|
32
|
+
por aplicação repetiria infraestrutura. A projeção opt-in preserva os motores existentes e
|
|
33
|
+
permite adoção incremental, ao custo de instrumentação e reconciliação explícitas.
|
|
34
|
+
|
|
35
|
+
## Verificação
|
|
36
|
+
|
|
37
|
+
Testar revisão obsoleta, isolamento, snapshot malformado, aviso após commit, reconexão,
|
|
38
|
+
revogação de acesso, limites de buffer e cancelamento. A API não deve aceitar payload arbitrário
|
|
39
|
+
nem possuir método para executar/repetir trabalho. Publicar somente após os gates do SDK.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.4.0",
|
|
4
4
|
"description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -103,6 +103,26 @@
|
|
|
103
103
|
"types": "./src/observability/drivers/opentelemetry.ts",
|
|
104
104
|
"default": "./src/observability/drivers/opentelemetry.ts"
|
|
105
105
|
},
|
|
106
|
+
"./executions": {
|
|
107
|
+
"types": "./src/executions/index.ts",
|
|
108
|
+
"default": "./src/executions/index.ts"
|
|
109
|
+
},
|
|
110
|
+
"./executions/memory": {
|
|
111
|
+
"types": "./src/executions/drivers/memory.ts",
|
|
112
|
+
"default": "./src/executions/drivers/memory.ts"
|
|
113
|
+
},
|
|
114
|
+
"./executions/pg": {
|
|
115
|
+
"types": "./src/executions/drivers/pg.ts",
|
|
116
|
+
"default": "./src/executions/drivers/pg.ts"
|
|
117
|
+
},
|
|
118
|
+
"./executions/pg-changes": {
|
|
119
|
+
"types": "./src/executions/drivers/pg-changes.ts",
|
|
120
|
+
"default": "./src/executions/drivers/pg-changes.ts"
|
|
121
|
+
},
|
|
122
|
+
"./executions/stream": {
|
|
123
|
+
"types": "./src/executions/stream.ts",
|
|
124
|
+
"default": "./src/executions/stream.ts"
|
|
125
|
+
},
|
|
106
126
|
"./queue": {
|
|
107
127
|
"types": "./src/queue/index.ts",
|
|
108
128
|
"default": "./src/queue/index.ts"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { executionNotice, executionQuery, executionSnapshot } from '../index.ts'
|
|
2
|
+
import type { ExecutionChanges, ExecutionNotice, ExecutionSnapshot, ExecutionStore } from '../index.ts'
|
|
3
|
+
|
|
4
|
+
/** Driver de testes. Não é durável nem observa outros processos. */
|
|
5
|
+
export function memoryExecutions(): ExecutionStore & ExecutionChanges {
|
|
6
|
+
const snapshots = new Map<string, ExecutionSnapshot>()
|
|
7
|
+
const listeners = new Set<(notice: ExecutionNotice | null) => void>()
|
|
8
|
+
return {
|
|
9
|
+
async observe(input) {
|
|
10
|
+
const next = executionSnapshot(input)
|
|
11
|
+
const current = snapshots.get(next.id)
|
|
12
|
+
if (current && (current.revision >= next.revision || current.tenantId !== next.tenantId ||
|
|
13
|
+
current.operation !== next.operation || current.createdAt !== next.createdAt || current.trigger !== next.trigger)) return false
|
|
14
|
+
snapshots.set(next.id, next)
|
|
15
|
+
for (const listener of listeners) {
|
|
16
|
+
// Um assinante não participa da confirmação da projeção.
|
|
17
|
+
try { listener(executionNotice(next)) } catch { /* transporte já desconectado */ }
|
|
18
|
+
}
|
|
19
|
+
return true
|
|
20
|
+
},
|
|
21
|
+
async get(id, tenantId) {
|
|
22
|
+
executionQuery({ tenantId, operations: [] })
|
|
23
|
+
const snapshot = snapshots.get(id)
|
|
24
|
+
return snapshot && snapshot.tenantId === tenantId ? structuredClone(snapshot) : null
|
|
25
|
+
},
|
|
26
|
+
async list(input) {
|
|
27
|
+
const query = executionQuery(input)
|
|
28
|
+
return [...snapshots.values()]
|
|
29
|
+
.filter((snapshot) => snapshot.tenantId === query.tenantId && query.operations.includes(snapshot.operation))
|
|
30
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id))
|
|
31
|
+
.slice(0, query.limit).map((snapshot) => structuredClone(snapshot))
|
|
32
|
+
},
|
|
33
|
+
subscribe(listener) {
|
|
34
|
+
const subscription = (notice: ExecutionNotice | null) => listener(notice)
|
|
35
|
+
listeners.add(subscription)
|
|
36
|
+
return () => { listeners.delete(subscription) }
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { parseExecutionNotice } from '../index.ts'
|
|
2
|
+
import type { ExecutionChanges, ExecutionNotice } from '../index.ts'
|
|
3
|
+
|
|
4
|
+
export interface ExecutionPgListener {
|
|
5
|
+
query(sql: string): Promise<unknown>
|
|
6
|
+
on(event: 'notification', listener: (message: { channel: string; payload?: string }) => void): unknown
|
|
7
|
+
on(event: 'error' | 'end', listener: () => void): unknown
|
|
8
|
+
removeListener(event: 'notification', listener: (message: { channel: string; payload?: string }) => void): unknown
|
|
9
|
+
removeListener(event: 'error' | 'end', listener: () => void): unknown
|
|
10
|
+
end(): Promise<unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Uma conexão LISTEN compartilhada entre assinantes; nunca usar conexão de transação/pooler. */
|
|
14
|
+
export function pgExecutionChanges(options: {
|
|
15
|
+
/** Conexão nova, já conectada e com connectionTimeoutMillis definido pelo consumidor. */
|
|
16
|
+
connect: () => Promise<ExecutionPgListener>
|
|
17
|
+
onError: (error: unknown) => void
|
|
18
|
+
channel?: string
|
|
19
|
+
reconnectMs?: number
|
|
20
|
+
}): ExecutionChanges {
|
|
21
|
+
const channel = options.channel ?? 'opus_executions'
|
|
22
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(channel)) throw new Error('Invalid execution channel')
|
|
23
|
+
const reconnectMs = options.reconnectMs ?? 1_000
|
|
24
|
+
if (!Number.isFinite(reconnectMs) || reconnectMs < 100) throw new Error('Invalid execution reconnect delay')
|
|
25
|
+
const listeners = new Set<{ notify: (notice: ExecutionNotice | null) => void; unavailable?: () => void }>()
|
|
26
|
+
let generation = 0
|
|
27
|
+
let cleanup: (() => void) | undefined
|
|
28
|
+
let retry: ReturnType<typeof setTimeout> | undefined
|
|
29
|
+
|
|
30
|
+
const report = (error: unknown) => { try { options.onError(error) } catch { /* diagnóstico não controla LISTEN */ } }
|
|
31
|
+
const broadcast = (notice: ExecutionNotice | null) => {
|
|
32
|
+
for (const listener of listeners) {
|
|
33
|
+
try { listener.notify(notice) } catch (error) { report(error) }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const close = (client: ExecutionPgListener) => {
|
|
37
|
+
const closingError = () => report(new Error('Execution change feed failed while closing'))
|
|
38
|
+
client.on('error', closingError)
|
|
39
|
+
void client.end().catch(report).finally(() => client.removeListener('error', closingError))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function connect(): Promise<void> {
|
|
43
|
+
const ownGeneration = ++generation
|
|
44
|
+
try {
|
|
45
|
+
const client = await options.connect()
|
|
46
|
+
if (ownGeneration !== generation || listeners.size === 0) { close(client); return }
|
|
47
|
+
const notification = (message: { channel: string; payload?: string }) => {
|
|
48
|
+
if (message.channel !== channel) return
|
|
49
|
+
try { broadcast(parseExecutionNotice(JSON.parse(message.payload ?? ''))) } catch (error) { report(error) }
|
|
50
|
+
}
|
|
51
|
+
const disconnected = () => {
|
|
52
|
+
if (ownGeneration !== generation) return
|
|
53
|
+
report(new Error('Execution change feed disconnected'))
|
|
54
|
+
reconnect()
|
|
55
|
+
}
|
|
56
|
+
client.on('notification', notification)
|
|
57
|
+
client.on('error', disconnected)
|
|
58
|
+
client.on('end', disconnected)
|
|
59
|
+
cleanup = () => {
|
|
60
|
+
client.removeListener('notification', notification)
|
|
61
|
+
client.removeListener('error', disconnected)
|
|
62
|
+
client.removeListener('end', disconnected)
|
|
63
|
+
close(client)
|
|
64
|
+
}
|
|
65
|
+
await client.query(`LISTEN "${channel}"`)
|
|
66
|
+
if (ownGeneration === generation) broadcast(null)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (ownGeneration !== generation) return
|
|
69
|
+
report(error)
|
|
70
|
+
reconnect()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function disconnect(): void {
|
|
75
|
+
++generation
|
|
76
|
+
if (retry !== undefined) { clearTimeout(retry); retry = undefined }
|
|
77
|
+
const dispose = cleanup
|
|
78
|
+
cleanup = undefined
|
|
79
|
+
dispose?.()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function reconnect(): void {
|
|
83
|
+
disconnect()
|
|
84
|
+
const disconnectedGeneration = generation
|
|
85
|
+
for (const listener of [...listeners]) {
|
|
86
|
+
try { listener.unavailable?.() } catch (error) { report(error) }
|
|
87
|
+
}
|
|
88
|
+
if (generation === disconnectedGeneration && listeners.size > 0) {
|
|
89
|
+
retry = setTimeout(() => { retry = undefined; void connect() }, reconnectMs)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
subscribe(listener, onUnavailable) {
|
|
95
|
+
const subscription = { notify: listener, unavailable: onUnavailable }
|
|
96
|
+
listeners.add(subscription)
|
|
97
|
+
if (listeners.size === 1) void connect()
|
|
98
|
+
return () => {
|
|
99
|
+
listeners.delete(subscription)
|
|
100
|
+
if (listeners.size === 0) disconnect()
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { executionQuery, executionSnapshot } from '../index.ts'
|
|
2
|
+
import type { ExecutionSnapshot, ExecutionStore } from '../index.ts'
|
|
3
|
+
|
|
4
|
+
export interface ExecutionPgPool {
|
|
5
|
+
query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PgExecutionsOptions {
|
|
9
|
+
pool: ExecutionPgPool
|
|
10
|
+
table?: string
|
|
11
|
+
channel?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sqlIdentifier(value: string): string {
|
|
15
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(value)) throw new Error('Invalid execution SQL identifier')
|
|
16
|
+
return `"${value}"`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** SQL para a migration do consumidor. O driver não aplica DDL nem grants automaticamente. */
|
|
20
|
+
export function executionTableSql(table = 'execution_snapshots'): string {
|
|
21
|
+
const name = sqlIdentifier(table)
|
|
22
|
+
return `CREATE TABLE ${name} (
|
|
23
|
+
id text PRIMARY KEY,
|
|
24
|
+
tenant_id text,
|
|
25
|
+
operation text NOT NULL,
|
|
26
|
+
revision bigint NOT NULL CHECK (revision > 0),
|
|
27
|
+
updated_at timestamptz NOT NULL,
|
|
28
|
+
snapshot jsonb NOT NULL
|
|
29
|
+
);
|
|
30
|
+
CREATE INDEX ON ${name} (tenant_id, operation, updated_at DESC, id);`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Escrita do snapshot e NOTIFY pertencem à mesma transação PostgreSQL. */
|
|
34
|
+
export function pgExecutions(options: PgExecutionsOptions): ExecutionStore {
|
|
35
|
+
const table = sqlIdentifier(options.table ?? 'execution_snapshots')
|
|
36
|
+
const channel = options.channel ?? 'opus_executions'
|
|
37
|
+
sqlIdentifier(channel)
|
|
38
|
+
return {
|
|
39
|
+
async observe(input) {
|
|
40
|
+
const next = executionSnapshot(input)
|
|
41
|
+
const result = await options.pool.query(`
|
|
42
|
+
WITH changed AS (
|
|
43
|
+
INSERT INTO ${table} AS current (id, tenant_id, operation, revision, updated_at, snapshot)
|
|
44
|
+
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
|
45
|
+
ON CONFLICT (id) DO UPDATE SET revision = EXCLUDED.revision,
|
|
46
|
+
updated_at = EXCLUDED.updated_at, snapshot = EXCLUDED.snapshot
|
|
47
|
+
WHERE current.revision < EXCLUDED.revision
|
|
48
|
+
AND current.tenant_id IS NOT DISTINCT FROM EXCLUDED.tenant_id
|
|
49
|
+
AND current.operation = EXCLUDED.operation
|
|
50
|
+
AND current.snapshot->>'createdAt' = EXCLUDED.snapshot->>'createdAt'
|
|
51
|
+
AND current.snapshot->>'trigger' = EXCLUDED.snapshot->>'trigger'
|
|
52
|
+
RETURNING id, tenant_id, operation, revision
|
|
53
|
+
)
|
|
54
|
+
SELECT pg_notify($7, json_build_object('id', id, 'tenantId', tenant_id,
|
|
55
|
+
'operation', operation, 'revision', revision)::text) FROM changed`,
|
|
56
|
+
[next.id, next.tenantId, next.operation, next.revision, next.updatedAt, JSON.stringify(next), channel])
|
|
57
|
+
return result.rows.length > 0
|
|
58
|
+
},
|
|
59
|
+
async get(id, tenantId) {
|
|
60
|
+
executionQuery({ tenantId, operations: [] })
|
|
61
|
+
const result = await options.pool.query(`SELECT snapshot FROM ${table} WHERE id = $1 AND tenant_id IS NOT DISTINCT FROM $2`, [id, tenantId])
|
|
62
|
+
const row = result.rows[0]
|
|
63
|
+
return row ? executionSnapshot(row.snapshot as ExecutionSnapshot) : null
|
|
64
|
+
},
|
|
65
|
+
async list(input) {
|
|
66
|
+
const query = executionQuery(input)
|
|
67
|
+
if (query.operations.length === 0) return []
|
|
68
|
+
const result = await options.pool.query(`SELECT snapshot FROM ${table}
|
|
69
|
+
WHERE tenant_id IS NOT DISTINCT FROM $1 AND operation = ANY($2::text[])
|
|
70
|
+
ORDER BY updated_at DESC, id ASC LIMIT $3`, [query.tenantId, query.operations, query.limit])
|
|
71
|
+
return result.rows.map((row) => executionSnapshot(row.snapshot as ExecutionSnapshot))
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Projeção operacional. Estados e revisões são informados pelo executor, nunca inferidos aqui. */
|
|
2
|
+
export type ExecutionStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'cancelled'
|
|
3
|
+
export type ExecutionTrigger = 'manual' | 'schedule' | 'queue' | 'system'
|
|
4
|
+
|
|
5
|
+
export interface ExecutionSnapshot {
|
|
6
|
+
id: string
|
|
7
|
+
tenantId: string | null
|
|
8
|
+
operation: string
|
|
9
|
+
revision: number
|
|
10
|
+
status: ExecutionStatus
|
|
11
|
+
trigger: ExecutionTrigger
|
|
12
|
+
/** Tentativa da mesma execução; o executor continua responsável por sua política de retry. */
|
|
13
|
+
attempt: number
|
|
14
|
+
createdAt: string
|
|
15
|
+
updatedAt: string
|
|
16
|
+
startedAt?: string
|
|
17
|
+
finishedAt?: string
|
|
18
|
+
heartbeatAt?: string
|
|
19
|
+
progress?: { completed: number; total?: number }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecutionNotice {
|
|
23
|
+
id: string
|
|
24
|
+
tenantId: string | null
|
|
25
|
+
operation: string
|
|
26
|
+
revision: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ExecutionQuery {
|
|
30
|
+
/** Obrigatório: null seleciona somente execuções sem tenant, nunca todos os tenants. */
|
|
31
|
+
tenantId: string | null
|
|
32
|
+
operations: readonly string[]
|
|
33
|
+
limit?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExecutionStore {
|
|
37
|
+
/** false: revisão obsoleta/repetida ou tentativa de alterar a identidade do registro. */
|
|
38
|
+
observe(snapshot: ExecutionSnapshot): Promise<boolean>
|
|
39
|
+
get(id: string, tenantId: string | null): Promise<ExecutionSnapshot | null>
|
|
40
|
+
list(query: ExecutionQuery): Promise<ExecutionSnapshot[]>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExecutionChanges {
|
|
44
|
+
/** null exige nova fotografia após conectar/reconectar. Não há replay de NOTIFY. */
|
|
45
|
+
subscribe(listener: (notice: ExecutionNotice | null) => void, onUnavailable?: () => void): () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statuses: readonly ExecutionStatus[] = ['queued', 'running', 'paused', 'succeeded', 'failed', 'cancelled']
|
|
49
|
+
const triggers: readonly ExecutionTrigger[] = ['manual', 'schedule', 'queue', 'system']
|
|
50
|
+
|
|
51
|
+
function identifier(value: unknown): asserts value is string {
|
|
52
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 200 || /[\x00-\x1f]/.test(value)) {
|
|
53
|
+
throw new Error('Invalid execution identifier')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function integer(value: unknown, min: number): asserts value is number {
|
|
58
|
+
if (!Number.isSafeInteger(value) || (value as number) < min) throw new Error('Invalid execution counter')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function date(value: unknown): asserts value is string {
|
|
62
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T.*Z$/.test(value) || !Number.isFinite(Date.parse(value))) {
|
|
63
|
+
throw new Error('Invalid execution timestamp')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Valida e seleciona explicitamente campos; propriedades extras não chegam à persistência. */
|
|
68
|
+
export function executionSnapshot(input: ExecutionSnapshot): ExecutionSnapshot {
|
|
69
|
+
identifier(input.id)
|
|
70
|
+
identifier(input.operation)
|
|
71
|
+
if (input.tenantId !== null) identifier(input.tenantId)
|
|
72
|
+
integer(input.revision, 1)
|
|
73
|
+
integer(input.attempt, 0)
|
|
74
|
+
if (!statuses.includes(input.status) || !triggers.includes(input.trigger)) throw new Error('Invalid execution state')
|
|
75
|
+
date(input.createdAt)
|
|
76
|
+
date(input.updatedAt)
|
|
77
|
+
const result: ExecutionSnapshot = {
|
|
78
|
+
id: input.id, tenantId: input.tenantId, operation: input.operation,
|
|
79
|
+
revision: input.revision, status: input.status, trigger: input.trigger, attempt: input.attempt,
|
|
80
|
+
createdAt: new Date(input.createdAt).toISOString(), updatedAt: new Date(input.updatedAt).toISOString(),
|
|
81
|
+
}
|
|
82
|
+
for (const key of ['startedAt', 'finishedAt', 'heartbeatAt'] as const) {
|
|
83
|
+
if (input[key] !== undefined) { date(input[key]); result[key] = new Date(input[key]).toISOString() }
|
|
84
|
+
}
|
|
85
|
+
if (Date.parse(result.updatedAt) < Date.parse(result.createdAt)) throw new Error('Invalid execution chronology')
|
|
86
|
+
if (input.progress !== undefined) {
|
|
87
|
+
integer(input.progress.completed, 0)
|
|
88
|
+
result.progress = { completed: input.progress.completed }
|
|
89
|
+
if (input.progress.total !== undefined) {
|
|
90
|
+
integer(input.progress.total, input.progress.completed)
|
|
91
|
+
result.progress.total = input.progress.total
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function executionNotice(snapshot: ExecutionSnapshot): ExecutionNotice {
|
|
98
|
+
return { id: snapshot.id, tenantId: snapshot.tenantId, operation: snapshot.operation, revision: snapshot.revision }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseExecutionNotice(value: unknown): ExecutionNotice {
|
|
102
|
+
if (typeof value !== 'object' || value === null) throw new Error('Invalid execution notice')
|
|
103
|
+
const input = value as ExecutionNotice
|
|
104
|
+
identifier(input.id)
|
|
105
|
+
identifier(input.operation)
|
|
106
|
+
if (input.tenantId !== null) identifier(input.tenantId)
|
|
107
|
+
integer(input.revision, 1)
|
|
108
|
+
return { id: input.id, tenantId: input.tenantId, operation: input.operation, revision: input.revision }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function executionQuery(query: ExecutionQuery): Required<ExecutionQuery> {
|
|
112
|
+
if (query.tenantId !== null) identifier(query.tenantId)
|
|
113
|
+
if (!Array.isArray(query.operations) || query.operations.length > 100) throw new Error('Invalid execution operations')
|
|
114
|
+
for (const operation of query.operations) identifier(operation)
|
|
115
|
+
const limit = query.limit ?? 50
|
|
116
|
+
integer(limit, 1)
|
|
117
|
+
if (limit > 200) throw new Error('Execution query limit exceeds 200')
|
|
118
|
+
return { tenantId: query.tenantId, operations: [...new Set(query.operations)], limit }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Diagnóstico de observação; nunca transforma o estado autoritativo em failed nem autoriza retry. */
|
|
122
|
+
export function executionLiveness(snapshot: ExecutionSnapshot, now: number, staleAfterMs: number): 'inactive' | 'unknown' | 'fresh' | 'stale' {
|
|
123
|
+
if (!Number.isFinite(now) || !Number.isFinite(staleAfterMs) || staleAfterMs <= 0) throw new Error('Invalid liveness clock')
|
|
124
|
+
if (snapshot.status !== 'running') return 'inactive'
|
|
125
|
+
if (snapshot.heartbeatAt === undefined) return 'unknown'
|
|
126
|
+
const heartbeat = Date.parse(snapshot.heartbeatAt)
|
|
127
|
+
if (!Number.isFinite(heartbeat) || heartbeat > now) return 'unknown'
|
|
128
|
+
return now - heartbeat >= staleAfterMs ? 'stale' : 'fresh'
|
|
129
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ExecutionChanges, ExecutionNotice } from './index.ts'
|
|
2
|
+
|
|
3
|
+
export interface ExecutionStreamOptions {
|
|
4
|
+
changes: ExecutionChanges
|
|
5
|
+
/** null: sessão e direito de acompanhar; notice: também autorizar a execução exata. */
|
|
6
|
+
authorize: (notice: ExecutionNotice | null) => Promise<boolean>
|
|
7
|
+
signal?: AbortSignal
|
|
8
|
+
heartbeatMs?: number
|
|
9
|
+
/** Renova a conexão e a autorização mesmo se não houver atividade. */
|
|
10
|
+
maxDurationMs?: number
|
|
11
|
+
onError: (error: unknown) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* SSE sem dados de negócio. Ler a fotografia por actions autorizadas após cada invalidate.
|
|
16
|
+
* Assinar antes da primeira invalidação evita a lacuna entre fotografia e assinatura.
|
|
17
|
+
*/
|
|
18
|
+
export async function executionStream(options: ExecutionStreamOptions): Promise<Response> {
|
|
19
|
+
const heartbeatMs = options.heartbeatMs ?? 15_000
|
|
20
|
+
const maxDurationMs = options.maxDurationMs ?? 300_000
|
|
21
|
+
if (!Number.isFinite(heartbeatMs) || heartbeatMs < 100 || !Number.isFinite(maxDurationMs) || maxDurationMs < heartbeatMs) {
|
|
22
|
+
throw new Error('Invalid execution stream timing')
|
|
23
|
+
}
|
|
24
|
+
if (options.signal?.aborted) return new Response(null, { status: 204 })
|
|
25
|
+
if (!await options.authorize(null)) return new Response(null, { status: 403 })
|
|
26
|
+
if (options.signal?.aborted) return new Response(null, { status: 204 })
|
|
27
|
+
|
|
28
|
+
const encoder = new TextEncoder()
|
|
29
|
+
let close = () => {}
|
|
30
|
+
let cancelled = false
|
|
31
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
32
|
+
start(controller) {
|
|
33
|
+
let closed = false
|
|
34
|
+
let busy = false
|
|
35
|
+
let pendingReset = false
|
|
36
|
+
let pinging = false
|
|
37
|
+
let sessionCheck: Promise<boolean> | undefined
|
|
38
|
+
const pending = new Map<string, ExecutionNotice>()
|
|
39
|
+
let unsubscribe: (() => void) | undefined
|
|
40
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
41
|
+
let deadline: ReturnType<typeof setTimeout> | undefined
|
|
42
|
+
close = () => {
|
|
43
|
+
if (closed) return
|
|
44
|
+
closed = true
|
|
45
|
+
pending.clear()
|
|
46
|
+
unsubscribe?.()
|
|
47
|
+
clearInterval(heartbeat)
|
|
48
|
+
clearTimeout(deadline)
|
|
49
|
+
options.signal?.removeEventListener('abort', close)
|
|
50
|
+
if (!cancelled) controller.close()
|
|
51
|
+
}
|
|
52
|
+
const send = (message: string) => {
|
|
53
|
+
if (closed) return
|
|
54
|
+
// Uma conexão lenta reconecta e refaz a fotografia; nunca acumula progresso sem limite.
|
|
55
|
+
if ((controller.desiredSize ?? 0) <= 0) { close(); return }
|
|
56
|
+
controller.enqueue(encoder.encode(message))
|
|
57
|
+
}
|
|
58
|
+
const invalidate = () => send('event: invalidate\ndata: {}\n\n')
|
|
59
|
+
const report = (error: unknown) => { try { options.onError(error) } catch { /* não vazar erro no SSE */ } }
|
|
60
|
+
|
|
61
|
+
function sessionAllowed(): Promise<boolean> {
|
|
62
|
+
sessionCheck ??= Promise.resolve().then(() => options.authorize(null)).finally(() => { sessionCheck = undefined })
|
|
63
|
+
return sessionCheck
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A fila de autorizações por recurso não pode segurar a renovação da sessão ou o heartbeat.
|
|
67
|
+
async function ping(): Promise<void> {
|
|
68
|
+
if (pinging || closed) return
|
|
69
|
+
pinging = true
|
|
70
|
+
try {
|
|
71
|
+
if (!await sessionAllowed()) close()
|
|
72
|
+
else send(': heartbeat\n\n')
|
|
73
|
+
} catch (error) { report(error); close() }
|
|
74
|
+
finally { pinging = false }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function flush(): Promise<void> {
|
|
78
|
+
if (busy || closed) return
|
|
79
|
+
busy = true
|
|
80
|
+
try {
|
|
81
|
+
while (!closed && (pending.size > 0 || pendingReset)) {
|
|
82
|
+
const notices = [...pending.values()]
|
|
83
|
+
pending.clear()
|
|
84
|
+
let allowed = pendingReset
|
|
85
|
+
pendingReset = false
|
|
86
|
+
if (!await sessionAllowed()) { close(); break }
|
|
87
|
+
for (const notice of notices) {
|
|
88
|
+
if (closed) break
|
|
89
|
+
if (await options.authorize(notice)) allowed = true
|
|
90
|
+
}
|
|
91
|
+
if (allowed && !closed) {
|
|
92
|
+
if (!await sessionAllowed()) { close(); break }
|
|
93
|
+
invalidate()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (error) { report(error); close() }
|
|
97
|
+
finally { busy = false }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const dispose = options.changes.subscribe((notice) => {
|
|
101
|
+
if (closed) return
|
|
102
|
+
if (notice === null) pendingReset = true
|
|
103
|
+
else pending.set(notice.id, notice)
|
|
104
|
+
if (pending.size > 128) { close(); return }
|
|
105
|
+
void flush()
|
|
106
|
+
}, close)
|
|
107
|
+
unsubscribe = dispose
|
|
108
|
+
if (closed) { dispose(); return }
|
|
109
|
+
options.signal?.addEventListener('abort', close, { once: true })
|
|
110
|
+
if (options.signal?.aborted) { close(); return }
|
|
111
|
+
heartbeat = setInterval(() => { void ping() }, heartbeatMs)
|
|
112
|
+
deadline = setTimeout(close, maxDurationMs)
|
|
113
|
+
pendingReset = true
|
|
114
|
+
void flush()
|
|
115
|
+
},
|
|
116
|
+
cancel() { cancelled = true; close() },
|
|
117
|
+
}, { highWaterMark: 16 })
|
|
118
|
+
return new Response(stream, { headers: {
|
|
119
|
+
'Content-Type': 'text/event-stream',
|
|
120
|
+
'Cache-Control': 'no-cache, no-store',
|
|
121
|
+
'X-Accel-Buffering': 'no',
|
|
122
|
+
} })
|
|
123
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Acompanhamento de execuções
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Acompanhamento de execuções
|
|
6
|
+
|
|
7
|
+
Use `executions` para reunir progresso e resultado de tarefas executadas por motores diferentes.
|
|
8
|
+
O módulo é experimental e opt-in: instalar o Opus não passa a descobrir crons, filas ou workers.
|
|
9
|
+
O executor continua dono do estado; este recurso mantém uma fotografia para consulta.
|
|
10
|
+
|
|
11
|
+
Para colocar trabalho em segundo plano, use [Filas](/docs/queue). Para acompanhar trabalho já
|
|
12
|
+
existente, adapte seu executor à projeção abaixo. Não usar a projeção para decidir reenvio ou retry.
|
|
13
|
+
|
|
14
|
+
## Publicar uma fotografia
|
|
15
|
+
|
|
16
|
+
O executor precisa conservar um ID e uma revisão crescente, inclusive entre tentativas e
|
|
17
|
+
reinícios. Grave essa revisão com o estado autoritativo; não use `Date.now()` nem um contador
|
|
18
|
+
exclusivo da memória do processo. O exemplo usa o driver em memória somente para demonstrar o
|
|
19
|
+
protocolo:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { memoryExecutions } from '@softize/opus/executions/memory'
|
|
23
|
+
|
|
24
|
+
const executions = memoryExecutions()
|
|
25
|
+
await executions.observe({
|
|
26
|
+
id: 'export-42',
|
|
27
|
+
tenantId: 'organization-7',
|
|
28
|
+
operation: 'catalog.export',
|
|
29
|
+
revision: 3,
|
|
30
|
+
attempt: 1,
|
|
31
|
+
status: 'running',
|
|
32
|
+
trigger: 'schedule',
|
|
33
|
+
createdAt: '2026-09-21T10:00:00.000Z',
|
|
34
|
+
updatedAt: '2026-09-21T10:01:00.000Z',
|
|
35
|
+
heartbeatAt: '2026-09-21T10:01:00.000Z',
|
|
36
|
+
progress: { completed: 150, total: 500 },
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`observe` retorna `false` para uma revisão repetida ou obsoleta e para uma tentativa de trocar
|
|
41
|
+
tenant, operação, criação ou disparo do mesmo ID. O ID é único em todo o store, não por tenant.
|
|
42
|
+
Cron que enfileira trabalho continua com
|
|
43
|
+
`trigger: 'schedule'`; entrar na fila não cria outra execução. Retomada mantém o ID e avança
|
|
44
|
+
revisão/tentativa conforme o executor. A projeção não valida transições de negócio.
|
|
45
|
+
|
|
46
|
+
Uma falha de projeção lança erro para o adaptador tratar. Registrar essa falha e reconciliar
|
|
47
|
+
depois a partir do executor; não deixar o erro provocar retry de um efeito já realizado.
|
|
48
|
+
`observe` não contém proteção automática para o fluxo de negócio do consumidor.
|
|
49
|
+
|
|
50
|
+
## Persistência e processos separados
|
|
51
|
+
|
|
52
|
+
Use `pgExecutions({ pool, table?, channel? })` para persistir. O pool é fornecido pelo consumidor,
|
|
53
|
+
sem dependência de conexão global. `executionTableSql(table?)` devolve o DDL para incluir na
|
|
54
|
+
migration; não executa a migration, grants ou retenção. Nomes de tabela/canal aceitam um
|
|
55
|
+
identificador SQL simples. Tabela padrão: `execution_snapshots`; canal: `opus_executions`.
|
|
56
|
+
|
|
57
|
+
O driver atualiza somente uma revisão maior e publica NOTIFY no mesmo statement. Em uma
|
|
58
|
+
transação externa, o aviso só sai no commit. Rollback não publica progresso. O snapshot é
|
|
59
|
+
durável; o aviso não é histórico nem fila de entrega.
|
|
60
|
+
|
|
61
|
+
`pgExecutionChanges({ connect, onError, channel?, reconnectMs? })` compartilha uma conexão
|
|
62
|
+
LISTEN entre seus assinantes. `connect` deve entregar uma conexão nova, já conectada, dedicada
|
|
63
|
+
e com timeout de conexão configurado. Não fornecer uma conexão emprestada por um pooler
|
|
64
|
+
transacional. O driver fecha a conexão quando sai o último assinante, registra falhas por
|
|
65
|
+
`onError` e tenta reconectar após `reconnectMs` (padrão: 1.000 ms; mínimo: 100 ms).
|
|
66
|
+
|
|
67
|
+
Após cada conexão, o assinante recebe `null`: refaça a fotografia. Uma conexão nova não recupera
|
|
68
|
+
avisos perdidos. O consumidor precisa reconciliar também snapshots que não foram gravados por
|
|
69
|
+
falha de banco; reconectar SSE não corrige uma projeção que nunca foi atualizada.
|
|
70
|
+
|
|
71
|
+
## Atualizar a interface por SSE
|
|
72
|
+
|
|
73
|
+
`executionStream` devolve uma `Response` Web para montar em uma rota do servidor. O consumidor
|
|
74
|
+
fornece autenticação e autorização; nenhum token é recebido pela URL ou pelo protocolo do SDK.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { executionStream } from '@softize/opus/executions/stream'
|
|
78
|
+
import type { ExecutionChanges, ExecutionNotice } from '@softize/opus/executions'
|
|
79
|
+
|
|
80
|
+
async function openProgress(
|
|
81
|
+
request: Request,
|
|
82
|
+
changes: ExecutionChanges,
|
|
83
|
+
authorize: (notice: ExecutionNotice | null) => Promise<boolean>,
|
|
84
|
+
logError: (error: unknown) => void,
|
|
85
|
+
) {
|
|
86
|
+
return executionStream({ changes, authorize, signal: request.signal, onError: logError })
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Para `authorize(null)`, verificar sessão e direito de acompanhar. Para um aviso, verificar
|
|
91
|
+
também tenant, operação e recurso exato. Essa verificação ocorre novamente durante o stream,
|
|
92
|
+
inclusive nos heartbeats e antes de emitir uma invalidação. Heartbeats revalidam a sessão
|
|
93
|
+
independentemente da fila de avisos: a função pode receber um aviso e `null` concorrentemente.
|
|
94
|
+
As actions de leitura devem aplicar a mesma política: filtrar a
|
|
95
|
+
consulta por tenant não substitui autorização.
|
|
96
|
+
|
|
97
|
+
O cliente recebe somente `event: invalidate` com `{}`. Ao recebê-lo, invalidar as queries das
|
|
98
|
+
actions autorizadas. Manter uma conexão compartilhada no provider da aplicação, não uma por
|
|
99
|
+
card. A primeira invalidação e a reconexão exigem nova fotografia; não há `Last-Event-ID` nem
|
|
100
|
+
replay. O navegador pode usar `EventSource` com cookies same-origin e sua reconexão nativa.
|
|
101
|
+
|
|
102
|
+
Heartbeat padrão: 15 segundos. Duração máxima padrão: cinco minutos, seguida de reconexão.
|
|
103
|
+
O stream encerra na revogação de acesso, abort, falha de autorização, perda de LISTEN ou cliente lento. Há limites
|
|
104
|
+
de 128 avisos pendentes e 16 frames na saída; excedê-los encerra a conexão para nova fotografia,
|
|
105
|
+
sem acumular um log em memória. Sessão é reavaliada no máximo a cada heartbeat enquanto a
|
|
106
|
+
autorização responde; configure timeout no serviço de autorização para não mantê-la pendente.
|
|
107
|
+
|
|
108
|
+
IDs, tenant e operação nos avisos são metadados operacionais internos. Não usar CPF, telefone,
|
|
109
|
+
e-mail ou segredo nesses identificadores. PostgreSQL NOTIFY não é uma fronteira de autorização:
|
|
110
|
+
restringir as credenciais e o acesso ao banco. Nada do aviso é copiado para o SSE.
|
|
111
|
+
|
|
112
|
+
## Detectar ausência de sinal
|
|
113
|
+
|
|
114
|
+
`executionLiveness(snapshot, nowMs, staleAfterMs)` retorna `inactive` quando não está em
|
|
115
|
+
execução, `unknown` quando não há heartbeat confiável, `fresh` com sinal recente e `stale`
|
|
116
|
+
quando o limite foi atingido. Heartbeat futuro é `unknown`, para não esconder diferença de
|
|
117
|
+
relógio. O chamador define a tolerância de acordo com a frequência do worker.
|
|
118
|
+
|
|
119
|
+
`stale` não altera `status`, não confirma falha e não autoriza retomar. Pausar, cancelar e
|
|
120
|
+
repetir continuam comandos do domínio. Logs completos e notificações pessoais também
|
|
121
|
+
continuam fora desta projeção.
|
|
122
|
+
|
|
123
|
+
## Referência de ExecutionSnapshot
|
|
124
|
+
|
|
125
|
+
| Campo | Contrato |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `id`, `tenantId`, `operation` | ID operacional, tenant explícito ou `null` e código da operação; IDs textuais limitados a 200 caracteres |
|
|
128
|
+
| `revision`, `attempt` | Inteiros seguros; revisão positiva e tentativa não negativa |
|
|
129
|
+
| `status` | `queued`, `running`, `paused`, `succeeded`, `failed` ou `cancelled` |
|
|
130
|
+
| `trigger` | `manual`, `schedule`, `queue` ou `system`; imutável para o mesmo ID |
|
|
131
|
+
| `createdAt`, `updatedAt` | Datas UTC; atualização não pode anteceder criação |
|
|
132
|
+
| `startedAt`, `finishedAt`, `heartbeatAt` | Datas UTC opcionais |
|
|
133
|
+
| `progress` | `completed` não negativo e `total` opcional, não inferior ao concluído; ambos inteiros seguros |
|
|
134
|
+
|
|
135
|
+
`executionSnapshot` valida, normaliza datas e seleciona somente os campos acima.
|
|
136
|
+
`executionNotice` extrai ID, tenant, operação e revisão. `parseExecutionNotice` valida dados
|
|
137
|
+
recebidos pelo transporte e remove propriedades extras.
|
|
138
|
+
|
|
139
|
+
## Referência de ExecutionStore e ExecutionChanges
|
|
140
|
+
|
|
141
|
+
| Método | Contrato |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `observe(snapshot)` | Grava somente revisão nova; retorna se alterou a projeção |
|
|
144
|
+
| `get(id, tenantId)` | Busca escopada; devolve snapshot ou `null` |
|
|
145
|
+
| `list({ tenantId, operations, limit? })` | Lista por atualização decrescente e ID; operações obrigatórias, limite padrão 50 e máximo 200 |
|
|
146
|
+
| `subscribe(listener, onUnavailable?)` | Assina aviso ou `null` de reconexão; comunica perda do transporte ao segundo callback e devolve função de saída |
|
|
147
|
+
|
|
148
|
+
`tenantId: null` seleciona somente execuções sem tenant, não todos os tenants. Lista de operações
|
|
149
|
+
vazia não retorna registros. `executionQuery` valida esse recorte. O catálogo de operações,
|
|
150
|
+
autorização, paginação completa do histórico e retenção pertencem ao consumidor; esta leitura
|
|
151
|
+
limitada atende os snapshots recentes, não substitui uma action `kind: 'list'`.
|
|
152
|
+
|
|
153
|
+
## Limites desta etapa
|
|
154
|
+
|
|
155
|
+
Não há adaptadores automáticos de runtime, BullMQ ou scheduler, heartbeat automático,
|
|
156
|
+
reconciliação agendada, UI pronta ou mecanismo genérico de pausa. Esses consumidores precisam
|
|
157
|
+
ser implementados e testados antes de afirmar que todos os processos estão acompanhados.
|
package/src/ui/docs/registry.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import eventsMd from "./content/events.md?raw";
|
|
|
33
33
|
import logMd from "./content/log.md?raw";
|
|
34
34
|
import observabilityMd from "./content/observability.md?raw";
|
|
35
35
|
import queueMd from "./content/queue.md?raw";
|
|
36
|
+
import executionsMd from "./content/executions.md?raw";
|
|
36
37
|
import schedulerMd from "./content/scheduler.md?raw";
|
|
37
38
|
import actionsMd from "./content/actions.md?raw";
|
|
38
39
|
import dataMd from "./content/data.md?raw";
|
|
@@ -321,6 +322,12 @@ export const PROTOCOL_SECTIONS: DocSection[] = [
|
|
|
321
322
|
badge: "queue",
|
|
322
323
|
render: doc(queueMd),
|
|
323
324
|
},
|
|
325
|
+
{
|
|
326
|
+
slug: "executions",
|
|
327
|
+
title: "Acompanhamento de execuções",
|
|
328
|
+
badge: "executions",
|
|
329
|
+
render: doc(executionsMd),
|
|
330
|
+
},
|
|
324
331
|
{
|
|
325
332
|
slug: "scheduler",
|
|
326
333
|
title: "Agendador",
|