@softize/opus 9.0.9 → 9.1.1
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 +36 -0
- package/README.md +2 -2
- package/docs/adr/0001-vendor-neutral-observability-context.md +96 -0
- package/docs/protocol.md +101 -12
- package/package.json +16 -1
- package/src/audit/drivers/pg.ts +60 -8
- package/src/core/actions.ts +3 -1
- package/src/core/index.ts +4 -0
- package/src/core/runtime.ts +230 -26
- package/src/core/trace.ts +34 -0
- package/src/core/types.ts +44 -0
- package/src/observability/drivers/opentelemetry.ts +165 -0
- package/src/observability/index.ts +8 -0
- package/src/queue/drivers/bullmq.ts +47 -5
- package/src/server/drivers/fastify.ts +16 -1
- package/src/server/drivers/node.ts +23 -3
- package/src/server/index.ts +57 -0
- package/src/testing/index.ts +3 -0
- package/src/ui/components/primitives/ask.tsx +186 -0
- package/src/ui/docs/content/ask.md +31 -0
- package/src/ui/docs/content/audit.md +17 -0
- package/src/ui/docs/content/observability.md +71 -0
- package/src/ui/docs/content/queue.md +29 -3
- package/src/ui/docs/content/runtime.md +18 -1
- package/src/ui/docs/registry.tsx +2 -0
- package/src/ui/meta.ts +6 -0
- package/src/ui/react.tsx +2 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import type { AskAnswer, AskQuestion } from '../../../core/index.ts'
|
|
3
|
+
import { cn } from '../../lib/cn.ts'
|
|
4
|
+
import { Button } from './button.tsx'
|
|
5
|
+
import { Card } from './card.tsx'
|
|
6
|
+
import { Input } from './input.tsx'
|
|
7
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip.tsx'
|
|
8
|
+
|
|
9
|
+
export interface AskProps {
|
|
10
|
+
/** Uma a quatro perguntas do contrato de elicitação. */
|
|
11
|
+
questions: AskQuestion[]
|
|
12
|
+
/** Respostas controladas, na mesma ordem de `questions`. Entradas ausentes são vazias. */
|
|
13
|
+
answers: AskAnswer[]
|
|
14
|
+
onChange: (answers: AskAnswer[]) => void
|
|
15
|
+
/** Dispara somente quando toda pergunta tem uma opção ou texto livre. */
|
|
16
|
+
onSubmit: (answers: AskAnswer[]) => void
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
busy?: boolean
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function answerFor(question: AskQuestion, answer?: AskAnswer): AskAnswer {
|
|
23
|
+
const optionLabels = new Set(question.options.map((option) => option.label))
|
|
24
|
+
const selected = (answer?.selected ?? []).filter((label) => optionLabels.has(label))
|
|
25
|
+
return {
|
|
26
|
+
header: question.header?.trim() || question.question,
|
|
27
|
+
selected: question.multiSelect ? selected : selected.slice(0, 1),
|
|
28
|
+
...(answer?.text === undefined ? {} : { text: answer.text }),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isAnswered(answer: AskAnswer): boolean {
|
|
33
|
+
return answer.selected.length > 0 || (answer.text?.trim() ?? '') !== ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Elicitação estruturada controlada. Não executa transporte nem mantém estado de domínio. */
|
|
37
|
+
export function Ask({
|
|
38
|
+
questions,
|
|
39
|
+
answers,
|
|
40
|
+
onChange,
|
|
41
|
+
onSubmit,
|
|
42
|
+
disabled = false,
|
|
43
|
+
busy = false,
|
|
44
|
+
className,
|
|
45
|
+
}: AskProps): React.ReactElement {
|
|
46
|
+
const id = React.useId()
|
|
47
|
+
const questionSignature = JSON.stringify(questions)
|
|
48
|
+
const [submittedSignature, setSubmittedSignature] = React.useState<string>()
|
|
49
|
+
const submitted = submittedSignature === questionSignature
|
|
50
|
+
const normalized = questions.map((question, index) => answerFor(question, answers[index]))
|
|
51
|
+
const validCount = questions.length >= 1 && questions.length <= 4
|
|
52
|
+
const allAnswered = validCount && normalized.every(isAnswered)
|
|
53
|
+
const locked = disabled || busy
|
|
54
|
+
|
|
55
|
+
const changeAt = (index: number, next: AskAnswer): void => {
|
|
56
|
+
const nextAnswers = [...normalized]
|
|
57
|
+
nextAnswers[index] = next
|
|
58
|
+
onChange(nextAnswers)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Card
|
|
63
|
+
asChild
|
|
64
|
+
className={cn('p-4', className)}
|
|
65
|
+
aria-busy={busy || undefined}
|
|
66
|
+
aria-disabled={disabled || undefined}
|
|
67
|
+
>
|
|
68
|
+
<form
|
|
69
|
+
data-slot="ask"
|
|
70
|
+
noValidate
|
|
71
|
+
onSubmit={(event) => {
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
setSubmittedSignature(questionSignature)
|
|
74
|
+
if (allAnswered && !locked) onSubmit(normalized)
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<TooltipProvider>
|
|
78
|
+
<div className="space-y-4">
|
|
79
|
+
{questions.map((question, questionIndex) => {
|
|
80
|
+
const answer = normalized[questionIndex]
|
|
81
|
+
const questionId = `${id}-question-${questionIndex}`
|
|
82
|
+
const errorId = `${id}-error-${questionIndex}`
|
|
83
|
+
const invalid = submitted && !isAnswered(answer)
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<fieldset key={questionId} disabled={locked} className="min-w-0 space-y-2.5">
|
|
87
|
+
<legend id={questionId} className="text-sm font-medium text-foreground">
|
|
88
|
+
{question.question}
|
|
89
|
+
</legend>
|
|
90
|
+
<div
|
|
91
|
+
role={question.multiSelect ? 'group' : 'radiogroup'}
|
|
92
|
+
aria-labelledby={questionId}
|
|
93
|
+
aria-describedby={invalid ? errorId : undefined}
|
|
94
|
+
className="flex flex-wrap gap-2"
|
|
95
|
+
onKeyDown={question.multiSelect ? undefined : (event) => {
|
|
96
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return
|
|
97
|
+
const radios = Array.from(
|
|
98
|
+
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)'),
|
|
99
|
+
)
|
|
100
|
+
const current = radios.indexOf(document.activeElement as HTMLButtonElement)
|
|
101
|
+
if (current < 0 || radios.length === 0) return
|
|
102
|
+
event.preventDefault()
|
|
103
|
+
const direction = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1
|
|
104
|
+
const next = radios[(current + direction + radios.length) % radios.length]
|
|
105
|
+
next.focus()
|
|
106
|
+
next.click()
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{question.options.map((option) => {
|
|
110
|
+
const selected = answer.selected.includes(option.label)
|
|
111
|
+
const firstSelected = answer.selected[0]
|
|
112
|
+
const firstOption = question.options[0]?.label
|
|
113
|
+
const optionButton = (
|
|
114
|
+
<Button
|
|
115
|
+
type="button"
|
|
116
|
+
size="sm"
|
|
117
|
+
shape="pill"
|
|
118
|
+
variant={selected ? 'secondary' : 'outline'}
|
|
119
|
+
role={question.multiSelect ? undefined : 'radio'}
|
|
120
|
+
aria-checked={question.multiSelect ? undefined : selected}
|
|
121
|
+
aria-pressed={question.multiSelect ? selected : undefined}
|
|
122
|
+
tabIndex={question.multiSelect
|
|
123
|
+
? undefined
|
|
124
|
+
: selected || (firstSelected === undefined && option.label === firstOption)
|
|
125
|
+
? 0
|
|
126
|
+
: -1}
|
|
127
|
+
disabled={locked}
|
|
128
|
+
onClick={() => {
|
|
129
|
+
const selectedOptions = question.multiSelect
|
|
130
|
+
? selected
|
|
131
|
+
? answer.selected.filter((label) => label !== option.label)
|
|
132
|
+
: [...answer.selected, option.label]
|
|
133
|
+
: [option.label]
|
|
134
|
+
changeAt(questionIndex, { ...answer, selected: selectedOptions })
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{option.label}
|
|
138
|
+
</Button>
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return option.description ? (
|
|
142
|
+
<Tooltip key={option.label}>
|
|
143
|
+
<TooltipTrigger asChild>{optionButton}</TooltipTrigger>
|
|
144
|
+
<TooltipContent>{option.description}</TooltipContent>
|
|
145
|
+
</Tooltip>
|
|
146
|
+
) : (
|
|
147
|
+
<React.Fragment key={option.label}>{optionButton}</React.Fragment>
|
|
148
|
+
)
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
<Input
|
|
152
|
+
value={answer.text ?? ''}
|
|
153
|
+
onChange={(event) => changeAt(questionIndex, { ...answer, text: event.target.value })}
|
|
154
|
+
placeholder="Outro (opcional)…"
|
|
155
|
+
aria-label={`Outra resposta para: ${question.question}`}
|
|
156
|
+
aria-invalid={invalid || undefined}
|
|
157
|
+
aria-describedby={invalid ? errorId : undefined}
|
|
158
|
+
disabled={locked}
|
|
159
|
+
/>
|
|
160
|
+
{invalid && (
|
|
161
|
+
<p id={errorId} className="text-sm text-destructive">
|
|
162
|
+
Escolha uma opção ou escreva uma resposta.
|
|
163
|
+
</p>
|
|
164
|
+
)}
|
|
165
|
+
</fieldset>
|
|
166
|
+
)
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
</TooltipProvider>
|
|
170
|
+
{!validCount && (
|
|
171
|
+
<p role="alert" className="mt-3 text-sm text-destructive">
|
|
172
|
+
Ask aceita de 1 a 4 perguntas.
|
|
173
|
+
</p>
|
|
174
|
+
)}
|
|
175
|
+
<div aria-live="polite" className="sr-only">
|
|
176
|
+
{submitted && !allAnswered ? 'Há perguntas sem resposta.' : ''}
|
|
177
|
+
</div>
|
|
178
|
+
<div className="mt-4 flex justify-end">
|
|
179
|
+
<Button type="submit" size="sm" busy={busy} disabled={disabled || !validCount}>
|
|
180
|
+
Responder
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
</form>
|
|
184
|
+
</Card>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Elicitação estruturada controlada baseada diretamente em `AskQuestion` e `AskAnswer`. O componente só apresenta e valida respostas; transporte, persistência e continuação do agente pertencem ao consumidor.
|
|
2
|
+
|
|
3
|
+
```tsx preview
|
|
4
|
+
const [answers, setAnswers] = React.useState([])
|
|
5
|
+
|
|
6
|
+
render(
|
|
7
|
+
<Ask
|
|
8
|
+
questions={[
|
|
9
|
+
{
|
|
10
|
+
question: 'Qual ambiente devemos usar?',
|
|
11
|
+
header: 'Ambiente',
|
|
12
|
+
options: [
|
|
13
|
+
{ label: 'Produção', description: 'Usa dados e serviços reais.' },
|
|
14
|
+
{ label: 'Homologação', description: 'Usa o ambiente de validação.' },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
question: 'Quais verificações devo executar?',
|
|
19
|
+
header: 'Verificações',
|
|
20
|
+
multiSelect: true,
|
|
21
|
+
options: [{ label: 'Testes' }, { label: 'Typecheck' }, { label: 'Build' }],
|
|
22
|
+
},
|
|
23
|
+
]}
|
|
24
|
+
answers={answers}
|
|
25
|
+
onChange={setAnswers}
|
|
26
|
+
onSubmit={(next) => console.log(next)}
|
|
27
|
+
/>,
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Cada pergunta exige pelo menos uma opção válida selecionada **ou** texto livre não vazio. `busy` mostra o progresso no botão e trava os controles; `disabled` apenas trava a interação. A ordem de `answers` acompanha a ordem de `questions`, e o `header` de cada resposta é derivado de `question.header` (ou do próprio enunciado quando o header estiver vazio).
|
|
@@ -31,6 +31,8 @@ interface AuditRecord {
|
|
|
31
31
|
output?: unknown
|
|
32
32
|
error?: ActionError
|
|
33
33
|
severity: 'info' | 'warning' | 'error'
|
|
34
|
+
trace?: { requestId?: string; parentActionId?: string } // legado
|
|
35
|
+
traceContext?: TraceContext // vendor-neutral
|
|
34
36
|
meta?: Record<string, unknown>
|
|
35
37
|
}
|
|
36
38
|
```
|
|
@@ -48,6 +50,21 @@ const dev = consoleAudit()
|
|
|
48
50
|
const prod = pgAudit({ pool, table: 'audit_log' })
|
|
49
51
|
```
|
|
50
52
|
|
|
53
|
+
O default continua compatível com a tabela legada. Para persistir o contexto novo, primeiro
|
|
54
|
+
adicione colunas próprias e depois habilite o opt-in — não reutilize as colunas legadas:
|
|
55
|
+
|
|
56
|
+
```sql
|
|
57
|
+
ALTER TABLE audit_log ADD COLUMN trace_id text;
|
|
58
|
+
ALTER TABLE audit_log ADD COLUMN span_id text;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const prod = pgAudit({ pool, traceContextColumns: true })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Rollouts com nomes existentes podem usar
|
|
66
|
+
`traceContextColumns: { traceId: 'otel_trace_id', spanId: 'otel_span_id' }`.
|
|
67
|
+
|
|
51
68
|
## Dado sensível: o redator global
|
|
52
69
|
|
|
53
70
|
Sem redator, os sinks persistem input e output **crus** — `user.create`/`setPassword`
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Observabilidade
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Observabilidade
|
|
6
|
+
|
|
7
|
+
O core expõe uma porta vendor-neutral; o driver OpenTelemetry cria spans ativos para actions
|
|
8
|
+
e reactions sem escolher backend, exporter ou Collector.
|
|
9
|
+
|
|
10
|
+
## Driver OpenTelemetry
|
|
11
|
+
|
|
12
|
+
Configure o SDK antes de criar o runtime. Exemplo com OTLP/HTTP apontando para um Collector:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { NodeSDK } from '@opentelemetry/sdk-node'
|
|
16
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
|
|
17
|
+
import { openTelemetryObservability } from '@softize/opus/observability/opentelemetry'
|
|
18
|
+
import { createRuntime } from '@softize/opus/core'
|
|
19
|
+
|
|
20
|
+
const sdk = new NodeSDK({
|
|
21
|
+
traceExporter: new OTLPTraceExporter({
|
|
22
|
+
url: 'http://otel-collector:4318/v1/traces',
|
|
23
|
+
}),
|
|
24
|
+
})
|
|
25
|
+
await sdk.start()
|
|
26
|
+
|
|
27
|
+
const runtime = createRuntime({
|
|
28
|
+
observability: openTelemetryObservability({
|
|
29
|
+
shutdown: () => sdk.shutdown(),
|
|
30
|
+
}),
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
O app é dono do `NodeSDK`, sampling, resource (`service.name`), exporter, autenticação e
|
|
35
|
+
retry. A opção `shutdown` é explícita: sem ela, `runtime.dispose()` não desliga o provider
|
|
36
|
+
global. Um `Tracer` também pode ser injetado para testes ou providers não globais.
|
|
37
|
+
|
|
38
|
+
## Semântica
|
|
39
|
+
|
|
40
|
+
- nomes de span: `opus.action <action>` e `opus.reaction <reaction>`;
|
|
41
|
+
- parent vem de `ContextBase.trace` ou do evento recebido;
|
|
42
|
+
- actions usam `ActionResult.ok` para status; reactions usam resolve/reject;
|
|
43
|
+
- somente atributos allowlisted de baixa cardinalidade são anexados;
|
|
44
|
+
- input, output, actor, tenant, request ID, error code/message e baggage não entram no span;
|
|
45
|
+
- `TraceContext` devolve apenas `traceId`, `spanId`, `traceFlags` e `traceState`.
|
|
46
|
+
|
|
47
|
+
## Fronteira HTTP W3C
|
|
48
|
+
|
|
49
|
+
Os drivers Fastify e Node oferecem propagação explícita, desativada por default:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
fastifyServer({ app, traceContext: 'w3c' })
|
|
53
|
+
nodeServer({ traceContext: 'w3c' })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
O opt-in extrai um `traceparent` v00 válido (e `tracestate` limitado) para
|
|
57
|
+
`ContextBase.trace`. Depois da action, injeta na resposta o `traceparent` do contexto
|
|
58
|
+
efetivo retornado pelo runtime. `baggage` nunca é lido nem copiado. Headers inválidos são
|
|
59
|
+
ignorados; sem a opção, os drivers mantêm o comportamento anterior e não interpretam nem
|
|
60
|
+
emitem contexto de trace.
|
|
61
|
+
|
|
62
|
+
## Limites
|
|
63
|
+
|
|
64
|
+
O driver OpenTelemetry não instrumenta HTTP automaticamente e não oferece backend APM.
|
|
65
|
+
Use o opt-in dos drivers Fastify/Node acima ou forneça `ContextBase.trace` na instrumentação
|
|
66
|
+
de outro transporte. A fronteira implementa somente `traceparent` v00 e uma validação
|
|
67
|
+
defensiva limitada de `tracestate`; não é um propagator OTel genérico.
|
|
68
|
+
Health do exporter/Collector só é reportado quando o app injeta `healthCheck`; o default
|
|
69
|
+
confirma apenas que a API do driver está configurada.
|
|
70
|
+
Sem um provider que produza spans válidos, o driver falha antes do callback e o runtime aplica
|
|
71
|
+
a degradação lenient — executa a action uma vez, sem trace — em vez de propagar IDs inválidos.
|
|
@@ -25,11 +25,14 @@ interface JobHandle<T = unknown> {
|
|
|
25
25
|
data?: T
|
|
26
26
|
error?: ActionError
|
|
27
27
|
attempts: number
|
|
28
|
+
trace?: TraceContext
|
|
28
29
|
}
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
`runtime.execute()` valida a chamada, cria o `JobSpec` e inclui o trace efetivo, request ID e
|
|
33
|
+
provenance original. O adapter persiste o envelope; o handle também expõe esse trace. No
|
|
34
|
+
worker, `runtime.executeJob()` revalida o envelope e executa um span filho, preservando a
|
|
35
|
+
cadeia request → enqueue → job sem aceitar baggage ou campos extras no carrier.
|
|
33
36
|
|
|
34
37
|
## Driver
|
|
35
38
|
|
|
@@ -52,10 +55,33 @@ const runtime = createRuntime({
|
|
|
52
55
|
})
|
|
53
56
|
```
|
|
54
57
|
|
|
55
|
-
A action declara `background: true` (+ retry/priority no contrato); chamá-la devolve um
|
|
58
|
+
A action declara `background: { enabled: true }` (+ retry/priority no contrato); chamá-la devolve um
|
|
56
59
|
`JobHandle` em vez do resultado, e o cliente pola `status(jobId)` (ou `subscribe`) até
|
|
57
60
|
`done`/`failed`.
|
|
58
61
|
|
|
62
|
+
## Worker BullMQ
|
|
63
|
+
|
|
64
|
+
O app continua dono do `Worker` e da reidratação de autenticação. O processor chama
|
|
65
|
+
`executeJob()` — nunca `execute()`, que criaria outro job:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
new Worker('opus', async (job) => {
|
|
69
|
+
const spec = job.data as JobSpec
|
|
70
|
+
const ctx = await authContextForJob(spec.ctx)
|
|
71
|
+
const result = await runtime.executeJob(spec, ctx, {
|
|
72
|
+
report: (progress) => job.updateProgress(progress),
|
|
73
|
+
})
|
|
74
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
75
|
+
return result.data
|
|
76
|
+
}, { connection })
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
O runtime confirma que o actor reidratado corresponde a `spec.ctx.userId`; tenant, request,
|
|
80
|
+
trace e provenance vêm do envelope. O driver mapeia `attempts`, backoff nativo e prioridade.
|
|
81
|
+
Multiplicador exponencial diferente de 2 ou `maxMs` exige `backoffStrategy` custom do worker e
|
|
82
|
+
é rejeitado pelo driver em vez de ser silenciosamente ignorado. `timeout` permanece no
|
|
83
|
+
envelope para o worker aplicar, pois não é uma opção de execução do `Queue.add`.
|
|
84
|
+
|
|
59
85
|
## Limites (por enquanto)
|
|
60
86
|
|
|
61
87
|
Driver hoje: `bullmq` (Redis). In-memory pra dev e outros backends (SQS, pg-boss…) entram
|
|
@@ -22,6 +22,7 @@ const runtime = createRuntime({
|
|
|
22
22
|
data: kyselyData({ db }), // fornece ctx.db
|
|
23
23
|
auth: makeAuthAdapter(), // resolve user/tenant/can
|
|
24
24
|
audit: [pgAudit({ pool })], // trilha de auditoria (N sinks)
|
|
25
|
+
observability: makeObservability(), // span ativo + TraceContext vendor-neutral
|
|
25
26
|
storage: fsStorage({ root }), // fornece ctx.storage (experimental)
|
|
26
27
|
config: { env, i18n: { defaultLocale: 'pt-BR' } },
|
|
27
28
|
})
|
|
@@ -42,6 +43,7 @@ await runtime.start()
|
|
|
42
43
|
| `auth` | `user`/`tenantId`/`can` do contexto | `jwtAuth` — `…/auth/jwt` · `betterAuthSession` — `…/auth/better-auth` |
|
|
43
44
|
| `audit` | trilha por execução de action | `pgAudit` — `…/audit/pg` · `consoleAudit` — `…/audit/console` |
|
|
44
45
|
| `log` | `ctx.log` estruturado | `pinoLogger` — `@softize/opus/log/pino` |
|
|
46
|
+
| `observability` | span ativo + propagação de trace | porta no core; driver opt-in |
|
|
45
47
|
| `eventBus` | `ctx.emit` + **reactions** | `mittEvents` — `@softize/opus/events/mitt` |
|
|
46
48
|
| `queue` | jobs em background | `bullmqQueue` — `@softize/opus/queue/bullmq` |
|
|
47
49
|
| `scheduler` | **schedules** (cron/intervalo) | `nodeCronScheduler` — `@softize/opus/scheduler/node-cron` |
|
|
@@ -60,7 +62,22 @@ Fora do runtime, mas parte do protocolo: o harness de teste (`runAction`/`testCo
|
|
|
60
62
|
|
|
61
63
|
`user`/`tenantId`/`can` (auth) · `db` (data) · `log` (logger) · `emit` (eventBus) ·
|
|
62
64
|
`storage` (storage) · `ai` (ai) · `provenance` (quem disparou: http, schedule,
|
|
63
|
-
reaction…) · `meta`.
|
|
65
|
+
reaction…) · `trace` (quando configurado) · `meta`.
|
|
66
|
+
|
|
67
|
+
O core não depende de OpenTelemetry. O `ObservabilityAdapter` envolve actions e reactions
|
|
68
|
+
com `runInSpan`; o `TraceContext` efetivo também segue para resultado, audit e eventos. Os
|
|
69
|
+
envelopes de job recebem o trace da execução que enfileirou; `executeJob()` cria a execução
|
|
70
|
+
filha no worker sem reenfileirar a action.
|
|
71
|
+
Falha de instrumentação é lenient e nunca repete o handler.
|
|
72
|
+
|
|
73
|
+
O core não extrai headers HTTP. Os drivers Fastify e Node podem fornecer
|
|
74
|
+
`ContextBase.trace` com `traceContext: 'w3c'`; o opt-in é desativado por default. Outros
|
|
75
|
+
transportes continuam responsáveis por sua fronteira. Em audit, o contexto novo usa
|
|
76
|
+
`traceContext`; o campo `trace` legado continua disponível sem mudança.
|
|
77
|
+
|
|
78
|
+
Para actions, o callback observável resolve com `ActionResult`: `resultKind: 'action-result'`
|
|
79
|
+
indica que o driver deve inspecionar `result.ok`. Uma Promise resolvida com `ok:false` ainda
|
|
80
|
+
representa span de erro. Reactions usam `resultKind: 'void'` e rejeitam em falha.
|
|
64
81
|
|
|
65
82
|
## Reactions e schedules
|
|
66
83
|
|
package/src/ui/docs/registry.tsx
CHANGED
|
@@ -56,6 +56,7 @@ import buttonGroupMd from './content/button-group.md?raw'
|
|
|
56
56
|
import calendarMd from './content/calendar.md?raw'
|
|
57
57
|
import cardMd from './content/card.md?raw'
|
|
58
58
|
import carouselMd from './content/carousel.md?raw'
|
|
59
|
+
import askMd from './content/ask.md?raw'
|
|
59
60
|
import chatMd from './content/chat.md?raw'
|
|
60
61
|
import checkboxMd from './content/checkbox.md?raw'
|
|
61
62
|
import collapsibleMd from './content/collapsible.md?raw'
|
|
@@ -316,6 +317,7 @@ export const UI_SECTIONS: DocSection[] = [
|
|
|
316
317
|
{
|
|
317
318
|
label: 'IA',
|
|
318
319
|
pages: [
|
|
320
|
+
{ slug: 'ask', title: 'Ask', render: comp('Ask', 'ask', askMd) },
|
|
319
321
|
{ slug: 'chat', title: 'Chat', render: comp('Chat', 'chat', chatMd) },
|
|
320
322
|
{ slug: 'composer', title: 'Composer', render: comp('Composer', 'composer', composerMd) },
|
|
321
323
|
],
|
package/src/ui/meta.ts
CHANGED
|
@@ -11,6 +11,12 @@ import type { ComponentMeta } from './index.ts'
|
|
|
11
11
|
|
|
12
12
|
/** Todos os metas, chaveados pelo nome canônico (kebab). Fonte única — zero duplicação. */
|
|
13
13
|
export const componentMeta = {
|
|
14
|
+
'ask': {
|
|
15
|
+
name: 'ask',
|
|
16
|
+
ancestry: 'opus',
|
|
17
|
+
whenToUse:
|
|
18
|
+
'Elicitação estruturada controlada para 1–4 perguntas `AskQuestion`: opções single/multi em pills, texto livre opcional, validação e submit de `AskAnswer[]`. Não faz transporte, persistência nem integração automática com ChatEvent; o consumidor controla `answers`/`onChange` e conecta `onSubmit` ao canal apropriado.',
|
|
19
|
+
},
|
|
14
20
|
'alert': {
|
|
15
21
|
name: 'alert',
|
|
16
22
|
ancestry: 'opus',
|
package/src/ui/react.tsx
CHANGED
|
@@ -19,6 +19,8 @@ export { Input } from './components/primitives/input.tsx'
|
|
|
19
19
|
|
|
20
20
|
export { Chat } from './components/primitives/chat.tsx'
|
|
21
21
|
export type { ChatProps, ChatMessage, ChatArtifact, ChatTranscriptItem } from './components/primitives/chat.tsx'
|
|
22
|
+
export { Ask } from './components/primitives/ask.tsx'
|
|
23
|
+
export type { AskProps } from './components/primitives/ask.tsx'
|
|
22
24
|
export { Composer } from './components/primitives/composer.tsx'
|
|
23
25
|
export type { ComposerProps } from './components/primitives/composer.tsx'
|
|
24
26
|
export { Copyable } from './components/primitives/copyable.tsx'
|