portal-mojo 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +42 -1
  2. package/docs/admin-assistant.md +14 -7
  3. package/docs/image-editor.md +13 -0
  4. package/docs/menus-and-access.md +14 -0
  5. package/docs/realtime.md +4 -2
  6. package/package.json +14 -2
  7. package/src/admin/assistant/AssistantLauncher.tsx +23 -0
  8. package/src/admin/assistant/AssistantPanel.tsx +3 -2
  9. package/src/admin/assistant/api.ts +1 -1
  10. package/src/admin/assistant/index.ts +1 -11
  11. package/src/admin/assistant/launchers.tsx +5 -11
  12. package/src/admin/assistant/pages.tsx +1 -1
  13. package/src/admin/assistant/permissions.ts +1 -0
  14. package/src/admin/assistant/streaming.ts +69 -41
  15. package/src/admin/bouncer/index.ts +1 -28
  16. package/src/admin/bouncer/models.ts +1 -1
  17. package/src/admin/bouncer/signatures.tsx +1 -1
  18. package/src/admin/cloudwatch/CloudWatchChart.tsx +1 -1
  19. package/src/admin/cloudwatch/data.ts +1 -1
  20. package/src/admin/cloudwatch/index.ts +1 -11
  21. package/src/admin/core/index.ts +215 -0
  22. package/src/admin/credentials/group-api-keys.tsx +1 -1
  23. package/src/admin/credentials/models.ts +1 -1
  24. package/src/admin/credentials/webhook-subscriptions.tsx +1 -1
  25. package/src/admin/dashboard/AdminDashboardPage.tsx +9 -18
  26. package/src/admin/dashboard/data.ts +1 -1
  27. package/src/admin/dashboard/index.ts +1 -0
  28. package/src/admin/dns/CertificateDetail.tsx +1 -1
  29. package/src/admin/dns/CertificateLifecyclePoller.tsx +1 -1
  30. package/src/admin/dns/CertificatesPage.tsx +1 -1
  31. package/src/admin/dns/DnsRecordsPanel.tsx +1 -1
  32. package/src/admin/dns/DomainCertificatesSection.tsx +1 -1
  33. package/src/admin/dns/DomainDetail.tsx +1 -1
  34. package/src/admin/dns/DomainOnboardingDialog.tsx +1 -1
  35. package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
  36. package/src/admin/dns/ProviderCredentialsPage.tsx +1 -1
  37. package/src/admin/dns/RegistrantContactPage.tsx +1 -1
  38. package/src/admin/dns/api.ts +1 -1
  39. package/src/admin/dns/index.ts +2 -59
  40. package/src/admin/dns/models.ts +1 -1
  41. package/src/admin/dns/setup.ts +27 -0
  42. package/src/admin/domains/assistant.ts +13 -0
  43. package/src/admin/domains/communications.ts +35 -0
  44. package/src/admin/domains/identity.ts +24 -0
  45. package/src/admin/domains/infrastructure.ts +24 -0
  46. package/src/admin/domains/observability.ts +28 -0
  47. package/src/admin/domains/operations.ts +18 -0
  48. package/src/admin/domains/security.ts +55 -0
  49. package/src/admin/identity/members/GroupMembersPanel.tsx +1 -1
  50. package/src/admin/identity/members/MemberDetail.tsx +1 -1
  51. package/src/admin/identity/members/MembersPage.tsx +1 -1
  52. package/src/admin/identity/members/index.ts +1 -17
  53. package/src/admin/identity/members/member-flows.tsx +1 -1
  54. package/src/admin/identity/members/models.ts +1 -1
  55. package/src/admin/identity/users/UserDetail.tsx +1 -1
  56. package/src/admin/identity/users/UsersPage.tsx +1 -1
  57. package/src/admin/identity/users/index.ts +1 -16
  58. package/src/admin/identity/users/models.ts +1 -1
  59. package/src/admin/identity/users/sections/LoginsSection.tsx +1 -1
  60. package/src/admin/identity/users/sections/NotificationsSection.tsx +1 -1
  61. package/src/admin/identity/users/sections/PersonalSection.tsx +1 -1
  62. package/src/admin/identity/users/sections/actions.tsx +1 -1
  63. package/src/admin/identity/users/sections/permission-catalog.ts +1 -1
  64. package/src/admin/identity/users/sections/queries.ts +1 -1
  65. package/src/admin/identity/users/sections/shared.tsx +1 -1
  66. package/src/admin/incidents/EventsPage.tsx +1 -1
  67. package/src/admin/incidents/IncidentDetail.tsx +1 -1
  68. package/src/admin/incidents/IncidentsPage.tsx +1 -1
  69. package/src/admin/incidents/index.ts +1 -8
  70. package/src/admin/incidents/models.ts +1 -1
  71. package/src/admin/index.ts +5 -364
  72. package/src/admin/jobs/JobDetail.tsx +1 -1
  73. package/src/admin/jobs/JobsTablePage.tsx +1 -1
  74. package/src/admin/jobs/RunnerDetail.tsx +2 -2
  75. package/src/admin/jobs/ScheduledTaskDetail.tsx +1 -1
  76. package/src/admin/jobs/ScheduledTasksPage.tsx +1 -1
  77. package/src/admin/jobs/columns.tsx +1 -1
  78. package/src/admin/jobs/control.ts +1 -1
  79. package/src/admin/jobs/index.ts +1 -39
  80. package/src/admin/jobs/models.ts +1 -1
  81. package/src/admin/jobs/queries.ts +1 -1
  82. package/src/admin/jobs/sections/JobChannelsPanel.tsx +1 -1
  83. package/src/admin/jobs/sections/JobOperationsSection.tsx +1 -1
  84. package/src/admin/messaging/EmailDomainWizard.tsx +1 -1
  85. package/src/admin/messaging/PublicMessagesPage.tsx +1 -1
  86. package/src/admin/messaging/api.ts +1 -1
  87. package/src/admin/messaging/data.ts +1 -1
  88. package/src/admin/messaging/index.ts +1 -9
  89. package/src/admin/messaging/models.ts +1 -1
  90. package/src/admin/messaging/push/PushPage.tsx +1 -1
  91. package/src/admin/messaging/push/api.ts +1 -1
  92. package/src/admin/messaging/push/index.ts +1 -4
  93. package/src/admin/messaging/push/models.ts +1 -1
  94. package/src/admin/monitoring/MetricsExplorerPage.tsx +1 -1
  95. package/src/admin/monitoring/MetricsSourcePicker.tsx +1 -1
  96. package/src/admin/monitoring/index.ts +1 -41
  97. package/src/admin/monitoring/metrics-explorer-client.ts +2 -2
  98. package/src/admin/monitoring/models.ts +1 -1
  99. package/src/admin/network/BlockedIPsPage.tsx +1 -1
  100. package/src/admin/network/BlockedIpDetail.tsx +1 -1
  101. package/src/admin/network/IPSetDetail.tsx +1 -1
  102. package/src/admin/network/IPSetsPage.tsx +1 -1
  103. package/src/admin/network/geofence/BlocksTab.tsx +1 -1
  104. package/src/admin/network/geofence/ExemptionsTab.tsx +1 -1
  105. package/src/admin/network/geofence/GeofencingPage.tsx +1 -1
  106. package/src/admin/network/geofence/RulesTab.tsx +1 -1
  107. package/src/admin/network/geofence/geofence-data.ts +3 -21
  108. package/src/admin/network/geofence/permissions.ts +13 -0
  109. package/src/admin/network/index.ts +1 -41
  110. package/src/admin/network/models.ts +3 -9
  111. package/src/admin/network/permissions.ts +8 -0
  112. package/src/admin/phonehub/PhoneHubPage.tsx +3 -3
  113. package/src/admin/phonehub/api.ts +1 -1
  114. package/src/admin/phonehub/data.ts +1 -1
  115. package/src/admin/phonehub/index.ts +1 -4
  116. package/src/admin/phonehub/models.ts +1 -1
  117. package/src/admin/public/assistant-launcher.ts +1 -0
  118. package/src/admin/public/assistant.ts +2 -0
  119. package/src/admin/public/communications.ts +4 -0
  120. package/src/admin/public/identity.ts +3 -0
  121. package/src/admin/public/infrastructure.ts +4 -0
  122. package/src/admin/public/observability.ts +4 -0
  123. package/src/admin/public/operations.ts +3 -0
  124. package/src/admin/public/security.ts +7 -0
  125. package/src/admin/registry.ts +40 -0
  126. package/src/admin/rules/RuleSetDetailPage.tsx +1 -1
  127. package/src/admin/rules/RuleSetsPage.tsx +1 -1
  128. package/src/admin/rules/index.ts +1 -6
  129. package/src/admin/rules/models.ts +1 -1
  130. package/src/admin/security/devices/LoginEventDetail.tsx +1 -1
  131. package/src/admin/security/devices/LoginLocationsPage.tsx +1 -1
  132. package/src/admin/security/devices/UserDeviceDetail.tsx +1 -1
  133. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +1 -1
  134. package/src/admin/security/devices/index.ts +1 -22
  135. package/src/admin/security/devices/models.ts +1 -1
  136. package/src/admin/security/geoip/GeoIpCachePage.tsx +1 -1
  137. package/src/admin/security/geoip/GeoIpDossier.tsx +1 -1
  138. package/src/admin/security/geoip/geoip-forms.ts +1 -1
  139. package/src/admin/security/geoip/index.ts +1 -17
  140. package/src/admin/security/geoip/models.ts +1 -1
  141. package/src/admin/security/index.ts +1 -23
  142. package/src/admin/security/models.ts +1 -1
  143. package/src/admin/security/tickets.tsx +1 -1
  144. package/src/admin/settings/SettingDetail.tsx +1 -1
  145. package/src/admin/settings/SettingEditor.tsx +1 -1
  146. package/src/admin/settings/index.ts +1 -13
  147. package/src/admin/settings/model.ts +1 -1
  148. package/src/admin/shortlinks/index.ts +1 -12
  149. package/src/admin/shortlinks/models.ts +1 -1
  150. package/src/admin/storage/BackendsPage.tsx +1 -1
  151. package/src/admin/storage/FileManagerDetail.tsx +1 -1
  152. package/src/admin/storage/FileView.tsx +1 -1
  153. package/src/admin/storage/FilesPage.tsx +1 -1
  154. package/src/admin/storage/index.ts +1 -18
  155. package/src/admin/storage/models.ts +3 -4
  156. package/src/admin/storage/permissions.ts +3 -0
  157. package/src/admin/storage/storage-dialogs.tsx +1 -1
  158. package/src/client/client.ts +7 -1
  159. package/src/client/mock.ts +10 -11
  160. package/src/client/realtime-mock.ts +17 -13
  161. package/src/client/realtime.ts +5 -4
  162. package/src/client/runtime.ts +21 -0
  163. package/src/client/upload.ts +6 -3
  164. package/src/ui/image-editor/ImageEditor.tsx +21 -22
  165. package/src/ui/image-editor/canvas.ts +82 -11
  166. package/src/ui/image-editor/math.ts +78 -0
  167. package/src/ui/shell.ts +11 -0
