From 665815045d821fffb0a71bbe080433dbb0d547b5 Mon Sep 17 00:00:00 2001 From: Adrian Besleaga Date: Sat, 15 Aug 2026 15:45:59 +0200 Subject: [PATCH] fix architecture boundaries and harden event delivery --- README.md | 3 +- demo/ai-todo-assistant/.env.example | 8 +- .../ARCHITECTURE-REMEDIATION-TODO.md | 19 +- demo/ai-todo-assistant/ARCHITECTURE.md | 27 ++- demo/ai-todo-assistant/OPERATIONS.md | 14 +- demo/ai-todo-assistant/README.md | 5 +- demo/ai-todo-assistant/TODO.md | 5 +- .../composition/generated/frontend-modules.ts | 6 + .../feature.definition.json | 5 +- .../frontend/contribution.tsx | 18 +- .../frontend/use-generate-task-plan.ts | 39 +--- .../contracts/v1/task-taxonomy.ts | 15 ++ .../contexts/task-management/domain/task.ts | 26 +++ .../IMPLEMENTATION_PLAN.md | 10 + .../accept-task-suggestion/backend/feature.ts | 62 ++++++ .../contracts/input.schema.json | 3 + .../contracts/output.schema.json | 10 + .../feature.definition.json | 52 +++++ .../frontend/contribution.tsx | 67 ++++++ .../accept-task-suggestion/manifest.ts | 30 +++ .../frontend/use-change-task-status.ts | 20 +- .../features/create-task/backend/feature.ts | 19 +- .../create-task/frontend/use-create-task.ts | 14 +- .../delete-task/frontend/use-delete-task.ts | 20 +- .../core/architecture/.dependency-cruiser.cjs | 20 ++ .../check-import-boundary-fixtures.mjs | 76 +++++++ .../core/architecture/dependency-policy.json | 6 +- .../core/backend/events/event-stream.ts | 190 ++++++++++++++++++ .../runtime/mongo-platform-capabilities.ts | 4 + demo/ai-todo-assistant/core/backend/server.ts | 7 + demo/ai-todo-assistant/core/frontend/api.ts | 72 +++++-- .../core/frontend/app-model.ts | 5 + .../core/frontend/auth/oidc.ts | 7 +- .../frontend/commands/command-id-store.ts | 33 +++ .../frontend/events/FrontendEventProvider.tsx | 47 ++++- .../core/frontend/events/event-stream.ts | 64 ++++++ .../core/frontend/query/react-query.tsx | 33 +++ .../core/frontend/slots/slot-registry.ts | 2 + .../core/frontend/vite-env.d.ts | 5 + .../core/shared/architecture-enums.ts | 2 + demo/ai-todo-assistant/package.json | 3 +- demo/ai-todo-assistant/tests/features.test.ts | 19 ++ .../tests/frontend-runtime.test.ts | 31 +++ .../tests/mongo.integration.test.ts | 11 + docs/architecture.md | 2 +- 45 files changed, 1013 insertions(+), 123 deletions(-) create mode 100644 demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/IMPLEMENTATION_PLAN.md create mode 100644 demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/backend/feature.ts create mode 100644 demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/input.schema.json create mode 100644 demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/output.schema.json create mode 100644 demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/feature.definition.json create mode 100644 demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/frontend/contribution.tsx create mode 100644 demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/manifest.ts create mode 100644 demo/ai-todo-assistant/core/architecture/check-import-boundary-fixtures.mjs create mode 100644 demo/ai-todo-assistant/core/backend/events/event-stream.ts create mode 100644 demo/ai-todo-assistant/core/frontend/commands/command-id-store.ts create mode 100644 demo/ai-todo-assistant/core/frontend/events/event-stream.ts diff --git a/README.md b/README.md index 6fc3573..337d0a9 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,8 @@ AIFA relies on executable guardrails, not documentation alone: - transactional idempotency, audit records, and an event outbox; - architecture, behavior, integration, and bundle checks. -The current feature set covers task creation, listing, status changes, deletion, AI-generated task plans, and AI workspace settings. +The current feature set covers task creation, listing, status changes, deletion, AI-generated task +plans, explicit suggestion acceptance, and AI workspace settings. ## Repository map diff --git a/demo/ai-todo-assistant/.env.example b/demo/ai-todo-assistant/.env.example index 959851e..5fea873 100644 --- a/demo/ai-todo-assistant/.env.example +++ b/demo/ai-todo-assistant/.env.example @@ -8,10 +8,16 @@ OLLAMA_MODEL=llama3.2 OIDC_ISSUER_URL= OIDC_AUDIENCE= OIDC_JWKS_URL= +# Browser OIDC settings are embedded by Vite at build time. +VITE_OIDC_ISSUER_URL= +VITE_OIDC_CLIENT_ID= +VITE_OIDC_AUDIENCE= +VITE_OIDC_REDIRECT_URI= +VITE_OIDC_SCOPES=TaskRead TaskWrite TaskDelete AssistantPlanGenerate SettingsRead EventRead # Production MCP calls use this OIDC access token. Never commit a real token. MCP_ACCESS_TOKEN= IDEMPOTENCY_LEASE_MS=300000 DEMO_TENANT_ID=local-tenant DEMO_USER_ID=local-user -# Optional authenticated SSE endpoint for domain events created outside this browser. +# Optional override; defaults to VITE_API_BASE_URL + /api/events. VITE_EVENT_STREAM_URL= diff --git a/demo/ai-todo-assistant/ARCHITECTURE-REMEDIATION-TODO.md b/demo/ai-todo-assistant/ARCHITECTURE-REMEDIATION-TODO.md index 0bd828c..8d16813 100644 --- a/demo/ai-todo-assistant/ARCHITECTURE-REMEDIATION-TODO.md +++ b/demo/ai-todo-assistant/ARCHITECTURE-REMEDIATION-TODO.md @@ -135,7 +135,7 @@ Status legend: - [x] Configuration validates ports and URLs; liveness and MongoDB readiness are separate endpoints. - [x] Tests now cover contract boundaries, capability failures, tenant overrides, event schemas, fencing, and concurrent outbox claims. - [x] Production contributions are code-split and MUI icons use direct imports. - - The build now transforms about 957 modules; the largest chunk is about 358 kB. + - The build now transforms about 972 modules; the largest chunk is about 368 kB. - CI enforces a 400 kB largest-chunk and 600 kB total-JavaScript budget. - [x] Generated contracts are used by frontend DTOs and selected feature inputs/outputs; runtime schemas remain authoritative. - Schema titles produce stable names, and generation deduplicates identical declarations while rejecting conflicts. @@ -152,7 +152,22 @@ Status legend: ## Verification status - [x] `npm run check` -- [x] `npm test` — 18 tests passing +- [x] `npm test` — 23 tests passing - [x] `npm run build` — server and web artifacts produced - [x] Built-server smoke test - [x] Browser end-to-end verification + +## Follow-up review remediation — 2026-08-15 + +- [x] Browser command hooks retain a stable command ID after unknown transport outcomes and use a + new ID only after a success or definitive HTTP failure. +- [x] Browser OIDC requests explicit configurable application scopes, including `EventRead` for + the live event boundary. +- [x] Native `EventSource` was replaced with bearer-authenticated fetch streaming; `/api/events` + reads tenant-scoped committed outbox events and supports opaque reconnect cursors. +- [x] Dependency rules now reject feature/domain imports from `core/backend`, Node runtime modules, + MongoDB, MCP, identity, environment, and provider packages; executable negative fixtures guard + the rules themselves. +- [x] Suggestion acceptance is a separate discovered feature composed through the typed + `TaskSuggestionActions` slot. AI Planning no longer knows the Task Management route, hook, or + cache tag. diff --git a/demo/ai-todo-assistant/ARCHITECTURE.md b/demo/ai-todo-assistant/ARCHITECTURE.md index cf97245..08e53b1 100644 --- a/demo/ai-todo-assistant/ARCHITECTURE.md +++ b/demo/ai-todo-assistant/ARCHITECTURE.md @@ -42,6 +42,7 @@ demo/ai-todo-assistant/ domain/ # Task invariants and value objects features/ create-task/ + accept-task-suggestion/ list-tasks/ change-task-status/ delete-task/ @@ -81,7 +82,7 @@ The task-status transition invariant lives here. Features invoke it; they do not ### AI Planning -Owns the persisted `TaskPlan` aggregate, validated task-plan suggestions, and the rules for safely turning provider output into task categories and priorities. Generating a plan creates a tenant-owned review record and emits `TaskPlanGeneratedV1`; it does not create tasks. Accepted suggestions use the Create Task feature boundary. +Owns the persisted `TaskPlan` aggregate, validated task-plan suggestions, and the rules for safely turning provider output into task categories and priorities. Generating a plan creates a tenant-owned review record and emits `TaskPlanGeneratedV1`; it does not create tasks. The independent Task Management `accept-task-suggestion` feature consumes the published suggestion contract and contributes its action through the typed `TaskSuggestionActions` slot. AI Planning therefore does not know an acceptance route or import another feature's hook. Contexts do not import each other’s domain or feature modules. They communicate using versioned contracts or domain events. @@ -91,7 +92,7 @@ Contexts do not import each other’s domain or feature modules. They communicat Every request—HTTP, MCP, browser, or provider callback—must resolve to an `Actor` containing a `TenantId`, `UserId`, and enum-backed permission scopes. Anonymous mutation is prohibited. -Browser authentication uses an OpenID Connect authorization-code flow with PKCE. The remote MCP endpoint uses Streamable HTTP and acts as an OAuth 2.1 protected resource: it validates bearer-token audience, expiry, tenant, user, and scopes on every request. A local stdio MCP connection obtains its credentials from the local environment and is restricted to the configured tenant. Token revocation, client registration, and scope assignment are owned by the identity adapter in core. +Browser authentication uses an OpenID Connect authorization-code flow with PKCE. The browser requests the application scopes configured by `VITE_OIDC_SCOPES`; live event delivery additionally requires `EventRead`. The remote MCP endpoint uses Streamable HTTP and acts as an OAuth 2.1 protected resource: it validates bearer-token audience, expiry, tenant, user, and scopes on every request. A local stdio MCP connection obtains its credentials from the local environment and is restricted to the configured tenant. Token revocation, client registration, and scope assignment are owned by the identity adapter in core. ### Authorization Middleware @@ -109,6 +110,10 @@ MCP authentication maps each client connection to an actor and only exposes tool Every mutation has a caller-supplied idempotency key and expected task version where relevant. The idempotency store atomically claims a key before feature execution, records a fenced execution identifier, and persists the command result on completion. A bounded lease lets a retry recover from a crashed worker without allowing an earlier execution to overwrite the recovered result. HTTP or MCP retries return the original completed result without repeating persistence or audit work. +Browser command hooks allocate one command identifier for each user intent. A successful response or +a definitive HTTP failure settles that identifier; an unknown transport outcome retains it so a +retry replays the same durable command instead of creating a duplicate task or plan. + Features use typed failures for `VersionConflict`, `IdempotencyKeyReuse`, `NotAuthorized`, and `ConfirmationRequired`. These failures are part of each adapter contract, not raw infrastructure errors. ## Events, Audit, and Outbox @@ -120,7 +125,12 @@ Within the MongoDB transaction for a successful mutation, the runtime writes the - `TaskDeletedV1` - `TaskPlanGeneratedV1` -An outbox publisher delivers events asynchronously and records delivery state. Consumers receive versioned event contracts, never direct imports from another context. This preserves reliability without coupling feature implementations. +An outbox publisher delivers events asynchronously and records delivery state. The authenticated +`/api/events` SSE boundary also reads the tenant's committed outbox records in event order for live +browser synchronization. Opaque cursors allow reconnects to resume without relying on browser +cookies or exposing tenant identity in the request. Consumers receive versioned event contracts, +never direct imports from another context. This preserves reliability without coupling feature +implementations. ## Feature Slice Contract @@ -224,7 +234,7 @@ browser. The successful command hook invalidates affected tags without waiting f outbox event to return to that browser. Tasks may also be created by MCP clients, another browser, another actor, or background processing. -For those cases, a generic core SSE or WebSocket adapter delivers validated versioned domain events +For those cases, the generic core authenticated-fetch SSE adapter delivers validated versioned domain events to dynamically registered frontend event consumers. A Task Management consumer maps `TaskCreatedV1`, `TaskStatusChangedV1`, and `TaskDeletedV1` to the same `TaskCollection` invalidation. Receiving both the local-success invalidation and its later domain event is expected; @@ -245,14 +255,15 @@ Focused frontend tests must verify that: 3. each domain-event consumer accepts its declared version and maps it to the correct tags; 4. duplicate local and remote invalidations are harmless and request work is deduplicated; 5. independently mounted slot contributions observe the same cache without importing each other; -6. actor or tenant changes clear cached data and terminate the previous event subscription. +6. actor or tenant changes clear cached data and terminate the previous event subscription; +7. reconnects reuse the last opaque event cursor and event reads remain tenant scoped. ## Dependency Boundaries ```txt core ────────────────────────────► core only context domain ──────────────────► core + its own domain -feature ─────────────────────────► core + itself + own domain + versioned context contracts +feature ─────────────────────────► core shared/frontend APIs + itself + own domain + versioned context contracts adapter (HTTP/MCP/UI) ───────────► core + its registered feature contract cross-context work ──────────────► versioned contracts or domain events only ``` @@ -268,6 +279,10 @@ Forbidden dependencies: `npm run check:imports` runs dependency-cruiser in CI to enforce import boundaries and circular-import rules. `validate-feature-definitions.ts` validates feature JSON, enum references, contract files, routes, MCP tools, security invariants, and the declared dependency graph. +Feature and domain code are explicitly denied imports from `core/backend`, Node runtime modules, +MongoDB, MCP, identity, environment, and provider packages. `npm run check:import-fixtures` executes +deliberately invalid source fixtures so a weakened dependency rule makes the architecture gate fail. + ## AIFA Runtime Rules Every feature receives only: diff --git a/demo/ai-todo-assistant/OPERATIONS.md b/demo/ai-todo-assistant/OPERATIONS.md index dc6684f..756da42 100644 --- a/demo/ai-todo-assistant/OPERATIONS.md +++ b/demo/ai-todo-assistant/OPERATIONS.md @@ -14,7 +14,19 @@ Probe `/health` for API liveness. Alert on Mongo connection failures, outbox doc ## Security and privacy -Production callers must provide a signed OIDC bearer token. The API verifies its issuer, audience, and signature using `OIDC_ISSUER_URL`, `OIDC_AUDIENCE`, and `OIDC_JWKS_URL`; it reads `sub`, `tenant_id`, and space-separated `scope` claims. The `x-aifa-*` headers are accepted only outside production. Set the short-lived `MCP_ACCESS_TOKEN` secret for the local stdio MCP bridge. Treat Ollama prompts and responses as tenant data; run local Ollama on trusted infrastructure and do not enable a remote model provider without a data-processing review. +Production callers must provide a signed OIDC bearer token. The API verifies its issuer, audience, +and signature using `OIDC_ISSUER_URL`, `OIDC_AUDIENCE`, and `OIDC_JWKS_URL`; it reads `sub`, +`tenant_id`, and space-separated `scope` claims. Configure the browser authorization request with +`VITE_OIDC_SCOPES`; it must include the application scopes required by the enabled features and +`EventRead` for live updates. The `x-aifa-*` headers are accepted only outside production. Set the +short-lived `MCP_ACCESS_TOKEN` secret for the local stdio MCP bridge. Treat Ollama prompts and +responses as tenant data; run local Ollama on trusted infrastructure and do not enable a remote +model provider without a data-processing review. + +The browser consumes `/api/events` using authenticated fetch streaming. The endpoint requires +`EventRead`, derives the tenant from the verified actor, and resumes from an opaque cursor carried +in `Last-Event-ID`. Preserve the tenant event-stream index and outbox retention window long enough +for the expected reconnect interval. ## Incident response diff --git a/demo/ai-todo-assistant/README.md b/demo/ai-todo-assistant/README.md index 150e4c3..dd165b9 100644 --- a/demo/ai-todo-assistant/README.md +++ b/demo/ai-todo-assistant/README.md @@ -50,14 +50,15 @@ Each feature lives under `contexts//features//` and owns - `contracts/` — JSON Schema input and output contracts. - `IMPLEMENTATION_PLAN.md` — the human-readable implementation guide. -The current feature set includes Create Task, List Tasks, Change Task Status, Delete Task, Generate Task Plan, and Manage AI Settings. +The current feature set includes Create Task, List Tasks, Change Task Status, Delete Task, +Generate Task Plan, Accept Task Suggestion, and Manage AI Settings. ### Typed UI slots The React shell owns layout and composition, while feature slices contribute their own UI through typed slots such as: - `AppHeader`, `AppNavigation`, `AppContent`, and `AppFooter` -- `TaskComposer`, `TaskList`, `TaskRowActions`, and `AssistantPanel` +- `TaskComposer`, `TaskList`, `TaskRowActions`, `AssistantPanel`, and `TaskSuggestionActions` - `SettingsPanel` Frontend contributions are discovered automatically. Adding a feature does not require maintaining a central list of product components. diff --git a/demo/ai-todo-assistant/TODO.md b/demo/ai-todo-assistant/TODO.md index 503fc47..c97ad6c 100644 --- a/demo/ai-todo-assistant/TODO.md +++ b/demo/ai-todo-assistant/TODO.md @@ -47,11 +47,13 @@ Update this checklist as work is completed. Read [ARCHITECTURE.md](ARCHITECTURE. - [x] Implement persisted generated TaskPlan records and initial review state. - [x] Implement the Ollama provider adapter with structured-output validation, timeouts, and safe local fallback. - [x] Implement the Generate Task Plan feature: backend, MUI assistant panel, MCP tool, manifest, and tests. -- [x] Implement suggestion acceptance through the Create Task API boundary. +- [x] Implement suggestion acceptance as an independent Task Management feature composed through the typed `TaskSuggestionActions` slot. ## React Application - [x] Introduce the generic frontend query/event runtime, versioned context cache tags, and feature-local query/command hooks; replace workspace-wide command orchestration with semantic cache invalidation. +- [x] Preserve browser command IDs across unknown transport outcomes and settle them only after a definitive result. +- [x] Deliver tenant-scoped outbox events through an authenticated, resumable SSE boundary. - [x] Create the MUI theme, React shell, API client, typed slot registry, and dynamic frontend discovery. - [x] Build the category-based task board. - [x] Build task-category and task-status filtering. @@ -64,6 +66,7 @@ Update this checklist as work is completed. Read [ARCHITECTURE.md](ARCHITECTURE. ## Quality and Release Readiness +- [x] Centralize command transport and task construction, remove taxonomy casts, and clean up event-stream lifecycle helpers. - [x] Add feature-level runtime behavioral tests. - [x] Add disposable-database MongoDB integration coverage (CI service and local opt-in script). - [x] Add HTTP authentication/idempotency boundary and task tenant/concurrency integration coverage; MCP parity remains covered through manifest-driven adapter composition. diff --git a/demo/ai-todo-assistant/composition/generated/frontend-modules.ts b/demo/ai-todo-assistant/composition/generated/frontend-modules.ts index 1367630..54e1e7a 100644 --- a/demo/ai-todo-assistant/composition/generated/frontend-modules.ts +++ b/demo/ai-todo-assistant/composition/generated/frontend-modules.ts @@ -6,6 +6,12 @@ export const frontendModules = [ eventConsumers: [] as const, load: () => import("../../contexts/ai-planning/features/generate-task-plan/frontend/contribution.js"), }, + { + id: "accept-task-suggestion", + slots: ["TaskSuggestionActions"] as const, + eventConsumers: [{"name":"invalidate-task-collection-after-suggestion-acceptance","eventType":"TaskCreatedV1","contract":"task-management/contracts/events/v1/task-created.schema.json"}] as const, + load: () => import("../../contexts/task-management/features/accept-task-suggestion/frontend/contribution.js"), + }, { id: "change-task-status", slots: ["TaskRowActions"] as const, diff --git a/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/feature.definition.json b/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/feature.definition.json index a314454..af82719 100644 --- a/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/feature.definition.json +++ b/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/feature.definition.json @@ -5,8 +5,7 @@ "businessNeed": "Save users from manually decomposing goals by generating reviewable, categorized task suggestions from natural language.", "dependsOn": [], "consumesContracts": [ - "task-management/contracts/v1/task-taxonomy.schema.json", - "task-management/contracts/v1/task-cache.schema.json" + "task-management/contracts/v1/task-taxonomy.schema.json" ], "backend": { "route": "/api/assistant/task-plan", @@ -49,7 +48,7 @@ { "id": "AIP-3", "description": "Register the MUI suggestion-review panel.", - "acceptanceCriterion": "Accepted suggestions use the create-task API boundary." + "acceptanceCriterion": "Acceptance actions compose through the typed TaskSuggestionActions slot without a direct feature call." } ] } diff --git a/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/contribution.tsx b/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/contribution.tsx index bc17206..840dd3e 100644 --- a/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/contribution.tsx +++ b/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/contribution.tsx @@ -20,8 +20,9 @@ import { } from "../../../../task-management/contracts/v1/task-taxonomy.js"; import type { TaskPlanSuggestionV1 } from "../../../../../core/shared/generated/contracts.js"; import type { SlotContribution } from "../../../../../core/frontend/slots/slot-registry.js"; +import { Slot } from "../../../../../core/frontend/slots/Slot.js"; import { SlotName } from "../../../../../core/shared/architecture-enums.js"; -import { useAcceptTaskSuggestion, useGenerateTaskPlan } from "./use-generate-task-plan.js"; +import { useGenerateTaskPlan } from "./use-generate-task-plan.js"; function Planner() { const [goal, setGoal] = useState(""); @@ -29,7 +30,6 @@ function Planner() { const [priority, setPriority] = useState(TaskPriority.Medium); const [suggestions, setSuggestions] = useState([]); const generatePlan = useGenerateTaskPlan(); - const acceptSuggestion = useAcceptTaskSuggestion(); return ( - {(generatePlan.error || acceptSuggestion.error) && ( + {generatePlan.error && ( - {(generatePlan.error ?? acceptSuggestion.error)?.message} + {generatePlan.error.message} )} - + ))} diff --git a/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/use-generate-task-plan.ts b/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/use-generate-task-plan.ts index 1f8964f..c6ac897 100644 --- a/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/use-generate-task-plan.ts +++ b/demo/ai-todo-assistant/contexts/ai-planning/features/generate-task-plan/frontend/use-generate-task-plan.ts @@ -1,10 +1,6 @@ -import { api } from "../../../../../core/frontend/api.js"; -import { useMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; -import type { - TaskPlanSuggestionV1, - TaskViewV1, -} from "../../../../../core/shared/generated/contracts.js"; -import { TaskCacheTag } from "../../../../task-management/contracts/v1/task-cache.js"; +import { commandApi } from "../../../../../core/frontend/api.js"; +import { useCommandMutation } from "../../../../../core/frontend/query/react-query.js"; +import type { TaskPlanSuggestionV1 } from "../../../../../core/shared/generated/contracts.js"; import type { TaskCategory, TaskPriority, @@ -17,29 +13,14 @@ interface GenerateTaskPlanInput { } export function useGenerateTaskPlan() { - return useMutation({ - mutationFn: async (input) => + return useCommandMutation({ + commandFn: async (input, commandId) => ( - await api<{ suggestions: TaskPlanSuggestionV1[] }>("/api/assistant/task-plan", { - method: "POST", - headers: { "idempotency-key": crypto.randomUUID() }, - body: JSON.stringify(input), - }) + await commandApi<{ suggestions: TaskPlanSuggestionV1[] }>( + "/api/assistant/task-plan", + commandId, + input, + ) ).suggestions, }); } - -export function useAcceptTaskSuggestion() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ title, category, priority }) => - ( - await api<{ task: TaskViewV1 }>("/api/tasks", { - method: "POST", - headers: { "idempotency-key": crypto.randomUUID() }, - body: JSON.stringify({ title, category, priority }), - }) - ).task, - onSuccess: () => queryClient.invalidateTags([TaskCacheTag.Collection]), - }); -} diff --git a/demo/ai-todo-assistant/contexts/task-management/contracts/v1/task-taxonomy.ts b/demo/ai-todo-assistant/contexts/task-management/contracts/v1/task-taxonomy.ts index 8230e1f..6ab7a0d 100644 --- a/demo/ai-todo-assistant/contexts/task-management/contracts/v1/task-taxonomy.ts +++ b/demo/ai-todo-assistant/contexts/task-management/contracts/v1/task-taxonomy.ts @@ -16,3 +16,18 @@ export enum TaskStatus { InProgress = "InProgress", Completed = "Completed", } + +function parseEnumValue( + values: readonly Value[], + value: string, +): Value | undefined { + return values.find((candidate) => candidate === value); +} + +export function parseTaskCategory(value: string): TaskCategory | undefined { + return parseEnumValue(Object.values(TaskCategory), value); +} + +export function parseTaskPriority(value: string): TaskPriority | undefined { + return parseEnumValue(Object.values(TaskPriority), value); +} diff --git a/demo/ai-todo-assistant/contexts/task-management/domain/task.ts b/demo/ai-todo-assistant/contexts/task-management/domain/task.ts index 04db1bc..7e4eeee 100644 --- a/demo/ai-todo-assistant/contexts/task-management/domain/task.ts +++ b/demo/ai-todo-assistant/contexts/task-management/domain/task.ts @@ -18,6 +18,32 @@ export interface Task { completedAt: string | null; } +export interface NewTaskDraft { + id: string; + title: string; + category: TaskCategory; + priority: TaskPriority; + now: string; +} + +export function normalizeTaskTitle(value: string): string | undefined { + return value.trim() || undefined; +} + +export function createTaskDraft(input: NewTaskDraft): Omit { + return { + id: input.id, + title: input.title, + category: input.category, + priority: input.priority, + status: TaskStatus.Todo, + version: 1, + createdAt: input.now, + updatedAt: input.now, + completedAt: null, + }; +} + export type TaskTransitionResult = | { ok: true; task: Task } | { ok: false; from: TaskStatus; to: TaskStatus }; diff --git a/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/IMPLEMENTATION_PLAN.md b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..68c6132 --- /dev/null +++ b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/IMPLEMENTATION_PLAN.md @@ -0,0 +1,10 @@ +# Accept Task Suggestion + +This feature consumes the published AI Planning suggestion contract and turns one explicitly +reviewed suggestion into a tenant-bound task. AI Planning supplies the suggestion to the typed +`TaskSuggestionActions` slot; it does not know which feature implements acceptance or which route +is invoked. + +The mutation uses the standard task persistence, clock, ID, and domain-event capabilities. It +emits `TaskCreatedV1`, invalidates the task collection after a local success, and preserves the +browser command ID across unknown transport outcomes. diff --git a/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/backend/feature.ts b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/backend/feature.ts new file mode 100644 index 0000000..d2361e9 --- /dev/null +++ b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/backend/feature.ts @@ -0,0 +1,62 @@ +import { + CapabilityName, + DomainEventType, + ErrorCode, +} from "../../../../../core/shared/architecture-enums.js"; +import { FeatureName } from "../../../../../core/shared/generated/feature-names.js"; +import type { + AcceptTaskSuggestionOutputV1, + TaskPlanSuggestionV1, +} from "../../../../../core/shared/generated/contracts.js"; +import type { AifaFeature } from "../../../../../core/shared/aifa.js"; +import { + createTaskDraft, + normalizeTaskTitle, +} from "../../../domain/task.js"; +import type { TaskCapabilities } from "../../../domain/task-capabilities.js"; +import { toTaskView } from "../../../contracts/v1/task-view.js"; +import { + parseTaskCategory, + parseTaskPriority, +} from "../../../contracts/v1/task-taxonomy.js"; + +type AcceptTaskSuggestionCapabilities = Pick< + TaskCapabilities, + "TaskCreate" | "IdCreate" | "ClockNow" | "DomainEventEmit" +>; + +export const feature: AifaFeature< + TaskPlanSuggestionV1, + AcceptTaskSuggestionOutputV1, + AcceptTaskSuggestionCapabilities +> = { + name: FeatureName.AcceptTaskSuggestion, + capabilities: [ + CapabilityName.TaskCreate, + CapabilityName.IdCreate, + CapabilityName.ClockNow, + CapabilityName.DomainEventEmit, + ], + async execute(context) { + const title = normalizeTaskTitle(context.input.title); + if (!title) return context.fail(ErrorCode.InvalidInput, "Task title is required"); + const category = parseTaskCategory(context.input.category); + const priority = parseTaskPriority(context.input.priority); + if (!category || !priority) + return context.fail(ErrorCode.InvalidInput, "Task taxonomy value is invalid"); + const now = await context.capabilities.ClockNow(); + const task = createTaskDraft({ + id: await context.capabilities.IdCreate(), + title, + category, + priority, + now, + }); + const created = await context.capabilities.TaskCreate({ task }); + await context.capabilities.DomainEventEmit({ + eventType: DomainEventType.TaskCreatedV1, + data: { taskId: created.id }, + }); + return context.ok({ task: toTaskView(created) }); + }, +}; diff --git a/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/input.schema.json b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/input.schema.json new file mode 100644 index 0000000..f62a036 --- /dev/null +++ b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/input.schema.json @@ -0,0 +1,3 @@ +{ + "$ref": "https://aifa.dev/ai-planning/contracts/v1/task-plan-suggestion.schema.json" +} diff --git a/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/output.schema.json b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/output.schema.json new file mode 100644 index 0000000..29ce0bd --- /dev/null +++ b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/contracts/output.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AcceptTaskSuggestionOutputV1", + "type": "object", + "additionalProperties": false, + "required": ["task"], + "properties": { + "task": { "$ref": "https://aifa.dev/task-management/contracts/v1/task-view.schema.json" } + } +} diff --git a/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/feature.definition.json b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/feature.definition.json new file mode 100644 index 0000000..64fb9b4 --- /dev/null +++ b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/feature.definition.json @@ -0,0 +1,52 @@ +{ + "$schema": "../../../../core/architecture/feature-definition.schema.json", + "name": "accept-task-suggestion", + "boundedContext": "task-management", + "businessNeed": "Let a person explicitly turn one reviewed AI plan suggestion into a governed task.", + "dependsOn": ["generate-task-plan"], + "consumesContracts": [ + "ai-planning/contracts/v1/task-plan-suggestion.schema.json", + "task-management/contracts/events/v1/task-created.schema.json" + ], + "backend": { + "route": "/api/task-plan-suggestions/accept", + "method": "POST", + "capabilities": ["TaskCreate", "IdCreate", "ClockNow", "DomainEventEmit"], + "mcp": { "exposed": false } + }, + "frontend": { + "slots": ["TaskSuggestionActions"], + "eventConsumers": [ + { + "name": "invalidate-task-collection-after-suggestion-acceptance", + "eventType": "TaskCreatedV1", + "contract": "task-management/contracts/events/v1/task-created.schema.json" + } + ] + }, + "contracts": { + "input": "contracts/input.schema.json", + "output": "contracts/output.schema.json" + }, + "events": ["task-management/contracts/events/v1/task-created.schema.json"], + "security": { + "mutation": true, + "actorRequired": true, + "idempotent": true, + "requiresConfirmation": false, + "concurrency": "none", + "mcpScopes": ["TaskWrite"] + }, + "workItems": [ + { + "id": "ATS-1", + "description": "Expose suggestion acceptance through its own governed feature contract.", + "acceptanceCriterion": "AI Planning contributes suggestion data without importing or calling Task Management feature code." + }, + { + "id": "ATS-2", + "description": "Compose the acceptance action through a typed suggestion action slot.", + "acceptanceCriterion": "The accepting contribution is independently discovered and invalidates the task collection after success." + } + ] +} diff --git a/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/frontend/contribution.tsx b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/frontend/contribution.tsx new file mode 100644 index 0000000..a406d29 --- /dev/null +++ b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/frontend/contribution.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { Alert, Button, Stack } from "@mui/material"; +import { commandApi } from "../../../../../core/frontend/api.js"; +import { useCommandMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; +import type { FrontendEventConsumer } from "../../../../../core/frontend/events/event-registry.js"; +import type { SlotContribution } from "../../../../../core/frontend/slots/slot-registry.js"; +import type { + TaskPlanSuggestionV1, + TaskViewV1, +} from "../../../../../core/shared/generated/contracts.js"; +import { DomainEventType, SlotName } from "../../../../../core/shared/architecture-enums.js"; +import { TaskCacheTag } from "../../../contracts/v1/task-cache.js"; +import { + acceptsTaskCollectionEvent, + type TaskCollectionEventV1, +} from "../../../contracts/v1/task-events.js"; + +function AcceptSuggestion({ suggestion }: { suggestion: TaskPlanSuggestionV1 }) { + const [accepted, setAccepted] = useState(false); + const queryClient = useQueryClient(); + const command = useCommandMutation({ + commandFn: async (input, commandId) => + ( + await commandApi<{ task: TaskViewV1 }>( + "/api/task-plan-suggestions/accept", + commandId, + input, + ) + ).task, + onSuccess: async () => { + setAccepted(true); + await queryClient.invalidateTags([TaskCacheTag.Collection]); + }, + }); + return ( + + {command.error && {command.error.message}} + + + ); +} + +export const contribution: SlotContribution = { + slot: SlotName.TaskSuggestionActions, + name: "accept-task-suggestion", + order: 10, + render: ({ suggestion }) => , +}; + +export const eventConsumers: FrontendEventConsumer[] = [ + { + name: "invalidate-task-collection-after-suggestion-acceptance", + eventType: DomainEventType.TaskCreatedV1, + contract: "task-management/contracts/events/v1/task-created.schema.json", + accepts: (event): event is TaskCollectionEventV1 => + acceptsTaskCollectionEvent(event, DomainEventType.TaskCreatedV1), + handle: (_event, { queryClient }) => queryClient.invalidateTags([TaskCacheTag.Collection]), + }, +]; diff --git a/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/manifest.ts b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/manifest.ts new file mode 100644 index 0000000..d50221e --- /dev/null +++ b/demo/ai-todo-assistant/contexts/task-management/features/accept-task-suggestion/manifest.ts @@ -0,0 +1,30 @@ +import { + DomainEventType, + HttpMethod, + PermissionScope, + SlotName, +} from "../../../../core/shared/architecture-enums.js"; +import type { FeatureManifest } from "../../../../core/shared/feature-manifest.js"; +import { feature } from "./backend/feature.js"; + +export const manifest = { + name: feature.name, + backend: { + method: HttpMethod.Post, + route: "/api/task-plan-suggestions/accept", + feature, + requiredScopes: [PermissionScope.TaskWrite], + }, + frontend: { + contributions: [ + { slot: SlotName.TaskSuggestionActions, name: "accept-task-suggestion" }, + ], + eventConsumers: [ + { + name: "invalidate-task-collection-after-suggestion-acceptance", + eventType: DomainEventType.TaskCreatedV1, + contract: "task-management/contracts/events/v1/task-created.schema.json", + }, + ], + }, +} satisfies FeatureManifest; diff --git a/demo/ai-todo-assistant/contexts/task-management/features/change-task-status/frontend/use-change-task-status.ts b/demo/ai-todo-assistant/contexts/task-management/features/change-task-status/frontend/use-change-task-status.ts index a962013..cf8d37c 100644 --- a/demo/ai-todo-assistant/contexts/task-management/features/change-task-status/frontend/use-change-task-status.ts +++ b/demo/ai-todo-assistant/contexts/task-management/features/change-task-status/frontend/use-change-task-status.ts @@ -1,6 +1,7 @@ -import { api } from "../../../../../core/frontend/api.js"; -import { useMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; +import { commandApi } from "../../../../../core/frontend/api.js"; +import { useCommandMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; import type { TaskViewV1 } from "../../../../../core/shared/generated/contracts.js"; +import { HttpMethod } from "../../../../../core/shared/architecture-enums.js"; import { TaskCacheTag } from "../../../contracts/v1/task-cache.js"; import type { TaskStatus } from "../../../contracts/v1/task-taxonomy.js"; @@ -11,14 +12,15 @@ interface ChangeTaskStatusInput { export function useChangeTaskStatus() { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async ({ task, status }) => + return useCommandMutation({ + commandFn: async ({ task, status }, commandId) => ( - await api<{ task: TaskViewV1 }>(`/api/tasks/${task.id}/status`, { - method: "PATCH", - headers: { "idempotency-key": crypto.randomUUID() }, - body: JSON.stringify({ status, expectedVersion: task.version }), - }) + await commandApi<{ task: TaskViewV1 }>( + `/api/tasks/${task.id}/status`, + commandId, + { status, expectedVersion: task.version }, + HttpMethod.Patch, + ) ).task, onSuccess: () => queryClient.invalidateTags([TaskCacheTag.Collection]), }); diff --git a/demo/ai-todo-assistant/contexts/task-management/features/create-task/backend/feature.ts b/demo/ai-todo-assistant/contexts/task-management/features/create-task/backend/feature.ts index 836f23e..efb11a0 100644 --- a/demo/ai-todo-assistant/contexts/task-management/features/create-task/backend/feature.ts +++ b/demo/ai-todo-assistant/contexts/task-management/features/create-task/backend/feature.ts @@ -5,7 +5,12 @@ import { } from "../../../../../core/shared/architecture-enums.js"; import { FeatureName } from "../../../../../core/shared/generated/feature-names.js"; import type { AifaFeature } from "../../../../../core/shared/aifa.js"; -import { TaskCategory, TaskPriority, TaskStatus, type Task } from "../../../domain/task.js"; +import { + createTaskDraft, + normalizeTaskTitle, + type TaskCategory, + type TaskPriority, +} from "../../../domain/task.js"; import type { TaskCapabilities } from "../../../domain/task-capabilities.js"; import { toTaskView, type TaskView } from "../../../contracts/v1/task-view.js"; @@ -27,20 +32,16 @@ export const feature: AifaFeature = { + const task = createTaskDraft({ id: await context.capabilities.IdCreate(), title, category: context.input.category, priority: context.input.priority, - status: TaskStatus.Todo, - version: 1, - createdAt: now, - updatedAt: now, - completedAt: null, - }; + now, + }); const created = await context.capabilities.TaskCreate({ task }); await context.capabilities.DomainEventEmit({ eventType: DomainEventType.TaskCreatedV1, diff --git a/demo/ai-todo-assistant/contexts/task-management/features/create-task/frontend/use-create-task.ts b/demo/ai-todo-assistant/contexts/task-management/features/create-task/frontend/use-create-task.ts index b03b86f..d9bde1f 100644 --- a/demo/ai-todo-assistant/contexts/task-management/features/create-task/frontend/use-create-task.ts +++ b/demo/ai-todo-assistant/contexts/task-management/features/create-task/frontend/use-create-task.ts @@ -1,5 +1,5 @@ -import { api } from "../../../../../core/frontend/api.js"; -import { useMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; +import { commandApi } from "../../../../../core/frontend/api.js"; +import { useCommandMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; import type { TaskViewV1 } from "../../../../../core/shared/generated/contracts.js"; import { TaskCacheTag } from "../../../contracts/v1/task-cache.js"; import type { TaskCategory, TaskPriority } from "../../../contracts/v1/task-taxonomy.js"; @@ -12,14 +12,10 @@ export interface CreateTaskInput { export function useCreateTask() { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (input) => + return useCommandMutation({ + commandFn: async (input, commandId) => ( - await api<{ task: TaskViewV1 }>("/api/tasks", { - method: "POST", - headers: { "idempotency-key": crypto.randomUUID() }, - body: JSON.stringify(input), - }) + await commandApi<{ task: TaskViewV1 }>("/api/tasks", commandId, input) ).task, onSuccess: () => queryClient.invalidateTags([TaskCacheTag.Collection]), }); diff --git a/demo/ai-todo-assistant/contexts/task-management/features/delete-task/frontend/use-delete-task.ts b/demo/ai-todo-assistant/contexts/task-management/features/delete-task/frontend/use-delete-task.ts index f99c164..9219688 100644 --- a/demo/ai-todo-assistant/contexts/task-management/features/delete-task/frontend/use-delete-task.ts +++ b/demo/ai-todo-assistant/contexts/task-management/features/delete-task/frontend/use-delete-task.ts @@ -1,18 +1,20 @@ -import { api } from "../../../../../core/frontend/api.js"; -import { useMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; +import { commandApi } from "../../../../../core/frontend/api.js"; +import { useCommandMutation, useQueryClient } from "../../../../../core/frontend/query/react-query.js"; import type { TaskViewV1 } from "../../../../../core/shared/generated/contracts.js"; +import { HttpMethod } from "../../../../../core/shared/architecture-enums.js"; import { TaskCacheTag } from "../../../contracts/v1/task-cache.js"; export function useDeleteTask() { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (task) => + return useCommandMutation({ + commandFn: async (task, commandId) => ( - await api<{ deleted: true }>(`/api/tasks/${task.id}`, { - method: "DELETE", - headers: { "idempotency-key": crypto.randomUUID() }, - body: JSON.stringify({ expectedVersion: task.version, confirmed: true }), - }) + await commandApi<{ deleted: true }>( + `/api/tasks/${task.id}`, + commandId, + { expectedVersion: task.version, confirmed: true }, + HttpMethod.Delete, + ) ).deleted, onSuccess: () => queryClient.invalidateTags([TaskCacheTag.Collection]), }); diff --git a/demo/ai-todo-assistant/core/architecture/.dependency-cruiser.cjs b/demo/ai-todo-assistant/core/architecture/.dependency-cruiser.cjs index ebc0a87..df17dd3 100644 --- a/demo/ai-todo-assistant/core/architecture/.dependency-cruiser.cjs +++ b/demo/ai-todo-assistant/core/architecture/.dependency-cruiser.cjs @@ -50,6 +50,26 @@ module.exports = { from: { path: "^contexts/[^/]+/features/" }, to: { path: "^contexts/[^/]+/infrastructure/" }, }, + { + name: "product-code-cannot-import-core-backend", + severity: "error", + from: { path: "^contexts/[^/]+/(domain|features)/" }, + to: { path: "^core/backend/" }, + }, + { + name: "features-cannot-import-node-runtime", + severity: "error", + from: { path: "^contexts/[^/]+/features/" }, + to: { dependencyTypes: ["core"] }, + }, + { + name: "features-cannot-import-infrastructure-packages", + severity: "error", + from: { path: "^contexts/[^/]+/features/" }, + to: { + path: "^(?:node_modules/)?(?:mongodb|@modelcontextprotocol/sdk|jose|dotenv)(?:/|$)", + }, + }, { name: "domain-cannot-import-features-or-infrastructure", severity: "error", diff --git a/demo/ai-todo-assistant/core/architecture/check-import-boundary-fixtures.mjs b/demo/ai-todo-assistant/core/architecture/check-import-boundary-fixtures.mjs new file mode 100644 index 0000000..0dbe7f9 --- /dev/null +++ b/demo/ai-todo-assistant/core/architecture/check-import-boundary-fixtures.mjs @@ -0,0 +1,76 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const fixtureRoot = await mkdtemp(join(tmpdir(), "aifa-import-boundary-")); +try { + await Promise.all([ + mkdir(join(fixtureRoot, "contexts/example/features/bad"), { recursive: true }), + mkdir(join(fixtureRoot, "core/backend"), { recursive: true }), + mkdir(join(fixtureRoot, "node_modules/mongodb"), { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(fixtureRoot, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + module: "NodeNext", + moduleResolution: "NodeNext", + target: "ES2022", + }, + }), + ), + writeFile(join(fixtureRoot, "core/backend/config.ts"), "export const secret = 'runtime';\n"), + writeFile( + join(fixtureRoot, "node_modules/mongodb/package.json"), + JSON.stringify({ name: "mongodb", version: "0.0.0", type: "module", exports: "./index.js" }), + ), + writeFile( + join(fixtureRoot, "node_modules/mongodb/index.js"), + "export class MongoClient {}\n", + ), + writeFile( + join(fixtureRoot, "contexts/example/features/bad/feature.ts"), + [ + 'import { secret } from "../../../../core/backend/config.js";', + 'import { readFile } from "node:fs/promises";', + 'import { MongoClient } from "mongodb";', + "void secret;", + "void readFile;", + "void MongoClient;", + ].join("\n"), + ), + ]); + const result = spawnSync( + join(appRoot, "node_modules/.bin/depcruise"), + [ + "--config", + join(appRoot, "core/architecture/.dependency-cruiser.cjs"), + "--output-type", + "json", + "contexts", + "core", + ], + { cwd: fixtureRoot, encoding: "utf8" }, + ); + const report = JSON.parse(result.stdout || "{}"); + const rules = new Set( + (report.summary?.violations ?? []).map((violation) => violation.rule?.name), + ); + for (const expected of [ + "product-code-cannot-import-core-backend", + "features-cannot-import-node-runtime", + "features-cannot-import-infrastructure-packages", + ]) { + if (!rules.has(expected)) + throw new Error( + `Dependency boundary fixture did not trigger '${expected}': ${JSON.stringify(report.summary?.violations ?? report.modules ?? [])}`, + ); + } + console.log("Dependency boundary violation fixtures were rejected."); +} finally { + await rm(fixtureRoot, { recursive: true, force: true }); +} diff --git a/demo/ai-todo-assistant/core/architecture/dependency-policy.json b/demo/ai-todo-assistant/core/architecture/dependency-policy.json index ec44350..e4040cf 100644 --- a/demo/ai-todo-assistant/core/architecture/dependency-policy.json +++ b/demo/ai-todo-assistant/core/architecture/dependency-policy.json @@ -2,13 +2,15 @@ "layers": ["core", "bounded-context-domain", "feature", "adapter"], "allowedImports": { "core": ["core"], - "bounded-context-domain": ["core", "bounded-context-domain"], - "feature": ["core", "bounded-context-domain", "feature-self"], + "bounded-context-domain": ["core-shared-contracts", "bounded-context-domain"], + "feature": ["core-shared-contracts", "core-frontend-runtime", "bounded-context-domain", "feature-self", "versioned-context-contracts"], "adapter": ["core", "feature"] }, "forbidden": [ "core-to-context-or-feature", "feature-to-other-feature", + "feature-to-core-backend", + "feature-to-runtime-or-infrastructure-package", "cross-context-domain-import", "circular-import" ], diff --git a/demo/ai-todo-assistant/core/backend/events/event-stream.ts b/demo/ai-todo-assistant/core/backend/events/event-stream.ts new file mode 100644 index 0000000..db6c3dc --- /dev/null +++ b/demo/ai-todo-assistant/core/backend/events/event-stream.ts @@ -0,0 +1,190 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Db } from "mongodb"; +import { ErrorCode, PermissionScope } from "../../shared/architecture-enums.js"; +import type { ActorResolver } from "../http/authorization-middleware.js"; +import { logger } from "../telemetry/logger.js"; + +export interface StreamCursor { + occurredAt: string; + seenEventIds: string[]; +} + +export interface StreamEvent { + occurredAt: string; + eventId: string; + eventType: string; + tenantId: string; + schemaVersion: number; + [property: string]: unknown; +} + +export interface EventStreamStore { + readAfter( + tenantId: string, + cursor: StreamCursor, + limit: number, + ): Promise; +} + +const eventBatchSize = 100; +const defaultPollIntervalMs = 1_000; +const maxCursorEventIds = 1_000; + +export function createMongoEventStreamStore(database: Db): EventStreamStore { + const events = database.collection<{ event: StreamEvent }>("outbox_events"); + return { + async readAfter(tenantId, cursor, limit) { + const documents = await events + .find({ + "event.tenantId": tenantId, + $or: [ + { "event.occurredAt": { $gt: cursor.occurredAt } }, + { + "event.occurredAt": cursor.occurredAt, + "event.eventId": { $nin: cursor.seenEventIds }, + }, + ], + }) + .sort({ "event.occurredAt": 1, "event.eventId": 1 }) + .limit(limit) + .toArray(); + return documents.map(({ event }) => event); + }, + }; +} + +function encodeCursor(cursor: StreamCursor): string { + return Buffer.from(JSON.stringify(cursor)).toString("base64url"); +} + +function decodeCursor(value: string | undefined): StreamCursor | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as StreamCursor; + if ( + typeof parsed.occurredAt === "string" && + !Number.isNaN(Date.parse(parsed.occurredAt)) && + Array.isArray(parsed.seenEventIds) && + parsed.seenEventIds.length <= maxCursorEventIds && + parsed.seenEventIds.every((item) => typeof item === "string") + ) + return parsed; + } catch {} + return undefined; +} + +function advanceCursor(cursor: StreamCursor, event: StreamEvent): StreamCursor { + return event.occurredAt === cursor.occurredAt + ? { ...cursor, seenEventIds: [...cursor.seenEventIds, event.eventId] } + : { occurredAt: event.occurredAt, seenEventIds: [event.eventId] }; +} + +function writeSseEvent( + response: ServerResponse, + cursor: StreamCursor, + event: string, + data: unknown, +): void { + response.write( + `id: ${encodeCursor(cursor)}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`, + ); +} + +function writeJson(response: ServerResponse, status: number, value: unknown): void { + response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value)); +} + +export function createEventStreamHandler(options: { + store: EventStreamStore; + resolveActor: ActorResolver; + corsOrigin?: string; + pollIntervalMs?: number; +}) { + return async (request: IncomingMessage, response: ServerResponse): Promise => { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`); + if (url.pathname !== "/api/events") return false; + if (options.corsOrigin) { + response.setHeader("access-control-allow-origin", options.corsOrigin); + response.setHeader("access-control-allow-headers", "authorization, last-event-id"); + response.setHeader("access-control-allow-methods", "GET, OPTIONS"); + } + if (request.method === "OPTIONS") { + response.writeHead(204).end(); + return true; + } + if (request.method !== "GET") { + response.writeHead(405, { allow: "GET" }).end(); + return true; + } + const actor = await options.resolveActor(request.headers); + if (!actor) { + writeJson(response, 401, { + ok: false, + error: { code: ErrorCode.NotAuthorized, message: "An actor is required" }, + }); + return true; + } + if (!actor.scopes.includes(PermissionScope.EventRead)) { + writeJson(response, 403, { + ok: false, + error: { code: ErrorCode.Forbidden, message: "The actor lacks EventRead" }, + }); + return true; + } + + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + let cursor = + decodeCursor(request.headers["last-event-id"]?.toString()) ?? + decodeCursor(url.searchParams.get("cursor") ?? undefined) ?? { + occurredAt: new Date().toISOString(), + seenEventIds: [], + }; + let closed = false; + let timer: ReturnType | undefined; + let wakeTimer: (() => void) | undefined; + const close = () => { + closed = true; + if (timer) clearTimeout(timer); + wakeTimer?.(); + timer = undefined; + wakeTimer = undefined; + }; + request.once("close", close); + response.once("close", close); + writeSseEvent(response, cursor, "ready", {}); + + void (async () => { + try { + while (!closed) { + const events = await options.store.readAfter(actor.tenantId, cursor, eventBatchSize); + for (const event of events) { + if (closed) return; + cursor = advanceCursor(cursor, event); + writeSseEvent(response, cursor, "domain-event", event); + } + await new Promise((resolve) => { + wakeTimer = resolve; + timer = setTimeout(() => { + timer = undefined; + wakeTimer = undefined; + resolve(); + }, options.pollIntervalMs ?? defaultPollIntervalMs); + }); + } + } catch (cause) { + if (!closed) + logger.error("event_stream_failed", { + tenantId: actor.tenantId, + error: cause instanceof Error ? cause.message : "Unknown error", + }); + response.end(); + } + })(); + return true; + }; +} diff --git a/demo/ai-todo-assistant/core/backend/runtime/mongo-platform-capabilities.ts b/demo/ai-todo-assistant/core/backend/runtime/mongo-platform-capabilities.ts index d06bb67..6452c0d 100644 --- a/demo/ai-todo-assistant/core/backend/runtime/mongo-platform-capabilities.ts +++ b/demo/ai-todo-assistant/core/backend/runtime/mongo-platform-capabilities.ts @@ -62,5 +62,9 @@ export async function ensurePlatformIndexes(database: Db): Promise { { "delivery.status": 1, "delivery.availableAt": 1, "event.occurredAt": 1 }, { name: "outbox_delivery" }, ), + database.collection("outbox_events").createIndex( + { "event.tenantId": 1, "event.occurredAt": 1, "event.eventId": 1 }, + { name: "tenant_event_stream" }, + ), ]); } diff --git a/demo/ai-todo-assistant/core/backend/server.ts b/demo/ai-todo-assistant/core/backend/server.ts index fe82346..36dc199 100644 --- a/demo/ai-todo-assistant/core/backend/server.ts +++ b/demo/ai-todo-assistant/core/backend/server.ts @@ -16,6 +16,7 @@ import { ErrorCode, HttpMethod } from "../shared/architecture-enums.js"; import type { Actor, RequestMetadata } from "../shared/aifa.js"; import type { FeatureManifest } from "../shared/feature-manifest.js"; import { createRemoteMcpHandler } from "./mcp-http.js"; +import { createEventStreamHandler, createMongoEventStreamStore } from "./events/event-stream.js"; const memoryServer = config.mongoUri ? undefined @@ -192,7 +193,13 @@ const remoteMcpHandler = createRemoteMcpHandler({ resourceUrl: new URL("/mcp", config.publicBaseUrl).href, authorizationServer: config.oidcIssuerUrl, }); +const eventStreamHandler = createEventStreamHandler({ + store: createMongoEventStreamStore(mongoDatabase), + resolveActor, + corsOrigin: config.corsOrigin, +}); const server = createServer(async (request, response) => { + if (await eventStreamHandler(request, response)) return; if (await remoteMcpHandler(request, response)) return; await apiHandler(request, response); }); diff --git a/demo/ai-todo-assistant/core/frontend/api.ts b/demo/ai-todo-assistant/core/frontend/api.ts index efda6ae..e69e740 100644 --- a/demo/ai-todo-assistant/core/frontend/api.ts +++ b/demo/ai-todo-assistant/core/frontend/api.ts @@ -1,30 +1,78 @@ import { getAccessToken } from "./auth/oidc.js"; +import { + ErrorCode, + HttpMethod, + PermissionScope, +} from "../shared/architecture-enums.js"; -export async function api(path: string, options: RequestInit = {}): Promise { - const developmentHeaders: Record = import.meta.env.DEV +export class ApiResponseError extends Error { + constructor( + message: string, + readonly status: number, + readonly code?: string, + ) { + super(message); + this.name = "ApiResponseError"; + } +} + +export function isDefinitiveCommandFailure(cause: unknown): cause is ApiResponseError { + return ( + cause instanceof ApiResponseError && + cause.status < 500 && + cause.code !== ErrorCode.RequestInProgress + ); +} + +export function apiUrl(path: string): string { + return `${import.meta.env.VITE_API_BASE_URL ?? ""}${path}`; +} + +export function getApiAuthHeaders(): Record { + const token = !import.meta.env.DEV ? getAccessToken() : undefined; + return import.meta.env.DEV ? { "x-aifa-tenant-id": "local-tenant", "x-aifa-user-id": "local-user", - "x-aifa-scopes": - "TaskRead,TaskWrite,TaskDelete,AssistantPlanGenerate,SettingsRead", + "x-aifa-scopes": Object.values(PermissionScope).join(","), } - : {}; - const response = await fetch(`${import.meta.env.VITE_API_BASE_URL ?? ""}${path}`, { + : token + ? { authorization: `Bearer ${token}` } + : {}; +} + +export async function commandApi( + path: string, + commandId: string, + input: unknown, + method: HttpMethod = HttpMethod.Post, +): Promise { + return api(path, { + method, + headers: { "idempotency-key": commandId }, + body: JSON.stringify(input), + }); +} + +export async function api(path: string, options: RequestInit = {}): Promise { + const response = await fetch(apiUrl(path), { ...options, headers: { "content-type": "application/json", - ...developmentHeaders, - ...(!import.meta.env.DEV && getAccessToken() - ? { authorization: `Bearer ${getAccessToken()}` } - : {}), + ...getApiAuthHeaders(), ...options.headers, }, }); const result = (await response.json()) as { ok: boolean; value?: Value; - error?: { message: string }; + error?: { code?: string; message: string }; }; - if (!response.ok || !result.ok) throw new Error(result.error?.message ?? "Request failed"); + if (!response.ok || !result.ok) + throw new ApiResponseError( + result.error?.message ?? "Request failed", + response.status, + result.error?.code, + ); return result.value as Value; } diff --git a/demo/ai-todo-assistant/core/frontend/app-model.ts b/demo/ai-todo-assistant/core/frontend/app-model.ts index b7781f5..4543680 100644 --- a/demo/ai-todo-assistant/core/frontend/app-model.ts +++ b/demo/ai-todo-assistant/core/frontend/app-model.ts @@ -1,6 +1,7 @@ import type { AppSurface } from "../shared/architecture-enums.js"; import type { TaskManagementTaxonomyV1, + TaskPlanSuggestionV1, TaskViewV1, } from "../shared/generated/contracts.js"; @@ -22,6 +23,10 @@ export interface TaskRowActionsModel { task: TaskView; } +export interface TaskSuggestionActionsModel { + suggestion: TaskPlanSuggestionV1; +} + export interface TaskListModel { tasks: readonly TaskView[]; error: string; diff --git a/demo/ai-todo-assistant/core/frontend/auth/oidc.ts b/demo/ai-todo-assistant/core/frontend/auth/oidc.ts index e6d2408..b1bcf34 100644 --- a/demo/ai-todo-assistant/core/frontend/auth/oidc.ts +++ b/demo/ai-todo-assistant/core/frontend/auth/oidc.ts @@ -1,3 +1,5 @@ +import { PermissionScope } from "../../shared/architecture-enums.js"; + interface OidcMetadata { authorization_endpoint: string; token_endpoint: string; @@ -12,6 +14,9 @@ function configuration() { issuer: import.meta.env.VITE_OIDC_ISSUER_URL as string | undefined, clientId: import.meta.env.VITE_OIDC_CLIENT_ID as string | undefined, audience: import.meta.env.VITE_OIDC_AUDIENCE as string | undefined, + scopes: + (import.meta.env.VITE_OIDC_SCOPES as string | undefined) ?? + Object.values(PermissionScope).join(" "), redirectUri: (import.meta.env.VITE_OIDC_REDIRECT_URI as string | undefined) ?? `${window.location.origin}${window.location.pathname}`, @@ -65,7 +70,7 @@ export async function beginSignIn(): Promise { client_id: config.clientId, redirect_uri: config.redirectUri, response_type: "code", - scope: "openid profile offline_access", + scope: `openid profile ${config.scopes}`, state, code_challenge: await sha256Base64Url(verifier), code_challenge_method: "S256", diff --git a/demo/ai-todo-assistant/core/frontend/commands/command-id-store.ts b/demo/ai-todo-assistant/core/frontend/commands/command-id-store.ts new file mode 100644 index 0000000..d5e6034 --- /dev/null +++ b/demo/ai-todo-assistant/core/frontend/commands/command-id-store.ts @@ -0,0 +1,33 @@ +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalize(item)]), + ); +} + +export function commandFingerprint(input: unknown): string { + const fingerprint = JSON.stringify(canonicalize(input)); + if (fingerprint === undefined) + throw new Error("Command input must be JSON-serializable"); + return fingerprint; +} + +export class CommandIdStore { + readonly #ids = new Map(); + + get(input: unknown): string { + const fingerprint = commandFingerprint(input); + const existing = this.#ids.get(fingerprint); + if (existing) return existing; + const created = crypto.randomUUID(); + this.#ids.set(fingerprint, created); + return created; + } + + settle(input: unknown): void { + this.#ids.delete(commandFingerprint(input)); + } +} diff --git a/demo/ai-todo-assistant/core/frontend/events/FrontendEventProvider.tsx b/demo/ai-todo-assistant/core/frontend/events/FrontendEventProvider.tsx index 87af08b..008321e 100644 --- a/demo/ai-todo-assistant/core/frontend/events/FrontendEventProvider.tsx +++ b/demo/ai-todo-assistant/core/frontend/events/FrontendEventProvider.tsx @@ -1,22 +1,47 @@ import { useEffect, type ReactNode } from "react"; import { frontendEventRegistry, parseFrontendDomainEvent } from "./event-registry.js"; import { useQueryClient } from "../query/react-query.js"; +import { subscribeToFrontendEvents } from "./event-stream.js"; + +function waitForReconnect(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const finish = () => { + clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, 1_000); + signal.addEventListener("abort", finish, { once: true }); + }); +} export function FrontendEventProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); useEffect(() => { - const streamUrl = import.meta.env.VITE_EVENT_STREAM_URL; - if (!streamUrl) return; - const source = new EventSource(streamUrl, { withCredentials: true }); - source.onmessage = (message) => { - try { - const event = parseFrontendDomainEvent(JSON.parse(message.data) as unknown); - void frontendEventRegistry.dispatch(event, { queryClient }); - } catch (cause) { - console.error("Rejected frontend domain event", cause); + const controller = new AbortController(); + let lastEventId: string | undefined; + void (async () => { + while (!controller.signal.aborted) { + try { + await subscribeToFrontendEvents({ + signal: controller.signal, + lastEventId, + onCursor: (value) => (lastEventId = value), + onEvent: async (value) => { + const event = parseFrontendDomainEvent(value); + await frontendEventRegistry.dispatch(event, { queryClient }); + }, + }); + } catch (cause) { + if (controller.signal.aborted) return; + if (!(cause instanceof Error && cause.message === "Event stream identity is unavailable")) + console.error("Event stream disconnected", cause); + } + await waitForReconnect(controller.signal); } - }; - return () => source.close(); + })(); + return () => controller.abort(); }, [queryClient]); return children; } diff --git a/demo/ai-todo-assistant/core/frontend/events/event-stream.ts b/demo/ai-todo-assistant/core/frontend/events/event-stream.ts new file mode 100644 index 0000000..43cbae2 --- /dev/null +++ b/demo/ai-todo-assistant/core/frontend/events/event-stream.ts @@ -0,0 +1,64 @@ +import { apiUrl, getApiAuthHeaders } from "../api.js"; + +interface EventFrame { + event: string; + data: string; + id?: string; +} + +function parseFrame(value: string): EventFrame | undefined { + let event = "message"; + let id: string | undefined; + const data: string[] = []; + for (const line of value.split("\n")) { + if (line.startsWith(":")) continue; + const separator = line.indexOf(":"); + const field = separator < 0 ? line : line.slice(0, separator); + const content = separator < 0 ? "" : line.slice(separator + 1).replace(/^ /, ""); + if (field === "event") event = content; + if (field === "id") id = content; + if (field === "data") data.push(content); + } + return data.length || id ? { event, data: data.join("\n"), ...(id ? { id } : {}) } : undefined; +} + +export async function subscribeToFrontendEvents(options: { + signal: AbortSignal; + lastEventId?: string; + onCursor(value: string): void; + onEvent(value: unknown): void | Promise; +}): Promise { + const headers = getApiAuthHeaders(); + if (!Object.keys(headers).length) throw new Error("Event stream identity is unavailable"); + const configuredUrl = (import.meta.env.VITE_EVENT_STREAM_URL as string | undefined)?.trim(); + const response = await fetch( + configuredUrl || apiUrl("/api/events"), + { + headers: { + accept: "text/event-stream", + ...headers, + ...(options.lastEventId ? { "last-event-id": options.lastEventId } : {}), + }, + signal: options.signal, + }, + ); + if (!response.ok || !response.body) + throw new Error(`Event stream returned HTTP ${response.status}`); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (!options.signal.aborted) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }).replaceAll("\r\n", "\n"); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const frame = parseFrame(buffer.slice(0, boundary)); + buffer = buffer.slice(boundary + 2); + if (frame?.id) options.onCursor(frame.id); + if (frame?.event === "domain-event" && frame.data) + await options.onEvent(JSON.parse(frame.data) as unknown); + boundary = buffer.indexOf("\n\n"); + } + if (done) return; + } +} diff --git a/demo/ai-todo-assistant/core/frontend/query/react-query.tsx b/demo/ai-todo-assistant/core/frontend/query/react-query.tsx index e3ebe0a..6aa38fd 100644 --- a/demo/ai-todo-assistant/core/frontend/query/react-query.tsx +++ b/demo/ai-todo-assistant/core/frontend/query/react-query.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useMemo, + useRef, useState, useSyncExternalStore, type ReactNode, @@ -13,6 +14,8 @@ import { type QueryOptions, type QuerySnapshot, } from "./query-client.js"; +import { isDefinitiveCommandFailure } from "../api.js"; +import { CommandIdStore } from "../commands/command-id-store.js"; const QueryClientContext = createContext(null); @@ -93,3 +96,33 @@ export function useMutation( ); return { mutate, isPending: pendingCount > 0, error, reset: () => setError(undefined) }; } + +export interface CommandMutationOptions { + commandFn(input: Input, commandId: string): Promise; + onSuccess?(value: Value, input: Input): void | Promise; +} + +/** + * Retains a command ID after an unknown transport outcome so a retry replays the + * same durable command. Definitive HTTP failures and successes settle the ID. + */ +export function useCommandMutation( + options: CommandMutationOptions, +): UseMutationResult { + const commandIds = useRef(null); + commandIds.current ??= new CommandIdStore(); + return useMutation({ + mutationFn: async (input: Input) => { + const commandId = commandIds.current!.get(input); + try { + const value = await options.commandFn(input, commandId); + commandIds.current!.settle(input); + return value; + } catch (cause) { + if (isDefinitiveCommandFailure(cause)) commandIds.current!.settle(input); + throw cause; + } + }, + onSuccess: options.onSuccess, + }); +} diff --git a/demo/ai-todo-assistant/core/frontend/slots/slot-registry.ts b/demo/ai-todo-assistant/core/frontend/slots/slot-registry.ts index fec7932..a442a1b 100644 --- a/demo/ai-todo-assistant/core/frontend/slots/slot-registry.ts +++ b/demo/ai-todo-assistant/core/frontend/slots/slot-registry.ts @@ -5,6 +5,7 @@ import type { ShellModel, TaskListModel, TaskRowActionsModel, + TaskSuggestionActionsModel, } from "../app-model.js"; export interface SlotModelMap { @@ -16,6 +17,7 @@ export interface SlotModelMap { [SlotName.TaskList]: TaskListModel; [SlotName.TaskRowActions]: TaskRowActionsModel; [SlotName.AssistantPanel]: EmptySlotModel; + [SlotName.TaskSuggestionActions]: TaskSuggestionActionsModel; [SlotName.SettingsPanel]: ShellModel; } diff --git a/demo/ai-todo-assistant/core/frontend/vite-env.d.ts b/demo/ai-todo-assistant/core/frontend/vite-env.d.ts index 5dda26e..288ffb9 100644 --- a/demo/ai-todo-assistant/core/frontend/vite-env.d.ts +++ b/demo/ai-todo-assistant/core/frontend/vite-env.d.ts @@ -2,4 +2,9 @@ interface ImportMetaEnv { readonly VITE_EVENT_STREAM_URL?: string; + readonly VITE_OIDC_ISSUER_URL?: string; + readonly VITE_OIDC_CLIENT_ID?: string; + readonly VITE_OIDC_AUDIENCE?: string; + readonly VITE_OIDC_REDIRECT_URI?: string; + readonly VITE_OIDC_SCOPES?: string; } diff --git a/demo/ai-todo-assistant/core/shared/architecture-enums.ts b/demo/ai-todo-assistant/core/shared/architecture-enums.ts index d88b7ce..186cab2 100644 --- a/demo/ai-todo-assistant/core/shared/architecture-enums.ts +++ b/demo/ai-todo-assistant/core/shared/architecture-enums.ts @@ -35,6 +35,7 @@ export enum SlotName { TaskList = "TaskList", TaskRowActions = "TaskRowActions", AssistantPanel = "AssistantPanel", + TaskSuggestionActions = "TaskSuggestionActions", SettingsPanel = "SettingsPanel", } @@ -52,6 +53,7 @@ export enum PermissionScope { TaskDelete = "TaskDelete", AssistantPlanGenerate = "AssistantPlanGenerate", SettingsRead = "SettingsRead", + EventRead = "EventRead", } export enum HttpMethod { diff --git a/demo/ai-todo-assistant/package.json b/demo/ai-todo-assistant/package.json index 38c295b..fb2e103 100644 --- a/demo/ai-todo-assistant/package.json +++ b/demo/ai-todo-assistant/package.json @@ -17,11 +17,12 @@ "validate:architecture": "tsx core/architecture/validate-feature-definitions.ts", "generate:contracts": "tsx core/architecture/generate-contract-types.ts", "check:imports": "depcruise --config core/architecture/.dependency-cruiser.cjs core contexts composition tests", + "check:import-fixtures": "node core/architecture/check-import-boundary-fixtures.mjs", "test": "tsx --test tests/*.test.ts tests/*.test.tsx", "test:integration": "tsx --test tests/mongo.integration.test.ts", "test:smoke": "node tests/built-server.smoke.mjs", "check:bundle": "node core/architecture/check-bundle-size.mjs", - "check": "npm run generate:contracts && npm run validate:architecture && npm run check:imports && tsc --noEmit" + "check": "npm run generate:contracts && npm run validate:architecture && npm run check:imports && npm run check:import-fixtures && tsc --noEmit" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/demo/ai-todo-assistant/tests/features.test.ts b/demo/ai-todo-assistant/tests/features.test.ts index ff76797..7f02a47 100644 --- a/demo/ai-todo-assistant/tests/features.test.ts +++ b/demo/ai-todo-assistant/tests/features.test.ts @@ -14,6 +14,7 @@ import { feature as changeStatus } from "../contexts/task-management/features/ch import { feature as deleteTask } from "../contexts/task-management/features/delete-task/backend/feature.js"; import { feature as listTasks } from "../contexts/task-management/features/list-tasks/backend/feature.js"; import { feature as generatePlan } from "../contexts/ai-planning/features/generate-task-plan/backend/feature.js"; +import { feature as acceptSuggestion } from "../contexts/task-management/features/accept-task-suggestion/backend/feature.js"; import { resolveActor } from "../core/backend/auth/actor-resolver.js"; import { publishPendingEvents } from "../core/backend/outbox/outbox-publisher.js"; @@ -145,6 +146,24 @@ test("AI planning stays provider-neutral and audits each plan", async () => { assert.deepEqual(audit, ["TaskPlanGeneratedV1"]); assert.equal(FeatureName.GenerateTaskPlan, generatePlan.name); }); +test("suggestion acceptance is an independent governed task mutation", async () => { + const { runtime, actor, metadata, audit } = fixture(); + const result = await runtime.run( + acceptSuggestion, + { + title: "Review the launch checklist", + category: TaskCategory.Work, + priority: TaskPriority.High, + rationale: "Turn the reviewed suggestion into an explicit commitment", + }, + actor, + metadata, + ); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.value.task.title, "Review the launch checklist"); + assert.deepEqual(audit, [DomainEventType.TaskCreatedV1]); +}); test("actor resolution accepts only configured scope values", async () => { assert.deepEqual( await resolveActor({ diff --git a/demo/ai-todo-assistant/tests/frontend-runtime.test.ts b/demo/ai-todo-assistant/tests/frontend-runtime.test.ts index a62a70f..a3c26a9 100644 --- a/demo/ai-todo-assistant/tests/frontend-runtime.test.ts +++ b/demo/ai-todo-assistant/tests/frontend-runtime.test.ts @@ -5,6 +5,8 @@ import { TaskCacheTag, TaskQueryName } from "../contexts/task-management/contrac import { DomainEventType } from "../core/shared/architecture-enums.js"; import { FrontendEventRegistry } from "../core/frontend/events/event-registry.js"; import { QueryClient } from "../core/frontend/query/query-client.js"; +import { CommandIdStore, commandFingerprint } from "../core/frontend/commands/command-id-store.js"; +import { ApiResponseError, isDefinitiveCommandFailure } from "../core/frontend/api.js"; async function settle(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -93,3 +95,32 @@ test("frontend event consumers reject unsupported contract versions", async () = /failed consumer validation/, ); }); + +test("browser command IDs survive unknown outcomes and settle after a definitive result", () => { + const store = new CommandIdStore(); + const input = { title: "Ship", details: { priority: "High", category: "Work" } }; + const first = store.get(input); + assert.equal( + store.get({ details: { category: "Work", priority: "High" }, title: "Ship" }), + first, + "canonical input reuses the command after a transport failure", + ); + store.settle(input); + assert.notEqual(store.get(input), first, "a settled user intent receives a fresh command ID"); + assert.equal( + commandFingerprint({ b: 2, a: 1 }), + commandFingerprint({ a: 1, b: 2 }), + ); + assert.equal( + isDefinitiveCommandFailure(new ApiResponseError("still running", 409, "RequestInProgress")), + false, + ); + assert.equal( + isDefinitiveCommandFailure(new ApiResponseError("invalid", 422, "InvalidInput")), + true, + ); + assert.equal( + isDefinitiveCommandFailure(new ApiResponseError("server failed", 500, "InternalError")), + false, + ); +}); diff --git a/demo/ai-todo-assistant/tests/mongo.integration.test.ts b/demo/ai-todo-assistant/tests/mongo.integration.test.ts index 3fd8b34..294f7ce 100644 --- a/demo/ai-todo-assistant/tests/mongo.integration.test.ts +++ b/demo/ai-todo-assistant/tests/mongo.integration.test.ts @@ -8,6 +8,7 @@ import { FeatureName } from "../core/shared/generated/feature-names.js"; import { DomainEventType, OutboxStatus } from "../core/shared/architecture-enums.js"; import { createMongoOutboxStore } from "../core/backend/outbox/mongo-outbox.js"; import { createMongoPlatformCapabilities } from "../core/backend/runtime/mongo-platform-capabilities.js"; +import { createMongoEventStreamStore } from "../core/backend/events/event-stream.js"; import { discoverFeatureManifests } from "../core/backend/discovery/discover-features.js"; import { createMongoTaskCapabilities, @@ -201,6 +202,16 @@ test("Mongo task capability enforces tenant isolation and optimistic concurrency assert.deepEqual(eventManifest.backend.contract.validateEvent(auditEvent), []); const storedOutbox = await database.collection("outbox_events").findOne({ "event.eventId": auditEvent?.eventId }); assert.deepEqual(eventManifest.backend.contract.validateEvent(storedOutbox?.event), []); + const eventStream = createMongoEventStreamStore(database); + assert.equal( + (await eventStream.readAfter(actorA.tenantId, { occurredAt: new Date(0).toISOString(), seenEventIds: [] }, 10)).length, + 1, + ); + assert.equal( + (await eventStream.readAfter(actorB.tenantId, { occurredAt: new Date(0).toISOString(), seenEventIds: [] }, 10)).length, + 0, + "event delivery is tenant scoped", + ); await database.collection("outbox_events").deleteMany({}); await database.collection("outbox_events").insertOne({ diff --git a/docs/architecture.md b/docs/architecture.md index 0784fec..8cb6565 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ A Create Task contribution does not import the task list or tell it to refresh. ```text core → core only context domain → core + its own domain -feature → core + itself + own domain + versioned contracts +feature → core shared/frontend APIs + itself + own domain + versioned contracts HTTP / MCP / UI adapter → core + its registered feature contract cross-context work → versioned contracts or domain events ```