@softize/opus 22.0.0 → 22.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/bin/cli.mjs +18 -1
- package/bin/lib/check.mjs +28 -2
- package/bin/lib/gen-manifest.mjs +2 -0
- package/bin/lib/gen-runner.mjs +5 -0
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
- package/docs/adr/0041-actions-declare-effect-and-personal-data.md +139 -0
- package/docs/protocol.md +15 -2
- package/package.json +5 -1
- package/registry/skills/create-opus-action/SKILL.md +7 -2
- package/registry/skills/create-opus-action/references/contract-and-binding.md +11 -4
- package/registry/skills/create-opus-action/references/evaluations.md +3 -0
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +6 -4
- package/registry/skills/test-opus-action/SKILL.md +2 -2
- package/registry/skills/test-opus-action/references/harness.md +2 -2
- package/registry/templates/app/src/App.tsx +1 -1
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
- package/registry/templates/app/src/domains/tasks/actions/list.ts +5 -5
- package/registry/templates/app/vite.config.ts +3 -2
- package/src/core/action-nature.ts +40 -0
- package/src/core/audit.ts +12 -4
- package/src/core/domain.ts +2 -2
- package/src/core/index.ts +2 -0
- package/src/core/runtime.ts +56 -1
- package/src/core/types.ts +46 -20
- package/src/design/index.ts +179 -0
- package/src/mcp/index.ts +6 -0
- package/src/testing/fake.ts +2 -2
- package/src/testing/index.ts +7 -3
- package/src/ui/docs/content/actions.md +79 -44
- package/src/ui/docs/content/mcp.md +6 -0
- package/src/ui/docs/content/testing.md +9 -25
- package/src/vite/design.ts +133 -23
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
- `memStorage()`: adapter de storage em memória com inspeção de chaves.
|
|
7
7
|
- `fake` e `fakeMany`: dados determinísticos derivados de schema.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
`
|
|
9
|
+
Teste o binding completo. Um contrato sem handler falha com `testing.no_handler`: os dados do modo
|
|
10
|
+
design são gerados pelo plugin `opusDesign()` e não são comportamento da action.
|
|
@@ -9,7 +9,7 @@ type TaskItem = z.infer<typeof Task>
|
|
|
9
9
|
export function App(): React.ReactElement {
|
|
10
10
|
// O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
|
|
11
11
|
// serve o backend no próprio dev server: dev normal executa o handler real;
|
|
12
|
-
// `pnpm dev:design` responde com
|
|
12
|
+
// `pnpm dev:design` responde com dado gerado a partir do contrato, isolado de qualquer backend.
|
|
13
13
|
const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
|
|
14
14
|
|
|
15
15
|
return (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { runAction } from '@softize/opus/testing'
|
|
3
|
-
import {
|
|
3
|
+
import { taskListImpl } from './list.ts'
|
|
4
4
|
|
|
5
5
|
// O harness roda o MESMO pipeline do runtime (valida input, cobra auth, valida
|
|
6
6
|
// output) — teste pelo contrato, não chamando o handler à mão.
|
|
@@ -14,21 +14,4 @@ describe('task.list', () => {
|
|
|
14
14
|
it('sem usuário, barra: a action não é pública', async () => {
|
|
15
15
|
await expect(runAction(taskListImpl, {})).rejects.toMatchObject({ code: 'auth.unauthenticated' })
|
|
16
16
|
})
|
|
17
|
-
|
|
18
|
-
describe('modo design (o que o `pnpm dev:design` serve)', () => {
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
// OPUS_MODE é global do processo — limpa pra não vazar entre testes.
|
|
21
|
-
delete process.env['OPUS_MODE']
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('o contrato puro responde pelo mockHandler, com dado válido pro schema', async () => {
|
|
25
|
-
process.env['OPUS_MODE'] = 'design'
|
|
26
|
-
const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
|
|
27
|
-
expect(output.items.length).toBeGreaterThan(0)
|
|
28
|
-
// `list` pula a validação de output no pipeline — afirme item a item.
|
|
29
|
-
for (const item of output.items) {
|
|
30
|
-
expect(Task.safeParse(item).success).toBe(true)
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
17
|
})
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { bindAction, defineContract } from '@softize/opus/core'
|
|
3
|
-
import { fakeMany } from '@softize/opus/testing'
|
|
4
3
|
|
|
5
|
-
/** Item do domínio-exemplo — o schema alimenta contrato,
|
|
4
|
+
/** Item do domínio-exemplo — o schema alimenta contrato, dados de design e testes. */
|
|
6
5
|
export const Task = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
|
|
7
6
|
|
|
8
7
|
// O split canônico contrato↔handler: o CONTRATO é declarativo e roda nos dois
|
|
9
8
|
// lados (a SPA consome via useListAction); o BINDING é server-only. Em
|
|
10
|
-
// `pnpm dev:design` o
|
|
11
|
-
// no dev normal e em prod, o handler real do binding assume.
|
|
9
|
+
// `pnpm dev:design` o plugin responde com dado gerado a partir do `output`
|
|
10
|
+
// (ADR 0037); no dev normal e em prod, o handler real do binding assume.
|
|
12
11
|
export const taskList = defineContract({
|
|
13
12
|
name: 'task.list',
|
|
14
13
|
kind: 'list',
|
|
@@ -18,8 +17,9 @@ export const taskList = defineContract({
|
|
|
18
17
|
'canônico de uma action list — contrato separado do handler, paginação por cursor.',
|
|
19
18
|
input: z.object({}),
|
|
20
19
|
output: Task,
|
|
20
|
+
// Leitura pelo GET (ADR 0041): list não declara `effect`. Tarefas não identificam pessoas.
|
|
21
|
+
personalData: false,
|
|
21
22
|
paginate: 'cursor',
|
|
22
|
-
mockHandler: async () => ({ items: fakeMany(Task, 5), cursor: { next: null }, total: 5 }),
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
// Fixture em memória — o domínio-exemplo não tem banco; troque pelo seu repositório.
|
|
@@ -5,11 +5,12 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Modo design (`vite --mode design`, script dev:design): o runtime Opus sobe DENTRO do
|
|
8
|
-
* dev server e
|
|
8
|
+
* dev server e as actions respondem o `/api` com dado gerado a partir do `output` do contrato
|
|
9
|
+
* (ADR 0037; cenários específicos entram pela opção `scenarios`) — preview autossuficiente,
|
|
9
10
|
* isolado de prod (o plugin desliga o `server.proxy` sozinho; prefixo ex-proxy sem
|
|
10
11
|
* cobertura responde 503, nunca vaza pra porta real). Quando o app ganhar um backend real,
|
|
11
12
|
* o `dev` normal proxeia pra ele (adicione `server.proxy`); o preview de design segue nos
|
|
12
|
-
*
|
|
13
|
+
* dados de design. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
|
|
13
14
|
*
|
|
14
15
|
* Import DINÂMICO NO TOP-LEVEL, de propósito, e em duas condições que andam juntas: (1) o
|
|
15
16
|
* @softize/opus embarca TS source e o config loader padrão (esbuild bundle — o que build e
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A natureza declarada de uma action (ADR 0041): o efeito sobre o estado e a exposição de dado
|
|
3
|
+
* pessoal. Funções puras, compartilhadas pelo runtime (registro, auditoria e ferramentas de IA) e
|
|
4
|
+
* por quem projeta o contrato.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ActionEffect, ActionKind } from './types.ts'
|
|
8
|
+
|
|
9
|
+
/** `list` e `view` são servidas por `GET`: leitura pelo protocolo, sem declaração. */
|
|
10
|
+
const READ_BY_PROTOCOL: ReadonlySet<ActionKind> = new Set(['list', 'view'])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* O efeito da action, com `list` e `view` resolvidos como leitura. `undefined` quando uma
|
|
14
|
+
* `simple` ou `form` não declarou: quem consome decide o que fazer com a ausência.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveActionEffect(action: { kind: ActionKind; effect?: ActionEffect }): ActionEffect | undefined {
|
|
17
|
+
return READ_BY_PROTOCOL.has(action.kind) ? 'read' : action.effect
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A declaração contraditória que o registro recusa: `write` numa action servida por `GET`. */
|
|
21
|
+
export function hasInvalidEffect(action: { kind: ActionKind; effect?: ActionEffect }): boolean {
|
|
22
|
+
return READ_BY_PROTOCOL.has(action.kind) && action.effect === 'write'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Quantos registros a saída devolveu: os itens da página de uma `list`, os elementos de um array,
|
|
27
|
+
* 1 para um objeto e 0 para ausência. `null` quando a forma não permite contar. Só a `list` lê
|
|
28
|
+
* `items`: numa `view`, `items` é parte do registro (as linhas de um pedido, os contatos de um
|
|
29
|
+
* cliente), e o registro devolvido continua sendo um.
|
|
30
|
+
*/
|
|
31
|
+
export function countReturnedRecords(kind: ActionKind, output: unknown): number | null {
|
|
32
|
+
if (output === null || output === undefined) return 0
|
|
33
|
+
if (Array.isArray(output)) return output.length
|
|
34
|
+
if (typeof output === 'object') {
|
|
35
|
+
const items = (output as { items?: unknown }).items
|
|
36
|
+
if (kind === 'list' && Array.isArray(items)) return items.length
|
|
37
|
+
return 1
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
package/src/core/audit.ts
CHANGED
|
@@ -41,7 +41,8 @@ export class AuditEmitter {
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Emite um record pros sinks apropriados.
|
|
44
|
-
* - `config === false` → no-op (audit desabilitado pra esta action)
|
|
44
|
+
* - `config === false` → no-op (audit desabilitado pra esta action), exceto quando o record
|
|
45
|
+
* marca exposição de dado pessoal: aí sai um registro mínimo, sem input nem output (ADR 0041).
|
|
45
46
|
* - `config === true | undefined` → record bruto, todos os sinks.
|
|
46
47
|
* - `config: AuditConfig` → aplica redact/fields/severity/sink filter.
|
|
47
48
|
*
|
|
@@ -53,11 +54,18 @@ export class AuditEmitter {
|
|
|
53
54
|
record: AuditRecord,
|
|
54
55
|
config?: boolean | AuditConfig,
|
|
55
56
|
): Promise<void> {
|
|
56
|
-
if (config === false)
|
|
57
|
+
if (config === false) {
|
|
58
|
+
// ADR 0041: a exposição de dado pessoal é registrada mesmo com o audit desligado. O registro
|
|
59
|
+
// mínimo não leva entrada nem saída, só quem executou, a action e a contagem.
|
|
60
|
+
if (record.personalData === undefined) return
|
|
61
|
+
const { output: _output, ...rest } = record
|
|
62
|
+
return this.dispatch({ ...rest, input: null, meta: { ...record.meta, exposureOnly: true } }, this.sinks)
|
|
63
|
+
}
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
return this.dispatch(this.applyConfig(record, config), this.filterSinks(config))
|
|
66
|
+
}
|
|
60
67
|
|
|
68
|
+
private async dispatch(processed: AuditRecord, targetSinks: AuditSink[]): Promise<void> {
|
|
61
69
|
if (targetSinks.length === 0) return
|
|
62
70
|
|
|
63
71
|
const results = await Promise.allSettled(
|
package/src/core/domain.ts
CHANGED
|
@@ -36,7 +36,7 @@ import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
|
|
|
36
36
|
* Um registrável de action num domínio: BOUND (`ActionDef`, com handler) ou
|
|
37
37
|
* contrato PURO (`ActionContract`, sem handler). Um domínio de design — a
|
|
38
38
|
* fatia isomorfa (ADR 0004) — registra só contratos, e em modo design o
|
|
39
|
-
* `
|
|
39
|
+
* plugin `opusDesign()` responde por eles (ADR 0037). Fora de design, contrato sem handler falha no
|
|
40
40
|
* `execute` (comportamento INTENCIONAL); o tipo só deixa de exigir o cast.
|
|
41
41
|
*/
|
|
42
42
|
export type DomainAction = ActionDef | ActionContract
|
|
@@ -205,7 +205,7 @@ function* iterateActions(
|
|
|
205
205
|
): IterableIterator<ActionDef> {
|
|
206
206
|
if (actions === undefined) return
|
|
207
207
|
// Um contrato puro (`ActionContract`, sem handler) entra como `ActionDef` no
|
|
208
|
-
// runtime — em design o `
|
|
208
|
+
// runtime — em design o plugin `opusDesign()` responde; fora de design, a ausência de
|
|
209
209
|
// handler falha no `execute` (intencional). O cast reconcilia o tipo neste
|
|
210
210
|
// boundary de normalização; ver `DomainAction`.
|
|
211
211
|
if (Array.isArray(actions)) {
|
package/src/core/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ export type {
|
|
|
67
67
|
ExpandSpec,
|
|
68
68
|
// action union + variants
|
|
69
69
|
ActionKind,
|
|
70
|
+
ActionEffect,
|
|
70
71
|
ActionBase,
|
|
71
72
|
SimpleAction,
|
|
72
73
|
FormAction,
|
|
@@ -222,3 +223,4 @@ export type {
|
|
|
222
223
|
RuntimeSetupConfig,
|
|
223
224
|
ResolvedRuntimeConfig,
|
|
224
225
|
} from './runtime.ts'
|
|
226
|
+
export { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
|
package/src/core/runtime.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { collectDomainWarnings, flattenDomain, isDomainConfig } from './domain.t
|
|
|
18
18
|
import type { DomainConfig } from './domain.ts'
|
|
19
19
|
import type { DataProductConfig } from './data-product.ts'
|
|
20
20
|
import { AuditEmitter } from './audit.ts'
|
|
21
|
+
import { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
|
|
21
22
|
import { error, isActionError, normalizeError } from './errors.ts'
|
|
22
23
|
import { normalizeTraceContext } from './trace.ts'
|
|
23
24
|
import { evalExpression } from '../dsl/eval.ts'
|
|
@@ -463,6 +464,15 @@ export class Runtime {
|
|
|
463
464
|
message: `Action "${action.name}" registrada duas vezes`,
|
|
464
465
|
})
|
|
465
466
|
}
|
|
467
|
+
if (hasInvalidEffect(action)) {
|
|
468
|
+
throw error({
|
|
469
|
+
code: 'runtime.invalid_action_effect',
|
|
470
|
+
category: 'internal',
|
|
471
|
+
message:
|
|
472
|
+
`A action "${action.name}" é ${action.kind}, servida por GET, e não pode declarar effect: 'write'. ` +
|
|
473
|
+
'Use simple ou form para escrever (ADR 0041).',
|
|
474
|
+
})
|
|
475
|
+
}
|
|
466
476
|
this.actions.set(action.name, action)
|
|
467
477
|
if (this.config.dev) this.warnIgnoredExecutionFields(action)
|
|
468
478
|
}
|
|
@@ -727,7 +737,11 @@ export class Runtime {
|
|
|
727
737
|
kind: 'action',
|
|
728
738
|
resultKind: 'action-result',
|
|
729
739
|
...(ctxBase.trace !== undefined ? { parent: ctxBase.trace } : {}),
|
|
730
|
-
attributes: {
|
|
740
|
+
attributes: {
|
|
741
|
+
'opus.action.id': actionId,
|
|
742
|
+
'opus.action.kind': action.kind,
|
|
743
|
+
...natureAttributes(action),
|
|
744
|
+
},
|
|
731
745
|
},
|
|
732
746
|
ctxBase.trace,
|
|
733
747
|
async (trace) => {
|
|
@@ -803,6 +817,8 @@ export class Runtime {
|
|
|
803
817
|
actionId,
|
|
804
818
|
action: action.name,
|
|
805
819
|
actionKind: action.kind,
|
|
820
|
+
// Enfileirar devolve o handle do job, não os dados: a exposição é marcada na execução.
|
|
821
|
+
nature: { kind: action.kind, ...(action.effect !== undefined ? { effect: action.effect } : {}) },
|
|
806
822
|
outcome: 'success',
|
|
807
823
|
durationMs,
|
|
808
824
|
ctx,
|
|
@@ -852,6 +868,7 @@ export class Runtime {
|
|
|
852
868
|
actionId,
|
|
853
869
|
action: action.name,
|
|
854
870
|
actionKind: action.kind,
|
|
871
|
+
nature: action,
|
|
855
872
|
outcome: 'success',
|
|
856
873
|
durationMs,
|
|
857
874
|
ctx,
|
|
@@ -884,6 +901,7 @@ export class Runtime {
|
|
|
884
901
|
actionId,
|
|
885
902
|
action: action.name,
|
|
886
903
|
actionKind: action.kind,
|
|
904
|
+
nature: action,
|
|
887
905
|
outcome: 'error',
|
|
888
906
|
durationMs,
|
|
889
907
|
ctx,
|
|
@@ -932,6 +950,7 @@ export class Runtime {
|
|
|
932
950
|
const cfg = normalizeAiConfig(action.ai)
|
|
933
951
|
if (cfg === null) continue
|
|
934
952
|
const dataProducts = [...(this.actionDataProducts.get(action.name) ?? [])].sort()
|
|
953
|
+
const effect = resolveActionEffect(action)
|
|
935
954
|
tools.push({
|
|
936
955
|
name: action.name,
|
|
937
956
|
...(action.label !== undefined
|
|
@@ -939,7 +958,23 @@ export class Runtime {
|
|
|
939
958
|
: {}),
|
|
940
959
|
description: cfg.description ?? action.description ?? action.name,
|
|
941
960
|
inputSchema: action.input,
|
|
961
|
+
// No MCP, `destructiveHint` ausente numa escrita vale `true`. Numa escrita declarada, o valor
|
|
962
|
+
// vai explícito, para o cliente não tratar como destrutiva o que o Opus não trata.
|
|
963
|
+
...(effect !== undefined || cfg.destructive === true
|
|
964
|
+
? {
|
|
965
|
+
annotations: {
|
|
966
|
+
...(effect !== undefined ? { readOnlyHint: effect === 'read' } : {}),
|
|
967
|
+
...(effect === 'write'
|
|
968
|
+
? { destructiveHint: cfg.destructive === true }
|
|
969
|
+
: cfg.destructive === true && effect === undefined
|
|
970
|
+
? { destructiveHint: true }
|
|
971
|
+
: {}),
|
|
972
|
+
},
|
|
973
|
+
}
|
|
974
|
+
: {}),
|
|
942
975
|
metadata: {
|
|
976
|
+
...(effect !== undefined ? { effect } : {}),
|
|
977
|
+
...(action.personalData !== undefined ? { personalData: action.personalData } : {}),
|
|
943
978
|
dataProducts,
|
|
944
979
|
dataProductLabels: Object.fromEntries(
|
|
945
980
|
dataProducts.map((id) => [id, this.dataProducts.get(id)?.label ?? id]),
|
|
@@ -1228,6 +1263,8 @@ export class Runtime {
|
|
|
1228
1263
|
actionId: string
|
|
1229
1264
|
action: string
|
|
1230
1265
|
actionKind?: ActionDef['kind']
|
|
1266
|
+
/** A natureza declarada (ADR 0041): efeito e dado pessoal viajam para o registro. */
|
|
1267
|
+
nature?: Pick<ActionDef, 'kind' | 'effect' | 'personalData'>
|
|
1231
1268
|
outcome: 'success' | 'error'
|
|
1232
1269
|
durationMs: number
|
|
1233
1270
|
ctx: ActionContext
|
|
@@ -1253,6 +1290,15 @@ export class Runtime {
|
|
|
1253
1290
|
}
|
|
1254
1291
|
if (args.output !== undefined) record.output = args.output
|
|
1255
1292
|
if (args.error !== undefined) record.error = args.error
|
|
1293
|
+
if (args.nature !== undefined) {
|
|
1294
|
+
const effect = resolveActionEffect(args.nature)
|
|
1295
|
+
if (effect !== undefined) record.effect = effect
|
|
1296
|
+
// A exposição só existe quando a action devolveu algo: erro não expõe o dado declarado, e a
|
|
1297
|
+
// resposta simulada do modo design (mock, cenário ou gerada) não é de ninguém.
|
|
1298
|
+
if (args.nature.personalData === true && args.outcome === 'success' && args.mode !== 'design') {
|
|
1299
|
+
record.personalData = { records: countReturnedRecords(args.nature.kind, args.output) }
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1256
1302
|
if (args.mode !== undefined) record.meta = { mode: args.mode }
|
|
1257
1303
|
return record
|
|
1258
1304
|
}
|
|
@@ -1750,6 +1796,15 @@ export class Runtime {
|
|
|
1750
1796
|
// Helpers
|
|
1751
1797
|
// =============================================================================
|
|
1752
1798
|
|
|
1799
|
+
/** Atributos do span com a natureza declarada da action (ADR 0041). */
|
|
1800
|
+
function natureAttributes(action: ActionDef): Record<string, string | boolean> {
|
|
1801
|
+
const effect = resolveActionEffect(action)
|
|
1802
|
+
return {
|
|
1803
|
+
...(effect !== undefined ? { 'opus.action.effect': effect } : {}),
|
|
1804
|
+
...(action.personalData !== undefined ? { 'opus.action.personal_data': action.personalData } : {}),
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1753
1808
|
async function settledSuccessfully<T>(promise: Promise<T>): Promise<boolean> {
|
|
1754
1809
|
try {
|
|
1755
1810
|
await promise
|
package/src/core/types.ts
CHANGED
|
@@ -356,6 +356,15 @@ export interface AuditRecord {
|
|
|
356
356
|
output?: unknown
|
|
357
357
|
error?: ActionError
|
|
358
358
|
|
|
359
|
+
/** Efeito resolvido da action (ADR 0041): `list` e `view` são sempre leitura. */
|
|
360
|
+
effect?: ActionEffect
|
|
361
|
+
/**
|
|
362
|
+
* Presente quando a action declara `personalData: true` e executou com sucesso (ADR 0041):
|
|
363
|
+
* a requisição expôs dado pessoal a quem a executou. `records` conta os registros devolvidos
|
|
364
|
+
* (itens da página de uma `list`, 1 para um objeto) e é `null` quando a saída não permite contar.
|
|
365
|
+
*/
|
|
366
|
+
personalData?: { records: number | null }
|
|
367
|
+
|
|
359
368
|
severity: 'info' | 'warning' | 'error'
|
|
360
369
|
/** Campo legado preservado para compatibilidade com sinks/consumers existentes. */
|
|
361
370
|
trace?: {
|
|
@@ -709,6 +718,12 @@ export interface ExpandSpec {
|
|
|
709
718
|
|
|
710
719
|
export type ActionKind = 'simple' | 'form' | 'list' | 'view'
|
|
711
720
|
|
|
721
|
+
/**
|
|
722
|
+
* O que a action faz com o estado (ADR 0041). `list` e `view` são leitura pelo protocolo (`GET`);
|
|
723
|
+
* `simple` e `form` declaram, porque cobrem as duas coisas.
|
|
724
|
+
*/
|
|
725
|
+
export type ActionEffect = 'read' | 'write'
|
|
726
|
+
|
|
712
727
|
export type AuthorizeFn<In, Loaded = Record<string, unknown>> = (
|
|
713
728
|
ctx: ActionContext,
|
|
714
729
|
input: In,
|
|
@@ -810,6 +825,20 @@ export interface ActionBase<In, Out, ParsedIn = In> {
|
|
|
810
825
|
input: Schema<In, ParsedIn>
|
|
811
826
|
output: Schema<Out>
|
|
812
827
|
|
|
828
|
+
// — Natureza (ADR 0041) —
|
|
829
|
+
/**
|
|
830
|
+
* `read` não altera estado; `write` altera. `list` e `view` já são leitura pelo protocolo e não
|
|
831
|
+
* declaram (declarar `write` nelas é recusado no registro). `simple` e `form` declaram: o `kind`
|
|
832
|
+
* sozinho não diz se a action lê ou escreve. Declarativo — o runtime não impede o handler.
|
|
833
|
+
*/
|
|
834
|
+
effect?: ActionEffect
|
|
835
|
+
/**
|
|
836
|
+
* A resposta contém dados que identificam pessoas: nome, documento, contato, placa ou texto
|
|
837
|
+
* livre sobre alguém. Com `true`, toda execução bem-sucedida é marcada no `AuditRecord` e no
|
|
838
|
+
* span, qualquer que seja o consumidor. Nunca inferido; ausente significa "não declarado".
|
|
839
|
+
*/
|
|
840
|
+
personalData?: boolean
|
|
841
|
+
|
|
813
842
|
// — Autorização (fail-closed default) —
|
|
814
843
|
public?: boolean
|
|
815
844
|
requires?: string | string[]
|
|
@@ -821,27 +850,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
|
|
|
821
850
|
|
|
822
851
|
/**
|
|
823
852
|
* Handler alternativo executado quando `process.env.OPUS_MODE === 'design'`.
|
|
824
|
-
* Use pra prototipar UI com dados realistas sem precisar do backend pronto.
|
|
825
|
-
*
|
|
826
|
-
* Em modo design: runtime tenta `mockHandler` primeiro; se ausente, cai
|
|
827
|
-
* no `handler` normal (permitindo testar pontualmente real ao lado de
|
|
828
|
-
* mocks).
|
|
829
853
|
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
* ```ts
|
|
836
|
-
* defineAction({
|
|
837
|
-
* name: 'ticket.list',
|
|
838
|
-
* // ...
|
|
839
|
-
* mockHandler: async () => MOCK_TICKETS,
|
|
840
|
-
* handler: () => {
|
|
841
|
-
* throw new Error('handler real ainda não implementado — use OPUS_MODE=design')
|
|
842
|
-
* },
|
|
843
|
-
* })
|
|
844
|
-
* ```
|
|
854
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); sai na próxima major. No modo design, a action sem
|
|
855
|
+
* mock já responde com dado gerado a partir do `output`; quando o design precisar de uma resposta
|
|
856
|
+
* específica, declare um cenário fora do contrato com `defineDesignScenario`, de
|
|
857
|
+
* `@softize/opus/design`, e aponte a opção `scenarios` do `opusDesign()` para ele.
|
|
845
858
|
*/
|
|
846
859
|
mockHandler?: HandlerFn<ParsedIn, Out>
|
|
847
860
|
|
|
@@ -919,6 +932,8 @@ export interface ListAction<In, Out, ParsedIn = In>
|
|
|
919
932
|
/**
|
|
920
933
|
* Mesma semântica de `ActionBase.mockHandler`, mas devolve `Paginated<Out>`
|
|
921
934
|
* (search retorna lista paginada, não item solto).
|
|
935
|
+
*
|
|
936
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); ver `ActionBase.mockHandler`.
|
|
922
937
|
*/
|
|
923
938
|
mockHandler?: (
|
|
924
939
|
ctx: ActionContext,
|
|
@@ -1315,12 +1330,23 @@ export interface AiTool {
|
|
|
1315
1330
|
title?: string
|
|
1316
1331
|
description: string
|
|
1317
1332
|
inputSchema: unknown
|
|
1333
|
+
/** Anotações padrão de ferramenta do MCP, derivadas do contrato (ADR 0041). */
|
|
1334
|
+
annotations?: {
|
|
1335
|
+
/** A ferramenta não altera estado (efeito `read`). */
|
|
1336
|
+
readOnlyHint?: boolean
|
|
1337
|
+
/** A ferramenta pode destruir dados (`ai.destructive`); só faz sentido quando não é leitura. */
|
|
1338
|
+
destructiveHint?: boolean
|
|
1339
|
+
}
|
|
1318
1340
|
/** Metadados de governança para o host; não fazem parte do prompt nem concedem acesso. */
|
|
1319
1341
|
metadata?: {
|
|
1320
1342
|
/** Identificadores técnicos dos Produtos de Dados relacionados. */
|
|
1321
1343
|
dataProducts?: string[]
|
|
1322
1344
|
/** Labels humanas por identificador; ausente para runtimes legados. */
|
|
1323
1345
|
dataProductLabels?: Record<string, string>
|
|
1346
|
+
/** Efeito resolvido; ausente quando `simple`/`form` não declaram. */
|
|
1347
|
+
effect?: ActionEffect
|
|
1348
|
+
/** A action declara devolver dado pessoal; ausente quando não declarou. */
|
|
1349
|
+
personalData?: boolean
|
|
1324
1350
|
}
|
|
1325
1351
|
}
|
|
1326
1352
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dados do modo design fora do contrato (ADR 0037).
|
|
3
|
+
*
|
|
4
|
+
* No modo design, toda action responde: sem cenário, a resposta é gerada pelo `fake` a partir do
|
|
5
|
+
* `output` do contrato. Quando o design precisa de coerência (um item em cada situação, algo criado
|
|
6
|
+
* que aparece na lista), um cenário registrado pelo nome da action vence a resposta padrão.
|
|
7
|
+
*
|
|
8
|
+
* O módulo é isomórfico e não conhece Vite: o plugin `opusDesign()` carrega os cenários e liga as
|
|
9
|
+
* respostas ao runtime de design.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import type { ActionContext, InferOutput, Paginated } from '../core/types.ts'
|
|
14
|
+
import { fake, fakeMany } from '../testing/fake.ts'
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Cenários
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
// `Symbol.for`: o plugin roda no processo do Vite e carrega os cenários pelo pipeline SSR, então
|
|
21
|
+
// a marca precisa sobreviver a duas instâncias do módulo.
|
|
22
|
+
const SCENARIO = Symbol.for('@softize/opus/design-scenario')
|
|
23
|
+
|
|
24
|
+
type ContractKind = 'simple' | 'form' | 'list' | 'view'
|
|
25
|
+
|
|
26
|
+
interface ScenarioContract {
|
|
27
|
+
name: string
|
|
28
|
+
kind: ContractKind
|
|
29
|
+
input: unknown
|
|
30
|
+
output: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ScenarioInput<C> = C extends { input: infer S } ? InferOutput<S> : never
|
|
34
|
+
|
|
35
|
+
type ScenarioOutput<C> = C extends { output: infer S }
|
|
36
|
+
? C extends { kind: 'list' }
|
|
37
|
+
? Paginated<InferOutput<S>>
|
|
38
|
+
: InferOutput<S>
|
|
39
|
+
: never
|
|
40
|
+
|
|
41
|
+
/** Resposta de um cenário: o valor devolvido, ou uma função da entrada já validada. */
|
|
42
|
+
export type DesignScenarioResponse<C> =
|
|
43
|
+
| ScenarioOutput<C>
|
|
44
|
+
| ((input: ScenarioInput<C>, ctx: ActionContext) => ScenarioOutput<C> | Promise<ScenarioOutput<C>>)
|
|
45
|
+
|
|
46
|
+
/** Cenário de design ligado a uma action pelo nome. */
|
|
47
|
+
export interface DesignScenario {
|
|
48
|
+
readonly [SCENARIO]: true
|
|
49
|
+
readonly action: string
|
|
50
|
+
respond(input: unknown, ctx: ActionContext): Promise<unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Declara a resposta de uma action no modo design, fora do contrato. A resposta é tipada pela
|
|
55
|
+
* entrada e pela saída do contrato; em `list`, é a página completa (`items`, `cursor`, `total`).
|
|
56
|
+
*/
|
|
57
|
+
export function defineDesignScenario<C extends ScenarioContract>(
|
|
58
|
+
contract: C,
|
|
59
|
+
// NoInfer: o contrato é inferido só do primeiro argumento. Sem isso, a resposta entra na
|
|
60
|
+
// inferência e os literais do retorno de uma função alargam (`'closed'` vira `string`).
|
|
61
|
+
response: NoInfer<DesignScenarioResponse<C>>,
|
|
62
|
+
): DesignScenario {
|
|
63
|
+
const respond =
|
|
64
|
+
typeof response === 'function'
|
|
65
|
+
? async (input: unknown, ctx: ActionContext): Promise<unknown> =>
|
|
66
|
+
(response as (input: unknown, ctx: ActionContext) => unknown)(input, ctx)
|
|
67
|
+
: async (): Promise<unknown> => response
|
|
68
|
+
return { [SCENARIO]: true, action: contract.name, respond }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `true` quando o valor foi criado por `defineDesignScenario`. */
|
|
72
|
+
export function isDesignScenario(value: unknown): value is DesignScenario {
|
|
73
|
+
return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[SCENARIO] === true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Resposta padrão
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
/** Itens por página da listagem padrão quando a entrada não pede `limit`. */
|
|
81
|
+
export const DESIGN_PAGE_SIZE = 20
|
|
82
|
+
|
|
83
|
+
// Teto do `limit` atendido: o maior tamanho de página que o `ActionList` oferece.
|
|
84
|
+
const DESIGN_MAX_PAGE_SIZE = 100
|
|
85
|
+
|
|
86
|
+
// Três páginas: o bastante para a paginação do preview ter para onde ir.
|
|
87
|
+
const DESIGN_PAGES = 3
|
|
88
|
+
|
|
89
|
+
const PAGINATION_KEYS = new Set(['page', 'cursor', 'offset', 'limit'])
|
|
90
|
+
|
|
91
|
+
interface DesignActionShape {
|
|
92
|
+
name: string
|
|
93
|
+
kind: ContractKind
|
|
94
|
+
output?: unknown
|
|
95
|
+
paginate?: 'cursor' | 'offset' | false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resposta padrão do modo design: dado gerado pelo `fake` a partir do `output`, com semente
|
|
100
|
+
* derivada do nome da action e da entrada. A mesma requisição recebe a mesma resposta.
|
|
101
|
+
*
|
|
102
|
+
* - `list` devolve três páginas do tamanho pedido em `limit` ({@link DESIGN_PAGE_SIZE} quando a
|
|
103
|
+
* entrada não pede) e respeita a paginação declarada; busca e filtros não filtram o dado gerado.
|
|
104
|
+
* - Nas demais, a saída ecoa o `id` da entrada quando o schema da saída aceita esse valor.
|
|
105
|
+
* - Saída que não é schema zod não tem gerador: a resposta é `null`.
|
|
106
|
+
*/
|
|
107
|
+
export function designResponse(action: DesignActionShape, input: unknown): unknown {
|
|
108
|
+
const output = action.output
|
|
109
|
+
if (!(output instanceof z.ZodType)) return null
|
|
110
|
+
const seed = `${action.name}:${canonicalJson(withoutPagination(input))}`
|
|
111
|
+
|
|
112
|
+
if (action.kind === 'list') {
|
|
113
|
+
const paginate = action.paginate ?? 'cursor'
|
|
114
|
+
const size = pageSize(input)
|
|
115
|
+
if (paginate === false) {
|
|
116
|
+
return { items: fakeMany(output, size, { seed }), cursor: { next: null }, total: size }
|
|
117
|
+
}
|
|
118
|
+
// O `limit` fica fora da semente: mudar o tamanho da página redistribui os mesmos itens.
|
|
119
|
+
const all = fakeMany(output, size * DESIGN_PAGES, { seed })
|
|
120
|
+
const page = requestedPage(input, size)
|
|
121
|
+
const start = (page - 1) * size
|
|
122
|
+
const next = paginate === 'cursor' && page < DESIGN_PAGES ? `design:${page + 1}` : null
|
|
123
|
+
const prev = paginate === 'cursor' && page > 1 ? `design:${page - 1}` : null
|
|
124
|
+
return {
|
|
125
|
+
items: all.slice(start, start + size),
|
|
126
|
+
cursor: { next, prev },
|
|
127
|
+
total: all.length,
|
|
128
|
+
} satisfies Paginated<unknown>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const value = fake(output, { seed })
|
|
132
|
+
const inputId = isRecord(input) ? input.id : undefined
|
|
133
|
+
if (inputId !== undefined && isRecord(value) && 'id' in value) {
|
|
134
|
+
// Só ecoa quando a saída continua válida: `id` numérico ou uuid na saída não aceita qualquer
|
|
135
|
+
// valor da entrada, e a validação de output do runtime recusaria a resposta.
|
|
136
|
+
const echoed = { ...value, id: inputId }
|
|
137
|
+
if (output.safeParse(echoed).success) return echoed
|
|
138
|
+
}
|
|
139
|
+
return value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pageSize(input: unknown): number {
|
|
143
|
+
const limit = isRecord(input) && typeof input.limit === 'number' ? Math.trunc(input.limit) : DESIGN_PAGE_SIZE
|
|
144
|
+
return Math.min(Math.max(1, limit), DESIGN_MAX_PAGE_SIZE)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Página pedida, de 1 em diante; além da última página, a lista vem vazia. */
|
|
148
|
+
function requestedPage(input: unknown, size: number): number {
|
|
149
|
+
if (!isRecord(input)) return 1
|
|
150
|
+
const cursor = typeof input.cursor === 'string' ? /^design:(\d+)$/.exec(input.cursor) : null
|
|
151
|
+
const fromCursor = cursor === null ? undefined : Number(cursor[1])
|
|
152
|
+
const fromOffset = typeof input.offset === 'number' ? Math.floor(input.offset / size) + 1 : undefined
|
|
153
|
+
const fromPage = typeof input.page === 'number' ? input.page : undefined
|
|
154
|
+
return Math.max(1, Math.trunc(fromCursor ?? fromPage ?? fromOffset ?? 1))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function withoutPagination(input: unknown): unknown {
|
|
158
|
+
if (!isRecord(input)) return input
|
|
159
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => !PAGINATION_KEYS.has(key)))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** JSON com chaves ordenadas: a mesma entrada produz a mesma semente, qualquer que seja a ordem. */
|
|
163
|
+
function canonicalJson(value: unknown): string {
|
|
164
|
+
if (typeof value === 'bigint') return `${value}n`
|
|
165
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString())
|
|
166
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
167
|
+
if (isRecord(value)) {
|
|
168
|
+
const entries = Object.keys(value)
|
|
169
|
+
.filter((key) => value[key] !== undefined)
|
|
170
|
+
.sort()
|
|
171
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
172
|
+
return `{${entries.join(',')}}`
|
|
173
|
+
}
|
|
174
|
+
return JSON.stringify(value) ?? 'null'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
179
|
+
}
|
package/src/mcp/index.ts
CHANGED
|
@@ -48,9 +48,15 @@ export function createOpusMcpServer(runtime: Runtime, opts: OpusMcpOptions = {})
|
|
|
48
48
|
...(t.title !== undefined ? { title: t.title } : {}),
|
|
49
49
|
description: t.description,
|
|
50
50
|
inputSchema: toJsonSchema(t.inputSchema) as { type: 'object' },
|
|
51
|
+
// Anotações padrão do MCP (ADR 0041): o cliente sabe se a ferramenta só lê.
|
|
52
|
+
...(t.annotations !== undefined ? { annotations: t.annotations } : {}),
|
|
51
53
|
_meta: {
|
|
52
54
|
'com.softize.opus/data-products': t.metadata?.dataProducts ?? [],
|
|
53
55
|
'com.softize.opus/data-product-labels': t.metadata?.dataProductLabels ?? {},
|
|
56
|
+
...(t.metadata?.effect !== undefined ? { 'com.softize.opus/effect': t.metadata.effect } : {}),
|
|
57
|
+
...(t.metadata?.personalData !== undefined
|
|
58
|
+
? { 'com.softize.opus/personal-data': t.metadata.personalData }
|
|
59
|
+
: {}),
|
|
54
60
|
},
|
|
55
61
|
})),
|
|
56
62
|
}))
|
package/src/testing/fake.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `fake` — dado sintético DETERMINÍSTICO a partir de um schema zod. É a matéria-prima do
|
|
3
|
-
* modo design:
|
|
4
|
-
* a UI com dado realista, sem backend nem banco
|
|
3
|
+
* modo design: a resposta gerada pelo plugin `opusDesign()` (ADR 0037) sai de `fake`/`fakeMany`
|
|
4
|
+
* sobre o `output` do contrato, e o preview mostra a UI com dado realista, sem backend nem banco.
|
|
5
5
|
*
|
|
6
6
|
* DETERMINISMO: PRNG próprio semeado (mulberry32) — mesma `seed` → mesma saída, sempre. Nunca
|
|
7
7
|
* `Math.random` (o preview tem que ser reproduzível entre execuções). O default é semeado por
|