package/README.md CHANGED
@@ -13,12 +13,53 @@ Live demos for everything: run `npm run dev:showcase` → **Develop → Componen
13
13
 
14
14
  ## Subpath surfaces
15
15
 
16
+ The `client/runtime`, `ui/shell`, `admin/registry`, and Assistant launcher
17
+ subpaths are stable, narrow package boundaries for first-party portal shells
18
+ and advanced hosts. They intentionally expose only the runtime pieces needed
19
+ at application startup; use the broader compatibility surfaces when those
20
+ additional exports are required.
21
+
16
22
  | Subpath | Surface |
17
23
  |---|---|
18
24
  | `portal-mojo/client` | Typed django-mojo protocol layer: envelope unwrap at exactly one boundary (a failed save **rejects**), `start`/`size` paging, `'-field'` sort, Django lookups, the URL-synced `useTableParams` store (single source of truth for table state), TanStack Query hooks. Auth client: password / magic-link / passkey login, forgot/reset, cross-origin handoff (`?auth_code=` scrub-before-network), single-flight refresh + pre-request gate (synthetic-401 reject, `/api/token/refresh` recursion guard), `X-Mojo-UID` device header, `getAuthSnapshot`/`subscribeAuth` for React. Auth-challenged realtime transport: `RealtimeProvider`/`RealtimeClient`, refcounted topics, typed event projection, and a deterministic mock. Boot auth with `initAuth()`. The in-memory HTTP mock transport lives here too — it is the wire contract's executable spec and evolves in lockstep with the client (any seeded active user logs in with email + `"mojo"`). |
25
+ | `portal-mojo/client/runtime` | Stable, narrow client boundary for first-party application runtime code. It omits eager mock-test controls; the mock transport itself loads on the first mock request. |
19
26
  | `portal-mojo/ui` | Mission-control UI: `ModelTable`, `FilterBar`/`FilterPills`, `SchemaForm`/`formModal`, `DetailView`, `RecordFeed`, `AttachmentQueue`/`UploadQueue`, `ImageEditor`/`imageEditorModal`, awaitable native-`<dialog>` `modal`, `toast`, `ThemeProvider`, `Guarded` (permission slot), `GroupSwitcher` (searchable tree selector), `RequiresGroup`, the sidebar engine (`registerMenus`/`setDefaultMenu` registry + `SidebarNav` — static or searchable accordion with a compact icon rail; global and group scopes are explicit), `Badge`/`MetricCard`/`Spark`, `fmt` formatter namespace. |
