@softize/opus 8.6.6
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 +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- package/src/vite/index.ts +8 -0
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tbdlib — Runtime
|
|
3
|
+
*
|
|
4
|
+
* Orquestrador central. Recebe actions + reactions + adapters + config no setup,
|
|
5
|
+
* registra cada peça, e executa o pipeline padrão (validate → load → auth →
|
|
6
|
+
* authorize → handler → audit → result).
|
|
7
|
+
*
|
|
8
|
+
* Pipeline interno é privado nesta versão (v0). Se crescer, fatorar em
|
|
9
|
+
* `pipeline.ts` separado.
|
|
10
|
+
*
|
|
11
|
+
* Ver §2, §3, §4, §5, §8, §14 do protocolo.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isReaction } from './reactions.ts'
|
|
15
|
+
import { isSchedule } from './schedules.ts'
|
|
16
|
+
import { collectDomainWarnings, flattenDomain, isDomainConfig } from './domain.ts'
|
|
17
|
+
import type { DomainConfig } from './domain.ts'
|
|
18
|
+
import { AuditEmitter } from './audit.ts'
|
|
19
|
+
import { error, isActionError, normalizeError } from './errors.ts'
|
|
20
|
+
import { evalExpression } from '../dsl/eval.ts'
|
|
21
|
+
import { parseExpression } from '../dsl/parser.ts'
|
|
22
|
+
import { parseLoad, resolveLoadArgs } from '../dsl/loads.ts'
|
|
23
|
+
import type { AstNode } from '../dsl/types.ts'
|
|
24
|
+
import type { LoadSpec as DslLoadSpec } from '../dsl/loads.ts'
|
|
25
|
+
import type {
|
|
26
|
+
ActionContext,
|
|
27
|
+
ActionDef,
|
|
28
|
+
ActionResult,
|
|
29
|
+
Adapter,
|
|
30
|
+
AuditRecord,
|
|
31
|
+
AuditSink,
|
|
32
|
+
AuthAdapter,
|
|
33
|
+
AuthorizeFn,
|
|
34
|
+
AuthorizeSpec,
|
|
35
|
+
BackoffSpec,
|
|
36
|
+
ClientAdapter,
|
|
37
|
+
ContextBase,
|
|
38
|
+
DataAdapter,
|
|
39
|
+
DomainEvent,
|
|
40
|
+
EventBusAdapter,
|
|
41
|
+
HealthStatus,
|
|
42
|
+
I18nRef,
|
|
43
|
+
LoaderFn,
|
|
44
|
+
LoaderResolver,
|
|
45
|
+
LoadsSpec,
|
|
46
|
+
Logger,
|
|
47
|
+
LoggerAdapter,
|
|
48
|
+
Provenance,
|
|
49
|
+
QueueAdapter,
|
|
50
|
+
ReactionContext,
|
|
51
|
+
ReactionDef,
|
|
52
|
+
ResultMeta,
|
|
53
|
+
Schema,
|
|
54
|
+
ScheduleDef,
|
|
55
|
+
SchedulerAdapter,
|
|
56
|
+
ServerAdapter,
|
|
57
|
+
StorageAdapter,
|
|
58
|
+
AiAdapter,
|
|
59
|
+
AiRunOptions,
|
|
60
|
+
BoundAiRunOptions,
|
|
61
|
+
BoundAi,
|
|
62
|
+
AiTool,
|
|
63
|
+
AIConfig,
|
|
64
|
+
User,
|
|
65
|
+
} from './types.ts'
|
|
66
|
+
|
|
67
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
68
|
+
|
|
69
|
+
/** Normaliza o `ai` da action (`boolean | AIConfig`) → config, ou null se não exposta à IA. */
|
|
70
|
+
function normalizeAiConfig(ai: boolean | AIConfig | undefined): AIConfig | null {
|
|
71
|
+
if (ai === undefined || ai === false) return null
|
|
72
|
+
if (ai === true) return { enabled: true }
|
|
73
|
+
return ai.enabled === false ? null : ai
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Setup types
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
export interface RuntimeSetup {
|
|
81
|
+
server?: ServerAdapter
|
|
82
|
+
data?: DataAdapter
|
|
83
|
+
auth?: AuthAdapter
|
|
84
|
+
logger?: LoggerAdapter
|
|
85
|
+
audit?: AuditSink | AuditSink[]
|
|
86
|
+
queue?: QueueAdapter
|
|
87
|
+
eventBus?: EventBusAdapter
|
|
88
|
+
scheduler?: SchedulerAdapter
|
|
89
|
+
client?: ClientAdapter
|
|
90
|
+
/** EXPERIMENTAL — storage de objetos (arquivos); chega nos handlers via `ctx.storage`. */
|
|
91
|
+
storage?: StorageAdapter
|
|
92
|
+
/** EXPERIMENTAL — IA generativa (complete/extract); chega nos handlers via `ctx.ai`. */
|
|
93
|
+
ai?: AiAdapter
|
|
94
|
+
|
|
95
|
+
config?: RuntimeSetupConfig
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Subset de `RuntimeConfig` que o usuário passa. Defaults completos são
|
|
100
|
+
* aplicados internamente em `applyConfigDefaults`.
|
|
101
|
+
*/
|
|
102
|
+
export interface RuntimeSetupConfig {
|
|
103
|
+
auditMode?: 'lenient' | 'strict'
|
|
104
|
+
emitMode?: 'lenient' | 'strict'
|
|
105
|
+
env?: 'development' | 'production' | 'test'
|
|
106
|
+
dev?: boolean
|
|
107
|
+
validateOutputInDev?: boolean
|
|
108
|
+
warnUndeclaredEmits?: boolean
|
|
109
|
+
dedupCacheTtl?: number
|
|
110
|
+
defaultRetry?: { attempts: number; backoff?: BackoffSpec }
|
|
111
|
+
i18n?: {
|
|
112
|
+
resolver?: (ref: I18nRef, locale?: string) => string
|
|
113
|
+
defaultLocale?: string
|
|
114
|
+
}
|
|
115
|
+
maxConcurrentActions?: number
|
|
116
|
+
maxConcurrentReactions?: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `RuntimeConfig` totalmente resolvido (com defaults aplicados).
|
|
121
|
+
* Acessível via `runtime.config` em adapters.
|
|
122
|
+
*/
|
|
123
|
+
export interface ResolvedRuntimeConfig {
|
|
124
|
+
auditMode: 'lenient' | 'strict'
|
|
125
|
+
emitMode: 'lenient' | 'strict'
|
|
126
|
+
env: 'development' | 'production' | 'test'
|
|
127
|
+
dev: boolean
|
|
128
|
+
validateOutputInDev: boolean
|
|
129
|
+
warnUndeclaredEmits: boolean
|
|
130
|
+
dedupCacheTtl: number
|
|
131
|
+
defaultRetry: { attempts: number; backoff?: BackoffSpec }
|
|
132
|
+
i18n: {
|
|
133
|
+
resolver?: (ref: I18nRef, locale?: string) => string
|
|
134
|
+
defaultLocale: string
|
|
135
|
+
}
|
|
136
|
+
maxConcurrentActions: number
|
|
137
|
+
maxConcurrentReactions: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// =============================================================================
|
|
141
|
+
// Factory
|
|
142
|
+
// =============================================================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Cria um runtime. Wrapper conveniente sobre `new Runtime(setup)`.
|
|
146
|
+
*/
|
|
147
|
+
export function createRuntime(setup: RuntimeSetup = {}): Runtime {
|
|
148
|
+
return new Runtime(setup)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// Default console logger
|
|
153
|
+
// =============================================================================
|
|
154
|
+
|
|
155
|
+
class ConsoleLogger implements Logger {
|
|
156
|
+
constructor(private readonly bindings: Readonly<Record<string, unknown>> = {}) {}
|
|
157
|
+
|
|
158
|
+
trace(msg: string, meta?: object): void {
|
|
159
|
+
this.write('trace', msg, meta)
|
|
160
|
+
}
|
|
161
|
+
debug(msg: string, meta?: object): void {
|
|
162
|
+
this.write('debug', msg, meta)
|
|
163
|
+
}
|
|
164
|
+
info(msg: string, meta?: object): void {
|
|
165
|
+
this.write('info', msg, meta)
|
|
166
|
+
}
|
|
167
|
+
warn(msg: string, meta?: object): void {
|
|
168
|
+
this.write('warn', msg, meta)
|
|
169
|
+
}
|
|
170
|
+
error(msg: string, meta?: object): void {
|
|
171
|
+
this.write('error', msg, meta)
|
|
172
|
+
}
|
|
173
|
+
fatal(msg: string, meta?: object): void {
|
|
174
|
+
this.write('fatal', msg, meta)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
child(bindings: object): Logger {
|
|
178
|
+
return new ConsoleLogger({ ...this.bindings, ...(bindings as object) })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private write(level: string, msg: string, meta?: object): void {
|
|
182
|
+
const merged = { ...this.bindings, ...(meta ?? {}) }
|
|
183
|
+
const target = level === 'error' || level === 'fatal' ? console.error : console.log
|
|
184
|
+
/* v8 ignore next — bindings sempre não-vazias quando criado via Runtime */
|
|
185
|
+
target(`[${level}] ${msg}`, Object.keys(merged).length > 0 ? merged : '')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// =============================================================================
|
|
190
|
+
// Defaults
|
|
191
|
+
// =============================================================================
|
|
192
|
+
|
|
193
|
+
function applyConfigDefaults(c: RuntimeSetupConfig = {}): ResolvedRuntimeConfig {
|
|
194
|
+
const env = c.env ?? 'development'
|
|
195
|
+
const dev = c.dev ?? env !== 'production'
|
|
196
|
+
const i18n: ResolvedRuntimeConfig['i18n'] = {
|
|
197
|
+
defaultLocale: c.i18n?.defaultLocale ?? 'pt-BR',
|
|
198
|
+
}
|
|
199
|
+
if (c.i18n?.resolver !== undefined) i18n.resolver = c.i18n.resolver
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
auditMode: c.auditMode ?? 'lenient',
|
|
203
|
+
emitMode: c.emitMode ?? 'lenient',
|
|
204
|
+
env,
|
|
205
|
+
dev,
|
|
206
|
+
validateOutputInDev: c.validateOutputInDev ?? dev,
|
|
207
|
+
warnUndeclaredEmits: c.warnUndeclaredEmits ?? dev,
|
|
208
|
+
dedupCacheTtl: c.dedupCacheTtl ?? 60_000,
|
|
209
|
+
defaultRetry: c.defaultRetry ?? {
|
|
210
|
+
attempts: 3,
|
|
211
|
+
backoff: { kind: 'exponential', initialMs: 1000 },
|
|
212
|
+
},
|
|
213
|
+
i18n,
|
|
214
|
+
maxConcurrentActions: c.maxConcurrentActions ?? Number.POSITIVE_INFINITY,
|
|
215
|
+
maxConcurrentReactions: c.maxConcurrentReactions ?? 10,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// Runtime
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
export class Runtime {
|
|
224
|
+
readonly config: ResolvedRuntimeConfig
|
|
225
|
+
readonly log: Logger
|
|
226
|
+
readonly audit: AuditEmitter
|
|
227
|
+
readonly auth: AuthAdapter | undefined
|
|
228
|
+
|
|
229
|
+
private readonly server: ServerAdapter | undefined
|
|
230
|
+
private readonly data: DataAdapter | undefined
|
|
231
|
+
private readonly logger: LoggerAdapter | undefined
|
|
232
|
+
private readonly queue: QueueAdapter | undefined
|
|
233
|
+
private readonly eventBus: EventBusAdapter | undefined
|
|
234
|
+
private readonly scheduler: SchedulerAdapter | undefined
|
|
235
|
+
private readonly client: ClientAdapter | undefined
|
|
236
|
+
private readonly storage: StorageAdapter | undefined
|
|
237
|
+
private readonly ai: AiAdapter | undefined
|
|
238
|
+
/** Cache das tools derivadas das actions `ai:enabled` (registry é estático pós-start). */
|
|
239
|
+
private aiToolsCache: AiTool[] | null = null
|
|
240
|
+
|
|
241
|
+
private readonly actions = new Map<string, ActionDef>()
|
|
242
|
+
private readonly reactions = new Map<string, ReactionDef<any>>()
|
|
243
|
+
private readonly schedules = new Map<string, ScheduleDef>()
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Map entidade → resolver, usado pra carregar specs DSL de loads
|
|
247
|
+
* (`'ticket(:id)'`). Registrado via `registerLoaders`. Closures
|
|
248
|
+
* em `action.loads` não passam por aqui.
|
|
249
|
+
*/
|
|
250
|
+
private readonly loaderResolvers = new Map<string, LoaderResolver>()
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Cache de AST de DSL `authorize` indexado por string crua. Parsing é
|
|
254
|
+
* idempotente; evita refazer toda execução.
|
|
255
|
+
*/
|
|
256
|
+
private readonly authorizeAstCache = new Map<string, AstNode>()
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Cache equivalente pra DSL de `loads` — chave é o spec string crua;
|
|
260
|
+
* valor é o `LoadSpec` parseado.
|
|
261
|
+
*/
|
|
262
|
+
private readonly loadSpecCache = new Map<string, DslLoadSpec>()
|
|
263
|
+
|
|
264
|
+
private started = false
|
|
265
|
+
private readonly disposeCallbacks: Array<() => Promise<void> | void> = []
|
|
266
|
+
|
|
267
|
+
constructor(setup: RuntimeSetup) {
|
|
268
|
+
this.config = applyConfigDefaults(setup.config)
|
|
269
|
+
this.logger = setup.logger
|
|
270
|
+
this.log = setup.logger ?? new ConsoleLogger({ runtime: 'tbdlib' })
|
|
271
|
+
|
|
272
|
+
this.audit = new AuditEmitter({ mode: this.config.auditMode, log: this.log })
|
|
273
|
+
if (setup.audit !== undefined) {
|
|
274
|
+
const sinks = Array.isArray(setup.audit) ? setup.audit : [setup.audit]
|
|
275
|
+
for (const sink of sinks) this.audit.register(sink)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.server = setup.server
|
|
279
|
+
this.data = setup.data
|
|
280
|
+
this.auth = setup.auth
|
|
281
|
+
this.queue = setup.queue
|
|
282
|
+
this.eventBus = setup.eventBus
|
|
283
|
+
this.scheduler = setup.scheduler
|
|
284
|
+
this.client = setup.client
|
|
285
|
+
this.storage = setup.storage
|
|
286
|
+
this.ai = setup.ai
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Registra actions, reactions, schedules e/ou domínios. Aceita lista mista;
|
|
291
|
+
* distinção por shape via `isSchedule`/`isReaction`/`isDomainConfig`.
|
|
292
|
+
*
|
|
293
|
+
* Quando recebe um `DomainConfig`, achata via `flattenDomain` e registra
|
|
294
|
+
* as peças resultantes — qualquer colisão de nome (incluindo entre domínios
|
|
295
|
+
* registrados separadamente) é detectada aqui.
|
|
296
|
+
*/
|
|
297
|
+
register(
|
|
298
|
+
items: Array<ActionDef | ReactionDef<any> | ScheduleDef | DomainConfig>,
|
|
299
|
+
): void {
|
|
300
|
+
for (const item of items) {
|
|
301
|
+
if (isDomainConfig(item)) {
|
|
302
|
+
collectDomainWarnings(item)
|
|
303
|
+
const { actions, reactions, schedules } = flattenDomain(item)
|
|
304
|
+
this.registerFlat(actions, reactions, schedules)
|
|
305
|
+
} else if (isSchedule(item)) {
|
|
306
|
+
this.registerSchedules([item])
|
|
307
|
+
} else if (isReaction(item)) {
|
|
308
|
+
this.registerReactions([item])
|
|
309
|
+
} else {
|
|
310
|
+
this.registerActions([item as ActionDef])
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Registra resolvers de entidades pra DSL `loads`.
|
|
317
|
+
*
|
|
318
|
+
* Quando uma action declara `loads: 'ticket(:id)'`, o runtime parsa o
|
|
319
|
+
* spec, resolve os args contra o input (`{ id: input.id }`) e chama o
|
|
320
|
+
* resolver registrado pra `ticket`.
|
|
321
|
+
*
|
|
322
|
+
* Closures em `action.loads` ignoram esse map — só specs string usam.
|
|
323
|
+
*
|
|
324
|
+
* Reregistrar a mesma entidade lança — fail-fast pra evitar shadow.
|
|
325
|
+
* Pra reset (testes), criar runtime novo.
|
|
326
|
+
*/
|
|
327
|
+
registerLoaders(resolvers: Record<string, LoaderResolver>): void {
|
|
328
|
+
for (const [entity, resolver] of Object.entries(resolvers)) {
|
|
329
|
+
if (this.loaderResolvers.has(entity)) {
|
|
330
|
+
throw error({
|
|
331
|
+
code: 'runtime.duplicate_loader',
|
|
332
|
+
category: 'internal',
|
|
333
|
+
message: `Loader resolver "${entity}" already registered`,
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
this.loaderResolvers.set(entity, resolver)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private registerFlat(
|
|
341
|
+
actions: ActionDef[],
|
|
342
|
+
reactions: Array<ReactionDef<any>>,
|
|
343
|
+
schedules: ScheduleDef[],
|
|
344
|
+
): void {
|
|
345
|
+
this.registerActions(actions)
|
|
346
|
+
this.registerReactions(reactions)
|
|
347
|
+
this.registerSchedules(schedules)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private registerActions(actions: ActionDef[]): void {
|
|
351
|
+
for (const action of actions) {
|
|
352
|
+
if (this.actions.has(action.name)) {
|
|
353
|
+
throw error({
|
|
354
|
+
code: 'runtime.duplicate_action',
|
|
355
|
+
category: 'internal',
|
|
356
|
+
message: `Action "${action.name}" registered twice`,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
this.actions.set(action.name, action)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private registerReactions(reactions: Array<ReactionDef<any>>): void {
|
|
364
|
+
for (const reaction of reactions) {
|
|
365
|
+
if (this.reactions.has(reaction.name)) {
|
|
366
|
+
throw error({
|
|
367
|
+
code: 'runtime.duplicate_reaction',
|
|
368
|
+
category: 'internal',
|
|
369
|
+
message: `Reaction "${reaction.name}" registered twice`,
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
this.reactions.set(reaction.name, reaction)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private registerSchedules(schedules: ScheduleDef[]): void {
|
|
377
|
+
for (const schedule of schedules) {
|
|
378
|
+
if (this.schedules.has(schedule.name)) {
|
|
379
|
+
throw error({
|
|
380
|
+
code: 'runtime.duplicate_schedule',
|
|
381
|
+
category: 'internal',
|
|
382
|
+
message: `Schedule "${schedule.name}" registered twice`,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
this.schedules.set(schedule.name, schedule)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Inicializa adapters, monta actions no server, subscreve reactions no event bus.
|
|
391
|
+
* Fail-fast: erro em qualquer init aborta o boot.
|
|
392
|
+
*/
|
|
393
|
+
async start(): Promise<void> {
|
|
394
|
+
if (this.started) {
|
|
395
|
+
throw error({
|
|
396
|
+
code: 'runtime.already_started',
|
|
397
|
+
category: 'internal',
|
|
398
|
+
message: 'Runtime.start() called twice',
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
for (const adapter of this.allAdapters()) {
|
|
403
|
+
if (adapter.init !== undefined) await adapter.init(this)
|
|
404
|
+
if (adapter.dispose !== undefined) {
|
|
405
|
+
this.disposeCallbacks.push(adapter.dispose.bind(adapter))
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (this.server !== undefined) {
|
|
410
|
+
for (const action of this.actions.values()) this.server.mount(action)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (this.eventBus !== undefined) {
|
|
414
|
+
for (const reaction of this.reactions.values()) this.subscribeReaction(reaction)
|
|
415
|
+
} else if (this.reactions.size > 0) {
|
|
416
|
+
throw error({
|
|
417
|
+
code: 'runtime.eventbus_required',
|
|
418
|
+
category: 'internal',
|
|
419
|
+
message: `${this.reactions.size} reaction(s) registered without an EventBusAdapter`,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (this.scheduler !== undefined) {
|
|
424
|
+
for (const schedule of this.schedules.values()) this.registerSchedule(schedule)
|
|
425
|
+
} else if (this.schedules.size > 0) {
|
|
426
|
+
throw error({
|
|
427
|
+
code: 'runtime.scheduler_required',
|
|
428
|
+
category: 'internal',
|
|
429
|
+
message: `${this.schedules.size} schedule(s) registered without a SchedulerAdapter`,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
this.started = true
|
|
434
|
+
this.log.info('runtime started', {
|
|
435
|
+
actions: this.actions.size,
|
|
436
|
+
reactions: this.reactions.size,
|
|
437
|
+
schedules: this.schedules.size,
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Graceful shutdown. Chama `dispose()` em cada adapter registrado.
|
|
443
|
+
*/
|
|
444
|
+
async dispose(): Promise<void> {
|
|
445
|
+
this.log.info('runtime disposing')
|
|
446
|
+
const callbacks = this.disposeCallbacks.slice().reverse()
|
|
447
|
+
for (const cb of callbacks) {
|
|
448
|
+
try {
|
|
449
|
+
await cb()
|
|
450
|
+
} catch (err) {
|
|
451
|
+
this.log.warn('adapter dispose failed', { error: String(err) })
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
this.started = false
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Agrega `healthCheck()` de todos os adapters registrados. Adapter sem
|
|
459
|
+
* `healthCheck` é considerado ok.
|
|
460
|
+
*/
|
|
461
|
+
async healthCheck(): Promise<{
|
|
462
|
+
ok: boolean
|
|
463
|
+
adapters: Record<string, HealthStatus>
|
|
464
|
+
}> {
|
|
465
|
+
const adapters: Record<string, HealthStatus> = {}
|
|
466
|
+
let ok = true
|
|
467
|
+
|
|
468
|
+
for (const adapter of this.allAdapters()) {
|
|
469
|
+
const key = `${adapter.kind}:${adapter.name}`
|
|
470
|
+
if (adapter.healthCheck === undefined) {
|
|
471
|
+
adapters[key] = { ok: true }
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const status = await adapter.healthCheck()
|
|
476
|
+
adapters[key] = status
|
|
477
|
+
if (!status.ok) ok = false
|
|
478
|
+
} catch (err) {
|
|
479
|
+
adapters[key] = { ok: false, details: { error: String(err) } }
|
|
480
|
+
ok = false
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const auditHealth = await this.audit.healthCheck()
|
|
485
|
+
adapters['audit:emitter'] = { ok: auditHealth.ok, details: auditHealth.details }
|
|
486
|
+
if (!auditHealth.ok) ok = false
|
|
487
|
+
|
|
488
|
+
return { ok, adapters }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Executa uma action de forma síncrona. Background actions usam
|
|
493
|
+
* `executeBackground` (não implementado em v0; queue adapter dispara).
|
|
494
|
+
*/
|
|
495
|
+
async execute(
|
|
496
|
+
actionName: string,
|
|
497
|
+
input: unknown,
|
|
498
|
+
ctxBase: ContextBase,
|
|
499
|
+
): Promise<ActionResult<unknown>> {
|
|
500
|
+
const action = this.actions.get(actionName)
|
|
501
|
+
if (action === undefined) {
|
|
502
|
+
return this.errorResult(actionName, error({
|
|
503
|
+
code: 'runtime.action_not_found',
|
|
504
|
+
category: 'not_found',
|
|
505
|
+
message: `Action "${actionName}" is not registered`,
|
|
506
|
+
}), 0, ctxBase.requestId)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const startedAt = performance.now()
|
|
510
|
+
const actionId = generateId()
|
|
511
|
+
const ctx = this.buildActionContext(action, actionId, ctxBase)
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
// — 1. Validate input ————————————————————————————————————————————————
|
|
515
|
+
const validatedInput = await this.validate(action.input, input, 'input')
|
|
516
|
+
|
|
517
|
+
// — 2. Load —————————————————————————————————————————————————————————
|
|
518
|
+
const loaded = await this.runLoaders(action, ctx, validatedInput)
|
|
519
|
+
|
|
520
|
+
// — 3. Authenticate (public-or-user) ——————————————————————————————————
|
|
521
|
+
if (action.public !== true && ctx.user === null) {
|
|
522
|
+
throw error({
|
|
523
|
+
code: 'auth.unauthenticated',
|
|
524
|
+
category: 'authentication',
|
|
525
|
+
message: 'Authentication required',
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// — 4. Authorize ——————————————————————————————————————————————————————
|
|
530
|
+
if (action.authorize !== undefined) {
|
|
531
|
+
const authorizeFn = this.compileAuthorize(action.authorize)
|
|
532
|
+
const decision = await authorizeFn(ctx, validatedInput, loaded)
|
|
533
|
+
if (decision === false) {
|
|
534
|
+
throw error({
|
|
535
|
+
code: 'auth.forbidden',
|
|
536
|
+
category: 'authorization',
|
|
537
|
+
message: 'Forbidden',
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
if (decision !== true) throw decision
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// — 5. Handler ————————————————————————————————————————————————————————
|
|
544
|
+
// Modo design (`OPUS_MODE=design`): tenta mockHandler primeiro, cai
|
|
545
|
+
// no handler real se não houver. Em qualquer outro modo, handler real
|
|
546
|
+
// sempre manda. Leitura de env mora aqui, no boundary do execute — o
|
|
547
|
+
// resto do core continua sem tocar `process.env`.
|
|
548
|
+
const isDesignMode = isOpusDesignMode()
|
|
549
|
+
const handlerToRun =
|
|
550
|
+
isDesignMode && action.mockHandler !== undefined
|
|
551
|
+
? action.mockHandler
|
|
552
|
+
: action.handler
|
|
553
|
+
const output = await handlerToRun(ctx, validatedInput, loaded)
|
|
554
|
+
const usedMock = handlerToRun === action.mockHandler
|
|
555
|
+
|
|
556
|
+
// — 6. Validate output (dev only) —————————————————————————————————————
|
|
557
|
+
// Search retorna Paginated<Item> mas `output` schema descreve só Item;
|
|
558
|
+
// skipamos pra evitar falso negativo. v1 pode validar items[].
|
|
559
|
+
const skipOutputValidation = action.kind === 'list'
|
|
560
|
+
const validatedOutput =
|
|
561
|
+
this.config.validateOutputInDev && !skipOutputValidation
|
|
562
|
+
? await this.validate(action.output, output, 'output')
|
|
563
|
+
: output
|
|
564
|
+
|
|
565
|
+
// — 7. Audit ———————————————————————————————————————————————————————————
|
|
566
|
+
const durationMs = performance.now() - startedAt
|
|
567
|
+
await this.audit.emit(
|
|
568
|
+
this.buildAuditRecord({
|
|
569
|
+
actionId,
|
|
570
|
+
action: action.name,
|
|
571
|
+
actionKind: action.kind,
|
|
572
|
+
outcome: 'success',
|
|
573
|
+
durationMs,
|
|
574
|
+
ctx,
|
|
575
|
+
input: validatedInput,
|
|
576
|
+
output: validatedOutput,
|
|
577
|
+
mode: usedMock ? 'design' : undefined,
|
|
578
|
+
}),
|
|
579
|
+
action.audit,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
// — 8. Build result ———————————————————————————————————————————————————
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
data: validatedOutput,
|
|
586
|
+
meta: this.buildMeta(actionId, action.name, durationMs, ctxBase.requestId),
|
|
587
|
+
}
|
|
588
|
+
} catch (thrown) {
|
|
589
|
+
const actionError = normalizeError(thrown)
|
|
590
|
+
const durationMs = performance.now() - startedAt
|
|
591
|
+
await this.audit.emit(
|
|
592
|
+
this.buildAuditRecord({
|
|
593
|
+
actionId,
|
|
594
|
+
action: action.name,
|
|
595
|
+
actionKind: action.kind,
|
|
596
|
+
outcome: 'error',
|
|
597
|
+
durationMs,
|
|
598
|
+
ctx,
|
|
599
|
+
input,
|
|
600
|
+
error: actionError,
|
|
601
|
+
}),
|
|
602
|
+
action.audit,
|
|
603
|
+
)
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
error: actionError,
|
|
607
|
+
meta: this.buildMeta(actionId, action.name, durationMs, ctxBase.requestId),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ===========================================================================
|
|
613
|
+
// Privados
|
|
614
|
+
// ===========================================================================
|
|
615
|
+
|
|
616
|
+
private *allAdapters(): IterableIterator<Adapter> {
|
|
617
|
+
if (this.logger !== undefined) yield this.logger
|
|
618
|
+
if (this.data !== undefined) yield this.data
|
|
619
|
+
if (this.auth !== undefined) yield this.auth
|
|
620
|
+
if (this.server !== undefined) yield this.server
|
|
621
|
+
if (this.queue !== undefined) yield this.queue
|
|
622
|
+
if (this.eventBus !== undefined) yield this.eventBus
|
|
623
|
+
if (this.scheduler !== undefined) yield this.scheduler
|
|
624
|
+
if (this.client !== undefined) yield this.client
|
|
625
|
+
if (this.storage !== undefined) yield this.storage
|
|
626
|
+
if (this.ai !== undefined) yield this.ai
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** As actions `ai:enabled` viradas em tool specs (cacheado — registry é estático pós-start).
|
|
630
|
+
* Cada tool carrega o Schema do input; quem consome (driver, MCP server) converte pra
|
|
631
|
+
* JSON Schema — o core não depende de zod-to-json-schema. Público: o bridge é reusado
|
|
632
|
+
* pela cola do ctx.ai E pelo MCP server (`@softize/opus/mcp`). */
|
|
633
|
+
aiTools(): AiTool[] {
|
|
634
|
+
if (this.aiToolsCache !== null) return this.aiToolsCache
|
|
635
|
+
const tools: AiTool[] = []
|
|
636
|
+
for (const action of this.actions.values()) {
|
|
637
|
+
const cfg = normalizeAiConfig(action.ai)
|
|
638
|
+
if (cfg === null) continue
|
|
639
|
+
tools.push({
|
|
640
|
+
name: action.name,
|
|
641
|
+
description: cfg.description ?? action.description ?? action.name,
|
|
642
|
+
inputSchema: action.input,
|
|
643
|
+
})
|
|
644
|
+
}
|
|
645
|
+
this.aiToolsCache = tools
|
|
646
|
+
return tools
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Liga o adapter de IA a um contexto: o `run` sai com as tools das actions `ai:enabled`
|
|
650
|
+
* e o `execute` que roda a action COM ESTE contexto (auth de quem chamou) + o gate de
|
|
651
|
+
* confirmação (destructive/requiresConfirmation). `complete`/`extract` passam direto.
|
|
652
|
+
* Null se não há adapter. É a "cola" que mantém o driver puro (DI de tools+execute). */
|
|
653
|
+
private bindAi(base: ContextBase): BoundAi | null {
|
|
654
|
+
const adapter = this.ai
|
|
655
|
+
if (adapter === undefined) return null
|
|
656
|
+
const tools = this.aiTools()
|
|
657
|
+
// O execute-como-usuário + gate de confirmação é o mesmo nos dois modos (run/runStream).
|
|
658
|
+
const runOptions = (opts?: BoundAiRunOptions): AiRunOptions => ({
|
|
659
|
+
...opts,
|
|
660
|
+
tools,
|
|
661
|
+
execute: async (name, toolInput) => {
|
|
662
|
+
const cfg = normalizeAiConfig(this.actions.get(name)?.ai)
|
|
663
|
+
if (cfg !== null && (cfg.destructive === true || cfg.requiresConfirmation === true)) {
|
|
664
|
+
const approved = opts?.confirm ? await opts.confirm({ name, input: toolInput }) : false
|
|
665
|
+
if (!approved) {
|
|
666
|
+
return { error: `A action "${name}" é destrutiva ou precisa de confirmação, e não foi aprovada.` }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const result = await this.execute(name, toolInput, base)
|
|
670
|
+
return result.ok ? result.data : { error: result.error }
|
|
671
|
+
},
|
|
672
|
+
})
|
|
673
|
+
return {
|
|
674
|
+
complete: (p, o) => adapter.complete(p, o),
|
|
675
|
+
extract: (p, s, o) => adapter.extract(p, s, o),
|
|
676
|
+
run: (input, opts) => {
|
|
677
|
+
if (adapter.run === undefined) {
|
|
678
|
+
throw new Error('O driver de IA configurado não implementa run (loop agêntico).')
|
|
679
|
+
}
|
|
680
|
+
return adapter.run(input, runOptions(opts))
|
|
681
|
+
},
|
|
682
|
+
runStream: (input, opts) => {
|
|
683
|
+
if (adapter.runStream === undefined) {
|
|
684
|
+
throw new Error('O driver de IA configurado não implementa runStream (loop agêntico em streaming).')
|
|
685
|
+
}
|
|
686
|
+
return adapter.runStream(input, runOptions(opts))
|
|
687
|
+
},
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** O adapter de IA LIGADO a um contexto — pra fora do handler (ex.: o backend do chat
|
|
692
|
+
* resolve o usuário e chama `runtime.aiFor(base).run(historico)`). Null sem adapter. */
|
|
693
|
+
aiFor(base: ContextBase): BoundAi | null {
|
|
694
|
+
return this.bindAi(base)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private buildActionContext(
|
|
698
|
+
action: ActionDef,
|
|
699
|
+
actionId: string,
|
|
700
|
+
base: ContextBase,
|
|
701
|
+
): ActionContext {
|
|
702
|
+
const dbExt = this.data?.contextExtension() ?? { db: undefined }
|
|
703
|
+
const provenance = resolveProvenance(base)
|
|
704
|
+
const log = this.log.child({
|
|
705
|
+
action: action.name,
|
|
706
|
+
actionId,
|
|
707
|
+
provenance: provenance.kind,
|
|
708
|
+
...(base.tenantId !== null ? { tenant: base.tenantId } : {}),
|
|
709
|
+
...(base.user !== null ? { userId: base.user.id } : {}),
|
|
710
|
+
...(base.requestId !== undefined ? { requestId: base.requestId } : {}),
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
user: base.user,
|
|
715
|
+
tenantId: base.tenantId,
|
|
716
|
+
can: base.can,
|
|
717
|
+
db: dbExt.db,
|
|
718
|
+
log,
|
|
719
|
+
emit: this.buildEmit(action, actionId, base),
|
|
720
|
+
storage: this.storage ?? null,
|
|
721
|
+
ai: this.bindAi(base),
|
|
722
|
+
provenance,
|
|
723
|
+
meta: base.meta ?? {},
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private buildEmit(
|
|
728
|
+
action: ActionDef,
|
|
729
|
+
actionId: string,
|
|
730
|
+
base: ContextBase,
|
|
731
|
+
): ActionContext['emit'] {
|
|
732
|
+
return async (event, data, meta) => {
|
|
733
|
+
if (
|
|
734
|
+
this.config.warnUndeclaredEmits &&
|
|
735
|
+
actionHasEmits(action) &&
|
|
736
|
+
!action.emits?.includes(event)
|
|
737
|
+
) {
|
|
738
|
+
this.log.warn('handler emitted undeclared event', {
|
|
739
|
+
action: action.name,
|
|
740
|
+
event,
|
|
741
|
+
declared: action.emits,
|
|
742
|
+
})
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (this.eventBus === undefined) {
|
|
746
|
+
if (this.config.emitMode === 'strict') {
|
|
747
|
+
throw error({
|
|
748
|
+
code: 'emit.no_bus',
|
|
749
|
+
category: 'internal',
|
|
750
|
+
message: `ctx.emit('${event}') called without EventBusAdapter`,
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
this.log.warn('emit dropped — no EventBusAdapter', { event })
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const domainEvent: DomainEvent = {
|
|
758
|
+
type: event,
|
|
759
|
+
id: generateId(),
|
|
760
|
+
timestamp: new Date().toISOString(),
|
|
761
|
+
actor: {
|
|
762
|
+
id: base.user?.id ?? null,
|
|
763
|
+
type: base.user !== null ? 'user' : 'system',
|
|
764
|
+
},
|
|
765
|
+
tenant: base.tenantId,
|
|
766
|
+
data,
|
|
767
|
+
source: {
|
|
768
|
+
action: action.name,
|
|
769
|
+
actionId,
|
|
770
|
+
...(meta?.correlation !== undefined ? { correlation: meta.correlation } : {}),
|
|
771
|
+
},
|
|
772
|
+
...(meta !== undefined ? { meta: meta as Record<string, unknown> } : {}),
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
await this.eventBus.publish(domainEvent)
|
|
777
|
+
} catch (err) {
|
|
778
|
+
if (this.config.emitMode === 'strict') {
|
|
779
|
+
throw error({
|
|
780
|
+
code: 'emit.failed',
|
|
781
|
+
category: 'internal',
|
|
782
|
+
message: `failed to publish event "${event}"`,
|
|
783
|
+
cause: String(err),
|
|
784
|
+
})
|
|
785
|
+
}
|
|
786
|
+
this.log.warn('event publish failed (lenient)', {
|
|
787
|
+
event,
|
|
788
|
+
error: String(err),
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private async validate<T>(
|
|
795
|
+
schema: Schema<T> | Schema<unknown, T>,
|
|
796
|
+
value: unknown,
|
|
797
|
+
kind: 'input' | 'output',
|
|
798
|
+
): Promise<T> {
|
|
799
|
+
const result = await Promise.resolve(schema['~standard'].validate(value))
|
|
800
|
+
if ('value' in result) return result.value as T
|
|
801
|
+
throw error({
|
|
802
|
+
code: kind === 'input' ? 'validation.invalid_input' : 'validation.invalid_output',
|
|
803
|
+
category: kind === 'input' ? 'validation' : 'internal',
|
|
804
|
+
message: kind === 'input' ? 'Input validation failed' : 'Output validation failed (dev)',
|
|
805
|
+
issues: result.issues.map((iss) => ({
|
|
806
|
+
path: (iss.path ?? []).join('.'),
|
|
807
|
+
code: 'invalid',
|
|
808
|
+
message: iss.message,
|
|
809
|
+
})),
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
private async runLoaders(
|
|
814
|
+
action: ActionDef,
|
|
815
|
+
ctx: ActionContext,
|
|
816
|
+
input: unknown,
|
|
817
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
818
|
+
const spec = action.loads as LoadsSpec<unknown> | undefined
|
|
819
|
+
if (spec === undefined) return undefined
|
|
820
|
+
|
|
821
|
+
const entries = this.normalizeLoadsEntries(spec)
|
|
822
|
+
const loaded: Record<string, unknown> = {}
|
|
823
|
+
for (const [key, loader] of entries) {
|
|
824
|
+
const value = await this.runSingleLoader(action, ctx, input, loader)
|
|
825
|
+
if (value === null || value === undefined) {
|
|
826
|
+
throw error({
|
|
827
|
+
code: `${action.name}.${key}.not_found`,
|
|
828
|
+
category: 'not_found',
|
|
829
|
+
message: `Resource "${key}" not found for action "${action.name}"`,
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
loaded[key] = value
|
|
833
|
+
}
|
|
834
|
+
return loaded
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Normaliza `LoadsSpec` em entries `[key, spec]`. Aceita:
|
|
839
|
+
* - `Record<string, string | LoaderFn>` — key vira o key direto.
|
|
840
|
+
* - `string[]` — extrai entidade de cada DSL e usa como key.
|
|
841
|
+
*/
|
|
842
|
+
private normalizeLoadsEntries(
|
|
843
|
+
spec: LoadsSpec<unknown>,
|
|
844
|
+
): Array<[string, string | LoaderFn<unknown>]> {
|
|
845
|
+
if (Array.isArray(spec)) {
|
|
846
|
+
return spec.map((dsl) => {
|
|
847
|
+
const parsed = this.parseLoadCached(dsl)
|
|
848
|
+
return [parsed.entity, dsl] as [string, string]
|
|
849
|
+
})
|
|
850
|
+
}
|
|
851
|
+
return Object.entries(spec) as Array<[string, string | LoaderFn<unknown>]>
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
private async runSingleLoader(
|
|
855
|
+
action: ActionDef,
|
|
856
|
+
ctx: ActionContext,
|
|
857
|
+
input: unknown,
|
|
858
|
+
loader: string | LoaderFn<unknown>,
|
|
859
|
+
): Promise<unknown> {
|
|
860
|
+
if (typeof loader === 'string') {
|
|
861
|
+
const parsed = this.parseLoadCached(loader)
|
|
862
|
+
const resolver = this.loaderResolvers.get(parsed.entity)
|
|
863
|
+
if (resolver === undefined) {
|
|
864
|
+
throw error({
|
|
865
|
+
code: 'runtime.loader_not_registered',
|
|
866
|
+
category: 'internal',
|
|
867
|
+
message: `Load spec "${loader}" em action "${action.name}" referencia entidade "${parsed.entity}" sem resolver registrado — use runtime.registerLoaders({ ${parsed.entity}: ... })`,
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
const args = resolveLoadArgs(parsed, (input ?? {}) as Record<string, unknown>)
|
|
871
|
+
return await resolver(args, ctx)
|
|
872
|
+
}
|
|
873
|
+
// closure legacy: (ctx, input)
|
|
874
|
+
return await loader(ctx, input)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private parseLoadCached(src: string): DslLoadSpec {
|
|
878
|
+
const cached = this.loadSpecCache.get(src)
|
|
879
|
+
if (cached !== undefined) return cached
|
|
880
|
+
const parsed = parseLoad(src)
|
|
881
|
+
this.loadSpecCache.set(src, parsed)
|
|
882
|
+
return parsed
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Compila `AuthorizeSpec` (string DSL ou closure) numa `AuthorizeFn`.
|
|
887
|
+
* String é parseada uma vez (cache por instância de runtime); a função
|
|
888
|
+
* resultante avalia a expressão contra um contexto plano com:
|
|
889
|
+
* `{ user, input, ctx, ...loaded }`
|
|
890
|
+
* Resultado é coercido a boolean — DSL não retorna `ActionError`.
|
|
891
|
+
*/
|
|
892
|
+
private compileAuthorize<In>(spec: AuthorizeSpec<In>): AuthorizeFn<In> {
|
|
893
|
+
if (typeof spec !== 'string') return spec
|
|
894
|
+
let ast = this.authorizeAstCache.get(spec)
|
|
895
|
+
if (ast === undefined) {
|
|
896
|
+
ast = parseExpression(spec)
|
|
897
|
+
this.authorizeAstCache.set(spec, ast)
|
|
898
|
+
}
|
|
899
|
+
const astCompiled = ast
|
|
900
|
+
return (ctx, input, loaded) => {
|
|
901
|
+
const evalCtx: Record<string, unknown> = {
|
|
902
|
+
user: ctx.user,
|
|
903
|
+
input,
|
|
904
|
+
ctx,
|
|
905
|
+
...(loaded ?? {}),
|
|
906
|
+
}
|
|
907
|
+
return Boolean(evalExpression(astCompiled, evalCtx))
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private buildAuditRecord(args: {
|
|
912
|
+
actionId: string
|
|
913
|
+
action: string
|
|
914
|
+
actionKind?: ActionDef['kind']
|
|
915
|
+
outcome: 'success' | 'error'
|
|
916
|
+
durationMs: number
|
|
917
|
+
ctx: ActionContext
|
|
918
|
+
input: unknown
|
|
919
|
+
output?: unknown
|
|
920
|
+
error?: ReturnType<typeof error>
|
|
921
|
+
/** Quando `'design'`, sinaliza que o mockHandler foi executado. */
|
|
922
|
+
mode?: 'design' | undefined
|
|
923
|
+
}): AuditRecord {
|
|
924
|
+
const record: AuditRecord = {
|
|
925
|
+
id: args.actionId,
|
|
926
|
+
timestamp: new Date().toISOString(),
|
|
927
|
+
action: args.action,
|
|
928
|
+
...(args.actionKind !== undefined ? { actionKind: args.actionKind } : {}),
|
|
929
|
+
outcome: args.outcome,
|
|
930
|
+
durationMs: args.durationMs,
|
|
931
|
+
provenance: args.ctx.provenance,
|
|
932
|
+
actor: actorFromProvenance(args.ctx.provenance, args.ctx.user),
|
|
933
|
+
tenant: args.ctx.tenantId,
|
|
934
|
+
input: args.input,
|
|
935
|
+
severity: args.outcome === 'success' ? 'info' : severityForError(args.error),
|
|
936
|
+
}
|
|
937
|
+
if (args.output !== undefined) record.output = args.output
|
|
938
|
+
if (args.error !== undefined) record.error = args.error
|
|
939
|
+
if (args.mode !== undefined) record.meta = { mode: args.mode }
|
|
940
|
+
return record
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private buildMeta(
|
|
944
|
+
actionId: string,
|
|
945
|
+
actionName: string,
|
|
946
|
+
durationMs: number,
|
|
947
|
+
requestId?: string,
|
|
948
|
+
): ResultMeta {
|
|
949
|
+
const meta: ResultMeta = {
|
|
950
|
+
actionId,
|
|
951
|
+
action: actionName,
|
|
952
|
+
durationMs,
|
|
953
|
+
}
|
|
954
|
+
if (requestId !== undefined) meta.requestId = requestId
|
|
955
|
+
return meta
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private errorResult(
|
|
959
|
+
actionName: string,
|
|
960
|
+
err: ReturnType<typeof error>,
|
|
961
|
+
durationMs: number,
|
|
962
|
+
requestId?: string,
|
|
963
|
+
): ActionResult<never> {
|
|
964
|
+
return {
|
|
965
|
+
ok: false,
|
|
966
|
+
error: err,
|
|
967
|
+
meta: this.buildMeta(generateId(), actionName, durationMs, requestId),
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private subscribeReaction(reaction: ReactionDef<any>): void {
|
|
972
|
+
if (this.eventBus?.subscribe === undefined) {
|
|
973
|
+
throw error({
|
|
974
|
+
code: 'runtime.subscribe_unsupported',
|
|
975
|
+
category: 'internal',
|
|
976
|
+
message: 'EventBusAdapter does not support subscribe()',
|
|
977
|
+
})
|
|
978
|
+
}
|
|
979
|
+
const patterns = Array.isArray(reaction.on) ? reaction.on : [reaction.on]
|
|
980
|
+
for (const pattern of patterns) {
|
|
981
|
+
const unsub = this.eventBus.subscribe(pattern, (event) => {
|
|
982
|
+
void this.runReaction(reaction, event)
|
|
983
|
+
})
|
|
984
|
+
this.disposeCallbacks.push(() => unsub())
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
private registerSchedule(schedule: ScheduleDef): void {
|
|
989
|
+
if (schedule.enabled === false) {
|
|
990
|
+
this.log.info('schedule disabled', { schedule: schedule.name })
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
// start() já validou que this.scheduler existe quando há schedules.
|
|
994
|
+
const scheduler = this.scheduler as SchedulerAdapter
|
|
995
|
+
const unsub = scheduler.register(schedule, async () => {
|
|
996
|
+
await this.fireSchedule(schedule)
|
|
997
|
+
})
|
|
998
|
+
this.disposeCallbacks.push(() => unsub())
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private async fireSchedule(schedule: ScheduleDef): Promise<void> {
|
|
1002
|
+
const input =
|
|
1003
|
+
typeof schedule.input === 'function'
|
|
1004
|
+
? await (schedule.input as () => unknown | Promise<unknown>)()
|
|
1005
|
+
: (schedule.input ?? {})
|
|
1006
|
+
await this.execute(schedule.action, input, {
|
|
1007
|
+
user: null,
|
|
1008
|
+
tenantId: null,
|
|
1009
|
+
can: () => false,
|
|
1010
|
+
provenance: { kind: 'schedule', scheduleName: schedule.name },
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
private async runReaction(
|
|
1015
|
+
reaction: ReactionDef<any>,
|
|
1016
|
+
event: DomainEvent,
|
|
1017
|
+
): Promise<void> {
|
|
1018
|
+
const provenance: Provenance = {
|
|
1019
|
+
kind: 'reaction',
|
|
1020
|
+
reactionName: reaction.name,
|
|
1021
|
+
sourceActionId: event.source.actionId,
|
|
1022
|
+
eventType: event.type,
|
|
1023
|
+
}
|
|
1024
|
+
const ctx: ReactionContext = {
|
|
1025
|
+
user: event.actor.id !== null ? ({ id: event.actor.id } as User) : null,
|
|
1026
|
+
tenantId: event.tenant ?? null,
|
|
1027
|
+
db: this.data?.contextExtension().db,
|
|
1028
|
+
log: this.log.child({
|
|
1029
|
+
reaction: reaction.name,
|
|
1030
|
+
eventType: event.type,
|
|
1031
|
+
provenance: 'reaction',
|
|
1032
|
+
}),
|
|
1033
|
+
emit: noopEmit,
|
|
1034
|
+
storage: this.storage ?? null,
|
|
1035
|
+
// Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
|
|
1036
|
+
// padrão — nada de LLM disparado por evento chamando action gated sem gate explícito).
|
|
1037
|
+
ai: this.bindAi({
|
|
1038
|
+
user: event.actor.id !== null ? ({ id: event.actor.id } as User) : null,
|
|
1039
|
+
tenantId: event.tenant ?? null,
|
|
1040
|
+
can: async () => false,
|
|
1041
|
+
meta: {},
|
|
1042
|
+
}),
|
|
1043
|
+
provenance,
|
|
1044
|
+
meta: {},
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
if (reaction.authorize !== undefined) {
|
|
1049
|
+
const allowed = await reaction.authorize(ctx, event)
|
|
1050
|
+
if (!allowed) return
|
|
1051
|
+
}
|
|
1052
|
+
await reaction.handler(ctx, event)
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
const actionError = isActionError(err) ? err : normalizeError(err)
|
|
1055
|
+
ctx.log.error('reaction failed', {
|
|
1056
|
+
code: actionError.code,
|
|
1057
|
+
message: actionError.message,
|
|
1058
|
+
})
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// =============================================================================
|
|
1064
|
+
// Helpers
|
|
1065
|
+
// =============================================================================
|
|
1066
|
+
|
|
1067
|
+
// ContextBase moveu pra types.ts (compartilhado entre core e adapters).
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Resolve provenance do ContextBase, com default `{ kind: 'system', source: 'unknown' }`
|
|
1071
|
+
* quando caller não passa. Server adapter / scheduler / runtime devem sempre
|
|
1072
|
+
* passar provenance explícita; default é safety net.
|
|
1073
|
+
*/
|
|
1074
|
+
function resolveProvenance(base: ContextBase): Provenance {
|
|
1075
|
+
if (base.provenance !== undefined) return base.provenance
|
|
1076
|
+
return { kind: 'system', source: 'unknown' }
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Deriva o shape legado `actor: { id, type }` a partir de Provenance,
|
|
1081
|
+
* preservando retrocompat de AuditRecord.
|
|
1082
|
+
*/
|
|
1083
|
+
function actorFromProvenance(
|
|
1084
|
+
provenance: Provenance,
|
|
1085
|
+
user: User | null,
|
|
1086
|
+
): AuditRecord['actor'] {
|
|
1087
|
+
const base = ((): AuditRecord['actor'] => {
|
|
1088
|
+
switch (provenance.kind) {
|
|
1089
|
+
case 'http':
|
|
1090
|
+
return user !== null
|
|
1091
|
+
? { id: user.id, type: 'user' }
|
|
1092
|
+
: { id: null, type: 'user' }
|
|
1093
|
+
case 'integration':
|
|
1094
|
+
return { id: provenance.name, type: 'integration' }
|
|
1095
|
+
case 'ai-agent':
|
|
1096
|
+
return { id: provenance.agentId, type: 'integration' }
|
|
1097
|
+
case 'schedule':
|
|
1098
|
+
case 'reaction':
|
|
1099
|
+
case 'background':
|
|
1100
|
+
case 'self-observation':
|
|
1101
|
+
case 'system':
|
|
1102
|
+
return { id: user?.id ?? null, type: 'system' }
|
|
1103
|
+
}
|
|
1104
|
+
})()
|
|
1105
|
+
const meta = userMeta(user)
|
|
1106
|
+
return meta === undefined ? base : { ...base, meta }
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* name/email do user resolvido → `actor.meta` — o que todo sink re-resolvia
|
|
1111
|
+
* (id→nome) por fora. Só os dois campos de identificação: o User é shape
|
|
1112
|
+
* aberto e despejá-lo inteiro no log vazaria o que ninguém pediu.
|
|
1113
|
+
*/
|
|
1114
|
+
function userMeta(user: User | null): Record<string, unknown> | undefined {
|
|
1115
|
+
if (user === null) return undefined
|
|
1116
|
+
const meta: Record<string, unknown> = {}
|
|
1117
|
+
if (typeof user['name'] === 'string') meta['name'] = user['name']
|
|
1118
|
+
if (typeof user['email'] === 'string') meta['email'] = user['email']
|
|
1119
|
+
return Object.keys(meta).length > 0 ? meta : undefined
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function actionHasEmits(action: ActionDef): action is ActionDef & { emits: string[] } {
|
|
1123
|
+
return (
|
|
1124
|
+
(action.kind === 'simple' || action.kind === 'form') &&
|
|
1125
|
+
Array.isArray((action as any).emits)
|
|
1126
|
+
)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function severityForError(err: any): 'info' | 'warning' | 'error' {
|
|
1130
|
+
/* v8 ignore next — defensivo; buildAuditRecord só chama com err presente */
|
|
1131
|
+
if (err === undefined) return 'info'
|
|
1132
|
+
if (err.severity === 'fatal') return 'error'
|
|
1133
|
+
if (err.severity === 'warning') return 'warning'
|
|
1134
|
+
return 'error'
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Placeholder pra `ctx.emit` em reactions. Reaction chain (reaction emite
|
|
1139
|
+
* eventos novos que disparam outras reactions) entra em v1+.
|
|
1140
|
+
*/
|
|
1141
|
+
const noopEmit: ActionContext['emit'] = async () => undefined
|
|
1142
|
+
|
|
1143
|
+
function generateId(): string {
|
|
1144
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
1145
|
+
return crypto.randomUUID()
|
|
1146
|
+
}
|
|
1147
|
+
// fallback determinístico-suficiente quando crypto não disponível
|
|
1148
|
+
return `id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Único ponto do core que lê `process.env`. Usado pelo `execute()` pra
|
|
1153
|
+
* decidir entre `handler` real e `mockHandler`. Isolado em função pra
|
|
1154
|
+
* testabilidade — testes podem `vi.stubEnv('OPUS_MODE', ...)`.
|
|
1155
|
+
*
|
|
1156
|
+
* Mantém a invariante "core nunca lê env" intacta no resto do código:
|
|
1157
|
+
* adapters/config seguem recebendo valores literais.
|
|
1158
|
+
*
|
|
1159
|
+
* Exportado (fora do index público) pro harness de `testing` aplicar a MESMA
|
|
1160
|
+
* régua no `runAction` — a semântica de design mora num lugar só.
|
|
1161
|
+
*/
|
|
1162
|
+
export function isOpusDesignMode(): boolean {
|
|
1163
|
+
// Acesso via globalThis: defensivo pra runtimes sem `process` (browser, edge) E
|
|
1164
|
+
// não exige @types/node no consumidor que compila este source (apps web).
|
|
1165
|
+
const g = globalThis as { process?: { env?: Record<string, string | undefined> } }
|
|
1166
|
+
return g.process?.env?.['OPUS_MODE'] === 'design'
|
|
1167
|
+
}
|