Canvas AI: Create the canvas_dev_ai module with a mock AI controller
## Summary
Create a new module `canvas_dev_ai` at `modules/canvas_dev_ai/` (sibling to `modules/canvas_ai/`). It adds a `js_settings_alter` hook that exposes a dev flag to `drupalSettings`, and a `CanvasDevAiBuilder` controller with a single `render()` action that returns a fixed mocked JSON response. It adds **one** new route (`/admin/api/canvas/ai-dev`) and **reuses the existing `canvas_ai.csrf_token` endpoint** for CSRF. No agent, no tools, no progress polling. This stands up the module and its request path; the real hop loop replaces the mock in a later issue.
## Why the module exists
The rewrite changes the AI controller, adds new tools, and replaces the agent loop. `canvas_dev_ai` is an experimental, opt-in module that holds this work so it can be built and released without altering the live `canvas_ai` editor. It wires to no live agent; installing it swaps the AI editor for the dev controller, and uninstalling it restores the old flow. The work graduates into `canvas_ai` in one controlled MR later.
- Roadmap: https://git.drupalcode.org/project/canvas/-/work_items/3591777
- POC (consumer side): https://git.drupalcode.org/project/canvas/-/merge_requests/1214
## Files to create
```
modules/canvas_dev_ai/
canvas_dev_ai.info.yml
canvas_dev_ai.routing.yml
src/
Controller/CanvasDevAiBuilder.php
Hook/CanvasDevAiHooks.php
```
### `canvas_dev_ai.info.yml`
Match the active feature-flag dev-module precedent `canvas_dev_cd` (`modules/canvas_dev_cd/canvas_dev_cd.info.yml`): a plain module whose description states the consequence. Do **not** set `lifecycle` or `hidden` — in Canvas those mark *retired* modules only (`canvas_dev_er`, `canvas_dev_translation` use `lifecycle: deprecated` + `hidden: true`).
```yaml
name: Drupal Canvas Dev AI
type: module
description: 'Enables the experimental Canvas AI client-side orchestration loop - feature flag until the revamped Canvas AI is fully stable. Replaces the AI chat backend and UI while installed.'
package: Drupal Canvas
core_version_requirement: ^11.3
dependencies:
- canvas:canvas
- canvas:canvas_ai
```
### `src/Hook/CanvasDevAiHooks.php`
Add a hook class with a `#[Hook('js_settings_alter')]` method `jsSettingsAlter(array &$settings)`. Mirror `Drupal\canvas_ai\Hook\CanvasAiHooks::jsSettingsAlter()` (`modules/canvas_ai/src/Hook/CanvasAiHooks.php:158`): guard on `!empty($settings['canvas']['aiExtensionAvailable'])`, then set:
```php
$settings['canvas']['aiDevMode'] = TRUE;
```
This is the flag the dev wizard (`AiWizardDev.tsx`, separate issue) reads to swap itself in and post to `/admin/api/canvas/ai-dev`. Mark the class `@internal`.
### `canvas_dev_ai.routing.yml`
**One** route only — the chat endpoint. No token route.
```yaml
canvas_dev_ai.builder:
path: '/admin/api/canvas/ai-dev'
defaults:
_controller: '\Drupal\canvas_dev_ai\Controller\CanvasDevAiBuilder::render'
requirements:
_permission: 'use Drupal Canvas AI'
```
Permission `use Drupal Canvas AI` is inherited from the `canvas_ai` dependency — do not define a new permission. No token route: the frontend already fetches its CSRF token from `canvas_ai`'s `/admin/api/canvas/token` and reuses it for every POST, so the dev controller just validates against the same seed (see the controller below).
### `src/Controller/CanvasDevAiBuilder.php`
A `final` `@internal` controller extending `ControllerBase`. Inject only `csrf_token` (`Drupal\Core\Access\CsrfTokenGenerator`).
- `render(Request $request): JsonResponse` — validate the `X-CSRF-Token` header against the seed `'canvas_ai.canvas_builder'` (the same seed `canvas_ai`'s token endpoint issues; throw `AccessDeniedHttpException` on failure, as `CanvasBuilder::render()` does at lines 75–78), then return the fixed mock. Add a class-level `@todo` noting the mock is temporary and will be replaced by the real hop loop (mirroring how `canvas_ai` marks its gates, `CanvasController.php:127`; link the graduation/controller work item once it exists).
```json
{
"status": true,
"should_continue": false,
"message": "This is a mocked response from the Canvas Dev AI controller.",
"progress": ""
}
```
No `getCsrfToken()` method — the frontend fetches its token from the existing `/admin/api/canvas/token`. The mock matches the shape `CanvasBuilder::render()` returns (`status`, `message`, `should_continue`, `progress`), so it is the frozen frontend contract for `AiWizardDev.tsx`.
## Explicitly excluded (do NOT copy from `canvas_ai`)
- A token endpoint — reuse `canvas_ai.csrf_token`; validate against seed `'canvas_ai.canvas_builder'`.
- Agent invocation, `main_agent` config, `getToolResults()`, structured-output merge.
- Progress/polling: no `getProgressItems()`, no `ai_agents.agent_status_poller`, no progress endpoint.
- File upload handling, chat-history reconstruction, `CanvasAiPageBuilderHelper`/`CanvasAiTempStore`/`CanvasAiChatHelper` — none are needed for the mock.
- Config entities and agents: none. Dev agents need new `canvas_dev_ai_*` config IDs to avoid `PreExistingConfigException`; they are a separate issue.
## Tests
`canvas_dev_ai` ships no tests of its own (same as `canvas_dev_cd`). The coverage lives in `canvas_ai`'s suite as one kernel test, `modules/canvas_ai/tests/src/Kernel/CanvasAiDevControllerTest.php` extending `CanvasKernelTestBase`, that enables `canvas_dev_ai` and checks two things:
1. **Flag follows install state.** With `canvas_dev_ai` installed, `canvas.aiDevMode` is exposed to `drupalSettings`; after uninstalling it, the key is gone.
2. **Controller returns the mock.** `CanvasDevAiBuilder::render()` returns the exact mock body above.
Keep it direct — no extra cases. This test is temporary; put a `@todo Remove in <work-item URL>` at the top of the class (mirroring `ApiLayoutControllerGetTest.php:710`) matching the `@todo` on the controller, so it is deleted/replaced when the real hop loop lands.
---
_Issue generated with AI assistance._
issue