27
+ | `portal-mojo/ui/shell` | Stable, narrow boundary for application-shell primitives: theme, guards, menus/sidebar, right panel, modal/toast hosts, password helpers, and formatters. |
20
28
  | `portal-mojo/charts` | Dependency-free SVG charts: `SeriesChart` (line/bar/area, stacked bars, legend toggle, crosshair tooltip) and `MetricsChart` (granularity/range/type control bar for `/api/metrics/fetch`). |
21
- | `portal-mojo/admin` | Global Admin **section bundles**, dual-mount: the standalone back-office portal mounts all of them at root; a product portal embeds them under a global "System" area. `AdminSection` contract + `adminSectionsMenu()` (sections → domain-grouped sidebar menu, mount-point-relative, with section + route permission clauses). Admin scope masks active-group membership even when embedded. Reusable domains include credentials, monitoring, security operations, settings, Bouncer, Members, and the Assistant control plane with narrow realtime streaming plus authoritative REST fallback/reconciliation. |
29
+ | `portal-mojo/admin/core` | Side-effect-light Admin contracts and host: `AdminSection`, the discriminated `AdminRoute` XOR, `adminSectionRoutes`, `adminSectionsMenu`, and `AdminLazyPage`. |
30
+ | `portal-mojo/admin/registry` | Stable, narrow built-in `ADMIN_SECTIONS` roster for first-party hosts, without broad page barrels. It installs the optional Email→DNS adapter synchronously so communications routes work before DNS navigation. |
31
+ | `portal-mojo/admin/assistant/launcher` | Stable, narrow shell launcher boundary without the Assistant page/data barrel. |
32
+ | `portal-mojo/admin/{identity,security,observability,operations,infrastructure,communications,assistant}` | Narrow domain APIs and registry contributions. Built-in routes use leaf-module `loadComponent` loaders; importing `infrastructure` synchronously installs the optional Email→DNS adapter. |
33
+ | `portal-mojo/admin` | Compatibility aggregate containing every historical named export and the stable `ADMIN_SECTIONS` roster. It intentionally installs the DNS adapter and Rule field registration synchronously. New code should prefer `admin/core` plus the domains it consumes. |
34
+
35
+ ### Admin route contract
36
+
37
+ Routes are an explicit XOR. A host-owned page stays synchronous; a packaged
38
+ page supplies a separately named loader. The host never guesses from whether a
39
+ value is callable.
40
+
41
+ ```tsx
42
+ const extension: AdminSection = {
43
+ id: 'billing', title: 'Billing', icon: 'bi-receipt',
44
+ permissions: ['sys.billing'],
45
+ routes: [{ path: '', component: BillingHome }],
46
+ };
47
+
48
+ const packaged: AdminSection = {
49
+ id: 'audit', title: 'Audit', icon: 'bi-journal-text',
50
+ permissions: ['sys.view_logs'],
51
+ routes: [{
52
+ path: '',
53
+ loadComponent: () => import('./AuditPage').then(({ AuditPage }) => ({ default: AuditPage })),
54
+ }],
55
+ };
56
+ ```
57
+
58
+ Lazy loading, chunk-error recovery, and retry render inside section and route
59
+ permission guards, so a denied route never invokes its loader. Routes stay
60
+ mount-relative: pass no mount for `#/…`, or `{mount: '/system'}` for the same
61
+ sections under `#/system/…`. Root denial and first-visible fallback semantics
62
+ are identical in both forms.
22
63
 
23
64
  ## What the consuming app must provide
24
65
 
@@ -9,20 +9,22 @@ Import from `portal-mojo/admin`.
9
9
  - An owned, text-only conversation uses realtime only when the shared transport
10
10
  is authenticated and the caller locally has `sys.view_admin`, matching the
11
11
  WebSocket handler's narrower server check. The client sends exactly
12
- `{type:'assistant_message',message,conversation_id?}`. A caller with only
12
+ `{type:'assistant_message',message,request_id,conversation_id?}`, with a fresh
13
+ canonical UUID per turn. A caller with only
13
14
  `sys.assistant`, any send with attachments, an unavailable socket, and a
14
15
  foreign inspect-only conversation stay on REST. Messages are never
15
16
  auto-resubmitted across paths.
16
17
  - `POST /api/assistant` sends `{message, conversation_id?, attachments?}`.
17
18
  `attachments` is omitted for text-only sends or contains 1–5 unique positive
18
19
  ids from completed authoritative queue references.
19
- - Realtime positively projects only `assistant_thinking`, `assistant_text`,
20
+ - Realtime accepts these Assistant event shapes only as exact direct frames;
21
+ topic-wrapped or broadcast Assistant-shaped traffic is ignored. It positively
22
+ projects only `assistant_thinking`, `assistant_text`,
20
23
  `assistant_tool_call`, `assistant_plan`, `assistant_plan_update`,
21
- `assistant_response`, and `assistant_error`, whether they arrive directly or
22
- through the transport's one wrapper boundary. Thinking, text, tool, plan,
23
- update, and response events require a positive `conversation_id` and are
24
- correlated to the active turn. `assistant_error` alone may omit it; such an
25
- uncorrelated server error terminates the active turn. Tool state retains
24
+ `assistant_response`, and `assistant_error`. Thinking, text, tool, plan,
25
+ update, response, and error events require the exact `request_id` sent for
26
+ the active turn. Non-error events also require a positive `conversation_id`;
27
+ an error may omit only the conversation id. Tool state retains
26
28
  bounded name/status/count only; raw input and the terminal `tool_calls_made`
27
29
  list never enter UI state, caches, logs, or mock observations. Plans omit
28
30
  tool inputs. Terminal duplication is checked only with the backend's
@@ -37,6 +39,11 @@ Import from `portal-mojo/admin`.
37
39
  reconnect; locally observed permission loss immediately clears this
38
40
  consumer's transient stream/subscriptions without disabling the shared
39
41
  provider.
42
+ - Direct-source binding and the echoed `request_id` jointly isolate concurrent
43
+ turns, including turns in the same conversation. Missing or mismatched IDs
44
+ are ignored; if no correlated acknowledgement arrives within ten seconds,
45
+ the outcome is marked unknown, the list is refreshed, and the send is never
46
+ retried automatically.
40
47
  - `POST /api/assistant/context` sends only `{model, pk}`. The returned conversation id is immediately fetched from `/api/assistant/conversation/<id>?graph=detail`; the client never synthesizes or reposts context text.
41
48
  - Conversation and Skill lists/details/deletes are imperative and component-local. They do not use `defineModel`, Query cache, `ModelTable`, a `RecordFeed` adapter, persistence, or exports.
42
49
  - A foreign conversation visible to an administrator is inspect-only. Only `conversation.user.id === me.id` enables continuation.
@@ -64,6 +64,19 @@ These helpers operate on logical pixels and do not mount UI, upload Files,
64
64
  persist state, or initiate a browser download. `decodeImageSource` is the one
65
65
  exception that performs the URL fetch described above.
66
66
 
67
+ ## Browser-work limits
68
+
69
+ The public low-level constants make the editor's availability boundary explicit:
70
+ `MAX_IMAGE_SOURCE_BYTES` is 25,000,000 bytes, `MAX_IMAGE_DIMENSION` is 8,192
71
+ pixels, `MAX_IMAGE_PIXELS` is 16,000,000 pixels per decoded/intermediate/final
72
+ surface, and `MAX_IMAGE_EDITOR_OPERATIONS` is 20. Every operation output is
73
+ checked before raster allocation. Initial transforms and filters must remain
74
+ inside the same ranges as the controls (scale 0.1–5; brightness, contrast, and
75
+ saturation 0–200; hue 0–360; blur 0–10; grayscale and sepia 0–100). URL bodies
76
+ are streamed to the byte ceiling, and source replacement or unmount aborts the
77
+ fetch and `<img>` fallback where the browser permits; `createImageBitmap` itself
78
+ is not abortable, so a stale result is closed immediately on resolution.
79
+
67
80
  ## Composition and history
68
81
 
69
82
  Operations are immutable and chronological. Transform creates a logical
@@ -58,6 +58,20 @@ Admin section bundles contribute through `adminSectionsMenu`. Use
58
58
  experience. Routes remain mount-relative; multiple embedded root sections
59
59
  share one `/system` landing instead of emitting duplicate index routes.
60
60
 
61
+ Import the route/menu machinery from `portal-mojo/admin/core`, then import only
62
+ the registry domains the shell installs. The stable domains are `identity`,
63
+ `security`, `observability`, `operations`, `infrastructure`, `communications`,
64
+ and `assistant`. `portal-mojo/admin` remains the compatibility aggregate and
65
+ retains every historical export, but deliberately performs the legacy DNS and
66
+ Rule-field setup at import time.
67
+
68
+ Built-in routes use `loadComponent`; external sections may use `component`.
69
+ These properties are mutually exclusive. Loading and retryable error states
70
+ live inside both permission guards, so access denial performs no page import.
71
+ The infrastructure entry installs the optional managed-DNS adapter
72
+ synchronously, which makes Email-first navigation safe even before any DNS page
73
+ chunk has loaded. `admin/core` performs neither DNS nor field registration.
74
+
61
75
  ## Permissions (A2) — client gates, server authority
62
76
 
63
77
  `Me.permissions` is a dict; truthy `true` OR `1` grants. Semantics
package/docs/realtime.md CHANGED
@@ -28,8 +28,10 @@ disable this shared provider.
28
28
  separately and is never explicitly subscribed by the client. That automatic
29
29
  topic is a delivery scope, not a presence feed.
30
30
  - Framework messages, one wrapped `{type:'message',data,topic?,timestamp?}`
31
- layer, and direct application events share one dispatch boundary. Malformed
32
- frames drop. No generic Query invalidation is performed.
31
+ layer, and direct application events share one dispatch boundary. Every
32
+ projected event carries `source: 'wrapped' | 'direct'` so consumers can keep
33
+ capability-bearing protocols on their exact wire source. Malformed frames
34
+ drop. No generic Query invalidation is performed.
33
35
 
34
36
  The transport does not invent application events. Current django-mojo has no
35
37
  canonical record-created/updated/deleted stream and no canonical presence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,9 +26,21 @@
26
26
  },
27
27
  "exports": {
28
28
  "./client": "./src/client/index.ts",
29
+ "./client/runtime": "./src/client/runtime.ts",
29
30
  "./ui": "./src/ui/index.ts",
31
+ "./ui/shell": "./src/ui/shell.ts",
30
32
  "./charts": "./src/charts/index.ts",
31
- "./admin": "./src/admin/index.ts"
33
+ "./admin": "./src/admin/index.ts",
34
+ "./admin/core": "./src/admin/core/index.ts",
35
+ "./admin/registry": "./src/admin/registry.ts",
36
+ "./admin/identity": "./src/admin/public/identity.ts",
37
+ "./admin/security": "./src/admin/public/security.ts",
38
+ "./admin/observability": "./src/admin/public/observability.ts",
39
+ "./admin/operations": "./src/admin/public/operations.ts",
40
+ "./admin/infrastructure": "./src/admin/public/infrastructure.ts",
41
+ "./admin/communications": "./src/admin/public/communications.ts",
42
+ "./admin/assistant": "./src/admin/public/assistant.ts",
43
+ "./admin/assistant/launcher": "./src/admin/public/assistant-launcher.ts"
32
44
  },
33
45
  "scripts": {
34
46
  "typecheck": "tsc --noEmit"
@@ -0,0 +1,23 @@
1
+ import { useEffect, useState, type MouseEvent } from 'react';
2
+ import { useCan } from '../../client/runtime';
3
+ import { useRightPanel } from '../../ui/RightPanel';
4
+ import { ASSISTANT_PERMISSIONS } from './permissions';
5
+
6
+ export function AssistantLauncher() {
7
+ const { can } = useCan(ASSISTANT_PERMISSIONS);
8
+ const panel = useRightPanel();
9
+ const [loading, setLoading] = useState(false);
10
+ const [error, setError] = useState('');
11
+ useEffect(() => { if (!can && panel.descriptor?.key.startsWith('assistant:')) panel.close(); }, [can, panel]);
12
+ if (!can) return null;
13
+ const open = async (event: MouseEvent<HTMLButtonElement>) => {
14
+ const launcher = event.currentTarget;
15
+ setLoading(true); setError('');
16
+ try {
17
+ const { AssistantPanel } = await import('./AssistantPanel');
18
+ panel.open({ key: 'assistant:new', title: 'Assistant', render: (context) => <AssistantPanel context={context} /> }, launcher);
19
+ } catch { setError('Assistant could not load. Click to retry.'); }
20
+ finally { setLoading(false); }
21
+ };
22
+ return <button type="button" className="btn-icon" title={error || 'Open Assistant'} aria-label={error || 'Open Assistant'} aria-pressed={panel.descriptor?.key === 'assistant:new'} disabled={loading} onClick={(event) => void open(event)}><i className="bi bi-stars" aria-hidden="true" /></button>;
23
+ }
@@ -1,13 +1,14 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import { useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
2
+ import { useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
3
3
  import type { RightPanelRenderContext } from '../../ui';
4
4
  import { AssistantFeed } from './AssistantFeed';
5
5
  import { sendAssistantMessage } from './api';
6
6
  import type { AssistantConversation, AssistantMessage } from './types';
7
7
  import type { FileReference } from '../../client/record-feed';
8
8
  import { AssistantOutcomeUnknownError, chooseAssistantTransport, startAssistantRealtimeTurn, type AssistantRealtimeProgress, type AssistantRealtimeTurn } from './streaming';
9
+ import { ASSISTANT_PERMISSIONS } from './permissions';
9
10
 
10
- export const ASSISTANT_PERMISSIONS = ['sys.view_admin', 'sys.assistant'];
11
+ export { ASSISTANT_PERMISSIONS } from './permissions';
11
12
 
12
13
  export function AssistantPanel({ context, initialConversation = null }: { context: RightPanelRenderContext; initialConversation?: AssistantConversation | null }) {
13
14
  const { can } = useCan(ASSISTANT_PERMISSIONS);
@@ -1,4 +1,4 @@
1
- import { mojoCall, mojoDelete, mojoList, type Params } from '../../client';
1
+ import { mojoCall, mojoDelete, mojoList, type Params } from '../../client/runtime';
2
2
  import { projectConversation, projectConversationSummary, projectReply, projectSkill } from './data';
3
3
  import type { AssistantConversation, AssistantConversationSummary, AssistantReply, AssistantSkill } from './types';
4
4
  import type { FileReference } from '../../client/record-feed';
@@ -1,6 +1,3 @@
1
- import type { AdminSection } from '../index';
2
- import { ASSISTANT_PERMISSIONS } from './AssistantPanel';
3
- import { ConversationsPage, MemoriesPage, SkillsPage } from './pages';
4
1
 
5
2
  export * from './types';
6
3
  export * from './data';
@@ -11,11 +8,4 @@ export * from './launchers';
11
8
  export * from './pages';
12
9
  export * from './streaming';
13
10
 
14
- export const ASSISTANT_ADMIN_SECTION: AdminSection = {
15
- id: 'assistant', basePath: 'assistant', title: 'Assistant', icon: 'bi-stars', navigationGroup: 'assistant', permissions: ASSISTANT_PERMISSIONS,
16
- routes: [
17
- { path: 'conversations', label: 'Conversations', component: ConversationsPage, permissions: ASSISTANT_PERMISSIONS },
18
- { path: 'skills', label: 'Skills', component: SkillsPage, permissions: ASSISTANT_PERMISSIONS },
19
- { path: 'memories', label: 'Memory', component: MemoriesPage, permissions: ['sys.assistant'] },
20
- ],
21
- };
11
+ export { ASSISTANT_ADMIN_SECTION } from '../domains/assistant';
@@ -1,17 +1,11 @@
1
1
  import { useEffect, useRef, useState, type MouseEvent } from 'react';
2
- import { useCan } from '../../client';
3
- import { useRightPanel } from '../../ui';
2
+ import { useCan } from '../../client/runtime';
3
+ import { useRightPanel } from '../../ui/RightPanel';
4
4
  import { SECURITY_VIEW_PERMS } from '../security-permissions';
5
- import { AssistantPanel, ASSISTANT_PERMISSIONS } from './AssistantPanel';
5
+ import { ASSISTANT_PERMISSIONS } from './permissions';
6
6
  import { createAssistantContext } from './api';
7
7
 
8
- export function AssistantLauncher() {
9
- const { can } = useCan(ASSISTANT_PERMISSIONS);
10
- const panel = useRightPanel();
11
- useEffect(() => { if (!can && panel.descriptor?.key.startsWith('assistant:')) panel.close(); }, [can, panel]);
12
- if (!can) return null;
13
- return <button type="button" className="btn-icon" title="Open Assistant" aria-label="Open Assistant" aria-pressed={panel.descriptor?.key === 'assistant:new'} onClick={(event) => panel.open({ key: 'assistant:new', title: 'Assistant', render: (context) => <AssistantPanel context={context} /> }, event.currentTarget)}><i className="bi bi-stars" aria-hidden="true" /></button>;
14
- }
8
+ export { AssistantLauncher } from './AssistantLauncher';
15
9
 
16
10
  export function AssistantContextLauncher({ model, pk, label = 'Ask Assistant' }: { model: 'incident.Incident' | 'incident.Ticket'; pk: number; label?: string }) {
17
11
  const assistant = useCan(ASSISTANT_PERMISSIONS);
@@ -25,7 +19,7 @@ export function AssistantContextLauncher({ model, pk, label = 'Ask Assistant' }:
25
19
  if (!assistant.can || !security.can) return null;
26
20
  const launch = async (event: MouseEvent<HTMLButtonElement>) => {
27
21
  const launcher = event.currentTarget; const run = ++generation.current; setLoading(true); setError('');
28
- try { const conversation = await createAssistantContext(model, pk); if (generation.current !== run) return; panel.open({ key: `assistant:context:${model}:${pk}:${conversation.id}`, title: `Assistant · ${model === 'incident.Incident' ? 'Incident' : 'Ticket'} #${pk}`, render: (context) => <AssistantPanel context={context} initialConversation={conversation} /> }, launcher); }
22
+ try { const [conversation, { AssistantPanel }] = await Promise.all([createAssistantContext(model, pk), import('./AssistantPanel')]); if (generation.current !== run) return; panel.open({ key: `assistant:context:${model}:${pk}:${conversation.id}`, title: `Assistant · ${model === 'incident.Incident' ? 'Incident' : 'Ticket'} #${pk}`, render: (context) => <AssistantPanel context={context} initialConversation={conversation} /> }, launcher); }
29
23
  catch (cause) { if (generation.current === run) setError(cause instanceof Error ? cause.message : 'Could not create context'); }
30
24
  finally { if (generation.current === run) setLoading(false); }
31
25
  };
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client';
2
+ import { mojoList, useCan, useMe, useRealtime, useRealtimeStatus } from '../../client/runtime';
3
3
  import { Badge, fmt, modal, toast } from '../../ui';
4
4
  import { AssistantFeed } from './AssistantFeed';
5
5
  import { deleteAssistantConversation, deleteAssistantMemory, deleteAssistantSkill, getAssistantConversation, getAssistantMemory, getAssistantSkill, listAssistantConversations, listAssistantSkills, saveAssistantMemory, sendAssistantMessage } from './api';
@@ -0,0 +1 @@
1
+ export const ASSISTANT_PERMISSIONS = ['sys.view_admin', 'sys.assistant'];
@@ -9,18 +9,21 @@ const MAX_TEXT = 20_000;
9
9
  const MAX_TOOL_NAME = 160;
10
10
  const MAX_TOOLS = 32;
11
11
  const MAX_PLAN_STEPS = 20;
12
+ const ASSISTANT_ACK_TIMEOUT_MS = 10_000;
13
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
12
14
  const object = (value: unknown): Record<string, unknown> | null => value != null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
13
15
  const text = (value: unknown, cap: number): string | null => typeof value === 'string' && value.length > 0 ? value.slice(0, cap) : null;
14
16
  const conversationId = (value: unknown): number | null => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : null;
17
+ const requestId = (value: unknown): string | null => typeof value === 'string' && UUID_PATTERN.test(value) ? value : null;
15
18
 
16
- export interface AssistantThinkingEvent { type: 'thinking'; conversationId: number }
17
- export interface AssistantTextEvent { type: 'text'; conversationId: number; text: string; blocks: AssistantBlock[] }
18
- export interface AssistantToolEvent { type: 'tool'; conversationId: number; name: string; status: 'running'; count: number }
19
+ export interface AssistantThinkingEvent { type: 'thinking'; conversationId: number; requestId: string }
20
+ export interface AssistantTextEvent { type: 'text'; conversationId: number; requestId: string; text: string; blocks: AssistantBlock[] }
21
+ export interface AssistantToolEvent { type: 'tool'; conversationId: number; requestId: string; name: string; status: 'running'; count: number }
19
22
  export interface AssistantPlanStep { id: number; description: string; status: 'pending' | 'in_progress' | 'done' | 'skipped'; summary?: string }
20
- export interface AssistantPlanEvent { type: 'plan'; conversationId: number; planId: string; title: string; steps: AssistantPlanStep[] }
21
- export interface AssistantPlanUpdateEvent { type: 'plan_update'; conversationId: number; planId: string; stepId: number; status: AssistantPlanStep['status']; summary?: string }
22
- export interface AssistantResponseEvent { type: 'response'; conversationId: number; messageId: number | string | null; created: number | string; response: string; blocks: AssistantBlock[]; toolCount: number; durationMs: number }
23
- export interface AssistantErrorEvent { type: 'error'; conversationId: number | null; error: string }
23
+ export interface AssistantPlanEvent { type: 'plan'; conversationId: number; requestId: string; planId: string; title: string; steps: AssistantPlanStep[] }
24
+ export interface AssistantPlanUpdateEvent { type: 'plan_update'; conversationId: number; requestId: string; planId: string; stepId: number; status: AssistantPlanStep['status']; summary?: string }
25
+ export interface AssistantResponseEvent { type: 'response'; conversationId: number; requestId: string; messageId: number | string | null; created: number | string; response: string; blocks: AssistantBlock[]; toolCount: number; durationMs: number }
26
+ export interface AssistantErrorEvent { type: 'error'; conversationId: number | null; requestId: string; error: string }
24
27
  export type AssistantProjectedEvent = AssistantThinkingEvent | AssistantTextEvent | AssistantToolEvent | AssistantPlanEvent | AssistantPlanUpdateEvent | AssistantResponseEvent | AssistantErrorEvent;
25
28
 
26
29
  function exact(value: unknown, expectedType: string): Record<string, unknown> | null {
@@ -29,18 +32,20 @@ function exact(value: unknown, expectedType: string): Record<string, unknown> |
29
32
  }
30
33
 
31
34
  export function projectAssistantThinking(value: unknown): AssistantThinkingEvent | null {
32
- const raw = exact(value, 'assistant_thinking'); const id = raw && conversationId(raw.conversation_id);
33
- return id == null ? null : { type: 'thinking', conversationId: id };
35
+ const raw = exact(value, 'assistant_thinking'); const id = raw && conversationId(raw.conversation_id); const turn = raw && requestId(raw.request_id);
36
+ return id == null || turn == null ? null : { type: 'thinking', conversationId: id, requestId: turn };
34
37
  }
35
38
 
36
39
  export function projectAssistantText(value: unknown): AssistantTextEvent | null {
37
- const raw = exact(value, 'assistant_text'); const id = raw && conversationId(raw.conversation_id); const content = raw && text(raw.text, MAX_TEXT);
38
- return id == null || content == null ? null : { type: 'text', conversationId: id, text: content, blocks: projectBlocks(raw.blocks) };
40
+ const raw = exact(value, 'assistant_text');
41
+ if (!raw) return null;
42
+ const id = conversationId(raw.conversation_id); const turn = requestId(raw.request_id); const content = text(raw.text, MAX_TEXT);
43
+ return id == null || turn == null || content == null ? null : { type: 'text', conversationId: id, requestId: turn, text: content, blocks: projectBlocks(raw.blocks) };
39
44
  }
40
45
 
41
46
  export function projectAssistantToolCall(value: unknown): AssistantToolEvent | null {
42
- const raw = exact(value, 'assistant_tool_call'); const id = raw && conversationId(raw.conversation_id); const name = raw && text(raw.tool, MAX_TOOL_NAME);
43
- return id == null || name == null ? null : { type: 'tool', conversationId: id, name, status: 'running', count: 1 };
47
+ const raw = exact(value, 'assistant_tool_call'); const id = raw && conversationId(raw.conversation_id); const turn = raw && requestId(raw.request_id); const name = raw && text(raw.tool, MAX_TOOL_NAME);
48
+ return id == null || turn == null || name == null ? null : { type: 'tool', conversationId: id, requestId: turn, name, status: 'running', count: 1 };
44
49
  }
45
50
 
46
51
  const PLAN_STATUSES = new Set<AssistantPlanStep['status']>(['pending', 'in_progress', 'done', 'skipped']);
@@ -49,38 +54,40 @@ function planStatus(value: unknown): AssistantPlanStep['status'] | null {
49
54
  }
50
55
 
51
56
  export function projectAssistantPlan(value: unknown): AssistantPlanEvent | null {
52
- const raw = exact(value, 'assistant_plan'); const id = raw && conversationId(raw.conversation_id); const plan = raw && object(raw.plan);
57
+ const raw = exact(value, 'assistant_plan'); const id = raw && conversationId(raw.conversation_id); const turn = raw && requestId(raw.request_id); const plan = raw && object(raw.plan);
53
58
  const planId = plan && text(plan.plan_id, 80); const title = plan && text(plan.title, 200);
54
- if (id == null || !plan || !planId || !title || !Array.isArray(plan.steps) || plan.steps.length < 1 || plan.steps.length > MAX_PLAN_STEPS) return null;
59
+ if (id == null || turn == null || !plan || !planId || !title || !Array.isArray(plan.steps) || plan.steps.length < 1 || plan.steps.length > MAX_PLAN_STEPS) return null;
55
60
  const steps = plan.steps.map(object).map((step) => {
56
61
  const stepId = step && conversationId(step.id); const description = step && text(step.description, 500); const status = step && planStatus(step.status);
57
62
  if (stepId == null || !description || !status) return null;
58
63
  const summary = text(step.summary, 500);
59
64
  return { id: stepId, description, status, ...(summary ? { summary } : {}) };
60
65
  });
61
- return steps.every(Boolean) ? { type: 'plan', conversationId: id, planId, title, steps: steps as AssistantPlanStep[] } : null;
66
+ return steps.every(Boolean) ? { type: 'plan', conversationId: id, requestId: turn, planId, title, steps: steps as AssistantPlanStep[] } : null;
62
67
  }
63
68
 
64
69
  export function projectAssistantPlanUpdate(value: unknown): AssistantPlanUpdateEvent | null {
65
- const raw = exact(value, 'assistant_plan_update'); const id = raw && conversationId(raw.conversation_id); const planId = raw && text(raw.plan_id, 80);
70
+ const raw = exact(value, 'assistant_plan_update'); const id = raw && conversationId(raw.conversation_id); const turn = raw && requestId(raw.request_id); const planId = raw && text(raw.plan_id, 80);
66
71
  const stepId = raw && conversationId(raw.step_id); const status = raw && planStatus(raw.status); const summary = raw && text(raw.summary, 500);
67
- return id == null || !planId || stepId == null || !status ? null : { type: 'plan_update', conversationId: id, planId, stepId, status, ...(summary ? { summary } : {}) };
72
+ return id == null || turn == null || !planId || stepId == null || !status ? null : { type: 'plan_update', conversationId: id, requestId: turn, planId, stepId, status, ...(summary ? { summary } : {}) };
68
73
  }
69
74
 
70
75
  export function projectAssistantResponse(value: unknown): AssistantResponseEvent | null {
71
- const raw = exact(value, 'assistant_response'); const id = raw && conversationId(raw.conversation_id); const response = raw && typeof raw.response === 'string' ? raw.response.slice(0, MAX_TEXT) : null;
72
- if (id == null || response == null) return null;
76
+ const raw = exact(value, 'assistant_response');
77
+ if (!raw) return null;
78
+ const id = conversationId(raw.conversation_id); const turn = requestId(raw.request_id); const response = typeof raw.response === 'string' ? raw.response.slice(0, MAX_TEXT) : null;
79
+ if (id == null || turn == null || response == null) return null;
73
80
  const messageId = typeof raw.message_id === 'number' && Number.isSafeInteger(raw.message_id) && raw.message_id > 0
74
81
  ? raw.message_id : typeof raw.message_id === 'string' && raw.message_id.length > 0 && raw.message_id.length <= 160 ? raw.message_id : null;
75
82
  const created = typeof raw.created === 'number' || typeof raw.created === 'string' ? raw.created : 0;
76
83
  const toolCount = Array.isArray(raw.tool_calls_made) ? Math.min(raw.tool_calls_made.length, MAX_TOOLS) : typeof raw.tool_calls_made === 'number' ? Math.min(Math.max(0, raw.tool_calls_made), MAX_TOOLS) : 0;
77
84
  const durationMs = typeof raw.duration_ms === 'number' && Number.isFinite(raw.duration_ms) ? Math.max(0, raw.duration_ms) : 0;
78
- return { type: 'response', conversationId: id, messageId, created, response, blocks: projectBlocks(raw.blocks), toolCount, durationMs };
85
+ return { type: 'response', conversationId: id, requestId: turn, messageId, created, response, blocks: projectBlocks(raw.blocks), toolCount, durationMs };
79
86
  }
80
87
 
81
88
  export function projectAssistantError(value: unknown): AssistantErrorEvent | null {
82
- const raw = exact(value, 'assistant_error'); const error = raw && text(raw.error, 1_000);
83
- return !raw || !error ? null : { type: 'error', conversationId: conversationId(raw.conversation_id), error };
89
+ const raw = exact(value, 'assistant_error'); const turn = raw && requestId(raw.request_id); const error = raw && text(raw.error, 1_000);
90
+ return !raw || turn == null || !error ? null : { type: 'error', conversationId: conversationId(raw.conversation_id), requestId: turn, error };
84
91
  }
85
92
 
86
93
  export const ASSISTANT_REALTIME_EVENTS = {
@@ -100,8 +107,8 @@ export function chooseAssistantTransport(input: { owner: boolean; textOnly: bool
100
107
  }
101
108
 
102
109
  export class AssistantOutcomeUnknownError extends Error {
103
- constructor() {
104
- super('Outcome unknown: the connection ended before the server assigned a conversation. The conversation list was refreshed; the message was not resent.');
110
+ constructor(message = 'Outcome unknown: the connection ended before the server assigned a conversation. The conversation list was refreshed; the message was not resent.') {
111
+ super(message);
105
112
  this.name = 'AssistantOutcomeUnknownError';
106
113
  }
107
114
  }
@@ -133,12 +140,15 @@ export function startAssistantRealtimeTurn(
133
140
  seenMessageIds: Set<number | string>;
134
141
  reconcile?: (conversationId: number) => Promise<AssistantConversation>;
135
142
  refreshList?: () => Promise<unknown>;
143
+ ackTimeoutMs?: number;
136
144
  },
137
145
  callbacks: AssistantRealtimeTurnCallbacks,
138
146
  ): AssistantRealtimeTurn {
147
+ const turnRequestId = crypto.randomUUID();
139
148
  let expected = input.conversationId ?? null;
140
149
  let active = true;
141
150
  let disconnected = false;
151
+ let acknowledged = false;
142
152
  let interimSequence = 0;
143
153
  const tools = new Map<string, { name: string; status: 'running'; count: number }>();
144
154
  let plan: AssistantPlanEvent | null = null;
@@ -153,39 +163,48 @@ export function startAssistantRealtimeTurn(
153
163
  cleanups.splice(0).forEach((cleanup) => cleanup());
154
164
  if (error) rejectPromise(error); else resolvePromise();
155
165
  };
156
- const correlated = (id: number | null): id is number => id != null && (expected == null || expected === id);
166
+ const acknowledge = () => {
167
+ if (!acknowledged) acknowledged = true;
168
+ };
169
+ const correlated = (id: number | null, eventRequestId: string): id is number => eventRequestId === turnRequestId && id != null && (expected == null || expected === id);
157
170
  const adopt = (id: number) => {
158
171
  if (expected == null) { expected = id; callbacks.onConversation(id); }
159
172
  };
160
173
  const progress = () => callbacks.onProgress({ tools: [...tools.values()].slice(0, MAX_TOOLS), plan });
161
174
 
162
- cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.thinking, ({ data }) => {
163
- if (!active || !correlated(data.conversationId)) return;
175
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.thinking, ({ data, source }) => {
176
+ if (source !== 'direct' || !active || !correlated(data.conversationId, data.requestId)) return;
177
+ acknowledge();
164
178
  adopt(data.conversationId);
165
179
  }));
166
- cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.text, ({ data }) => {
167
- if (!active || !correlated(data.conversationId)) return;
180
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.text, ({ data, source }) => {
181
+ if (source !== 'direct' || !active || !correlated(data.conversationId, data.requestId)) return;
182
+ acknowledge();
168
183
  adopt(data.conversationId);
169
184
  callbacks.onText({ id: `stream-${data.conversationId}-${++interimSequence}`, role: 'assistant', content: data.text, created: Math.floor(Date.now() / 1000), blocks: data.blocks });
170
185
  }));
171
- cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.tool, ({ data }) => {
172
- if (!active || !correlated(data.conversationId)) return;
186
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.tool, ({ data, source }) => {
187
+ if (source !== 'direct' || !active || !correlated(data.conversationId, data.requestId)) return;
188
+ acknowledge();
173
189
  adopt(data.conversationId);
174
190
  const current = tools.get(data.name);
175
191
  tools.set(data.name, { name: data.name, status: 'running', count: Math.min(MAX_TOOLS, (current?.count ?? 0) + 1) });
176
192
  progress();
177
193
  }));
178
- cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.plan, ({ data }) => {
179
- if (!active || !correlated(data.conversationId)) return;
194
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.plan, ({ data, source }) => {
195
+ if (source !== 'direct' || !active || !correlated(data.conversationId, data.requestId)) return;
196
+ acknowledge();
180
197
  adopt(data.conversationId); plan = data; progress();
181
198
  }));
182
- cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.planUpdate, ({ data }) => {
183
- if (!active || !correlated(data.conversationId) || !plan || plan.planId !== data.planId) return;
199
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.planUpdate, ({ data, source }) => {
200
+ if (source !== 'direct' || !active || !correlated(data.conversationId, data.requestId) || !plan || plan.planId !== data.planId) return;
201
+ acknowledge();
184
202
  plan = { ...plan, steps: plan.steps.map((step) => step.id === data.stepId ? { ...step, status: data.status, ...(data.summary ? { summary: data.summary } : {}) } : step) };
185
203
  progress();
186
204
  }));
187
- cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.response, ({ data }) => {
188
- if (!active || !correlated(data.conversationId)) return;
205
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.response, ({ data, source }) => {
206
+ if (source !== 'direct' || !active || !correlated(data.conversationId, data.requestId)) return;
207
+ acknowledge();
189
208
  adopt(data.conversationId);
190
209
  if (data.messageId == null) {
191
210
  void (input.reconcile ?? getAssistantConversation)(data.conversationId).then((conversation) => {
@@ -202,8 +221,9 @@ export function startAssistantRealtimeTurn(
202
221
  }
203
222
  finish();
204
223
  }));
205
- cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.error, ({ data }) => {
206
- if (!active || (data.conversationId != null && !correlated(data.conversationId))) return;
224
+ cleanups.push(client.on(ASSISTANT_REALTIME_EVENTS.error, ({ data, source }) => {
225
+ if (source !== 'direct' || !active || data.requestId !== turnRequestId || (data.conversationId != null && !correlated(data.conversationId, data.requestId))) return;
226
+ acknowledge();
207
227
  if (data.conversationId != null) adopt(data.conversationId);
208
228
  finish(new Error(data.error));
209
229
  }));
@@ -229,8 +249,16 @@ export function startAssistantRealtimeTurn(
229
249
  }
230
250
  }));
231
251
 
252
+ const ackTimer = window.setTimeout(() => {
253
+ if (!active || acknowledged) return;
254
+ callbacks.onUnknown();
255
+ void (input.refreshList ?? listAssistantConversations)().catch(() => undefined);
256
+ finish(new AssistantOutcomeUnknownError('Outcome unknown: the server did not return a correlated Assistant acknowledgement. The conversation list was refreshed; the message was not resent.'));
257
+ }, input.ackTimeoutMs ?? ASSISTANT_ACK_TIMEOUT_MS);
258
+ cleanups.push(() => window.clearTimeout(ackTimer));
259
+
232
260
  try {
233
- client.send({ type: 'assistant_message', message: input.message, ...(input.conversationId != null ? { conversation_id: input.conversationId } : {}) });
261
+ client.send({ type: 'assistant_message', message: input.message, request_id: turnRequestId, ...(input.conversationId != null ? { conversation_id: input.conversationId } : {}) });
234
262
  } catch (cause) {
235
263
  finish(cause instanceof Error ? cause : new Error('Realtime Assistant send failed'));
236
264
  }
@@ -1,34 +1,7 @@
1
- import type { AdminSection } from '../index';
2
- import { BOUNCER_VIEW_PERMS } from './models';
3
- import { BouncerSignalsPage } from './signals';
4
- import { BouncerDevicesPage } from './devices';
5
- import { BotSignaturesPage } from './signatures';
6
1
 
7
2
  export * from './models';
8
3
  export * from './signals';
9
4
  export * from './devices';
10
5
  export * from './signatures';
11
6
 
12
- /** Mount-point-relative Bouncer contribution for standalone or embedded admin. */
13
- export const BOUNCER_ADMIN_SECTION: AdminSection = {
14
- id: 'bouncer',
15
- basePath: 'security/bouncer',
16
- title: 'Bouncer',
17
- icon: 'bi-shield-check',
18
- navigationGroup: 'security',
19
- permissions: BOUNCER_VIEW_PERMS,
20
- routes: [
21
- {
22
- path: 'signals', label: 'Signal Decisions',
23
- component: BouncerSignalsPage, permissions: BOUNCER_VIEW_PERMS,
24
- },
25
- {
26
- path: 'devices', label: 'Device Reputation',
27
- component: BouncerDevicesPage, permissions: BOUNCER_VIEW_PERMS,
28
- },
29
- {
30
- path: 'signatures', label: 'Bot Signatures',
31
- component: BotSignaturesPage, permissions: BOUNCER_VIEW_PERMS,
32
- },
33
- ],
34
- };
7
+ export { BOUNCER_ADMIN_SECTION } from '../domains/security';
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
2
2
  import {
3
3
  defineModel, mojoCall,
4
4
  type Params,
5
- } from '../../client';
5
+ } from '../../client/runtime';
6
6
  import { SECURITY_VIEW_PERMS } from '../security-permissions';
7
7
  import { sanitizeIncidentRow } from '../incidents/sanitize';
8
8
 
@@ -4,7 +4,7 @@ import {
4
4
  fmt, modal, toast,
5
5
  type BatchAction, type Column, type Field, type FilterDef, type FormData,
6
6
  } from '../../ui';
7
- import { useCan } from '../../client';
7
+ import { useCan } from '../../client/runtime';
8
8
  import {
9
9
  BOUNCER_MANAGE_PERMS, BOUNCER_SIGNATURE_TYPES, BotSignatureModel,
10
10
  type BotSignatureRow, type BotSignatureType,
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { MetricsChart } from '../../charts';
3
- import { useAuthSnapshot } from '../../client';
3
+ import { useAuthSnapshot } from '../../client/runtime';
4
4
  import { fmt } from '../../ui';
5
5
  import {
6
6
  CLOUDWATCH_GRANULARITIES, loadCloudWatchSeries,
@@ -1,5 +1,5 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
- import { mojoCall, useAuthSnapshot, type MetricsResponse, type Params } from '../../client';
2
+ import { mojoCall, useAuthSnapshot, type MetricsResponse, type Params } from '../../client/runtime';
3
3
  import type { MetricsFetchParams } from '../../charts';
4
4
 
5
5
  export const CLOUDWATCH_PERMISSIONS = ['sys.manage_aws'];