@cat-factory/app 0.206.0 → 0.208.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -92
- package/app/components/binaryOutput/BinaryOutputReport.vue +26 -0
- package/app/components/fragments/FragmentLibraryManager.vue +4 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +18 -6
- package/app/components/layout/IntegrationsHub.vue +3 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +58 -0
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +55 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +158 -4
- package/app/components/pipeline/PipelineBuilder.vue +9 -2
- package/app/components/tutorial/TutorialPrompt.vue +20 -12
- package/app/composables/useTutorialTours.ts +9 -5
- package/app/docs/architecture.md +4 -4
- package/app/docs/consumer-extensions.md +47 -47
- package/app/modular/nav-contributions.ts +21 -9
- package/app/modular/tutorial-tours.spec.ts +190 -11
- package/app/modular/tutorial-tours.ts +226 -5
- package/app/utils/binaryOutput.spec.ts +105 -0
- package/app/utils/binaryOutput.ts +131 -8
- package/app/utils/tutorial.spec.ts +19 -0
- package/app/utils/tutorial.ts +31 -1
- package/i18n/locales/de.json +112 -3
- package/i18n/locales/en.json +113 -3
- package/i18n/locales/es.json +112 -3
- package/i18n/locales/fr.json +112 -3
- package/i18n/locales/he.json +112 -3
- package/i18n/locales/it.json +112 -3
- package/i18n/locales/ja.json +112 -3
- package/i18n/locales/pl.json +112 -3
- package/i18n/locales/tr.json +112 -3
- package/i18n/locales/uk.json +112 -3
- package/package.json +2 -2
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Extending the SPA from a consumer deployment
|
|
2
2
|
|
|
3
3
|
A deployment that consumes this layer (`extends: ['@cat-factory/app']`) can contribute
|
|
4
|
-
its own **components**
|
|
5
|
-
palette data
|
|
4
|
+
its own **components** (result windows, navigation entries, inspector panels, agent-kind
|
|
5
|
+
palette data) **without forking the layer**. This is the frontend counterpart of the
|
|
6
6
|
backend's public registries (`registerAgentKind`, `registerGate`; see
|
|
7
7
|
[`backend/docs/custom-agents.md`](../../../backend/docs/custom-agents.md)). The governing
|
|
8
8
|
principle is the same: **zero host edits for a consumer extension**.
|
|
9
9
|
|
|
10
|
-
A worked, end-to-end example ships in the template deployment
|
|
11
|
-
[`deploy/frontend/app/`](../../../deploy/frontend) (the `acme:security` module)
|
|
10
|
+
A worked, end-to-end example ships in the template deployment:
|
|
11
|
+
[`deploy/frontend/app/`](../../../deploy/frontend) (the `acme:security` module): the
|
|
12
12
|
frontend analogue of the backend
|
|
13
13
|
[`@cat-factory/example-custom-agent`](../../../backend/internal/example-custom-agent)
|
|
14
14
|
package. Read this guide alongside it.
|
|
@@ -31,13 +31,13 @@ import AcmeSecurityReport from '../components/acme/AcmeSecurityReport.vue'
|
|
|
31
31
|
export default defineNuxtPlugin(() => {
|
|
32
32
|
registerAppModule(
|
|
33
33
|
defineModule({
|
|
34
|
-
id: 'acme:security', // namespaced
|
|
34
|
+
id: 'acme:security', // namespaced - see "Rules" below
|
|
35
35
|
version: '1.0.0',
|
|
36
36
|
slots: {
|
|
37
37
|
resultViews: [{ id: 'acme:security-report', component: AcmeSecurityReport }],
|
|
38
|
-
agentKinds: [/* palette entries
|
|
39
|
-
nav: [/* sidebar / command-palette destinations
|
|
40
|
-
inspectorPanels: [/* per-block detail panels
|
|
38
|
+
agentKinds: [/* palette entries - see "Agent kinds" */],
|
|
39
|
+
nav: [/* sidebar / command-palette destinations - see "Navigation" */],
|
|
40
|
+
inspectorPanels: [/* per-block detail panels - see "Inspector panels" */],
|
|
41
41
|
},
|
|
42
42
|
}),
|
|
43
43
|
)
|
|
@@ -49,9 +49,9 @@ export default defineNuxtPlugin(() => {
|
|
|
49
49
|
- **`enforce: 'post'` is load-bearing.** The layer's own install plugin is `enforce:
|
|
50
50
|
'post'`, and Nuxt runs layer plugins before the consuming app's plugins within one
|
|
51
51
|
enforce bucket. So your registration plugin must run in the **default** (or `pre`) bucket
|
|
52
|
-
|
|
52
|
+
, i.e. **do not** put `enforce: 'post'` on it, or it registers too late and is silently
|
|
53
53
|
missed.
|
|
54
|
-
- **`defineModule` / the slot-entry types come from `@modular-vue/core
|
|
54
|
+
- **`defineModule` / the slot-entry types come from `@modular-vue/core`**: add it to your
|
|
55
55
|
deployment's `dependencies`.
|
|
56
56
|
|
|
57
57
|
## The landed seams
|
|
@@ -70,7 +70,7 @@ export default defineNuxtPlugin(() => {
|
|
|
70
70
|
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
|
|
71
71
|
|
|
72
72
|
A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
|
|
73
|
-
(the shipped default) exactly as it does for the first-party destinations
|
|
73
|
+
(the shipped default) exactly as it does for the first-party destinations: see
|
|
74
74
|
[the layer README](../../README.md#interface-modes-basic--advanced). Use it for a power-user
|
|
75
75
|
destination; the flag is independent of `gate`, so both must pass for the item to render.
|
|
76
76
|
|
|
@@ -80,7 +80,7 @@ Backend data selects a frontend component, joined by a namespaced id:
|
|
|
80
80
|
|
|
81
81
|
1. A backend agent kind (registered on `AgentKindRegistry`, e.g.
|
|
82
82
|
`@cat-factory/example-custom-agent`'s `security-auditor`) arrives in the workspace
|
|
83
|
-
snapshot with `presentation.resultView: '<ns>:<name>'
|
|
83
|
+
snapshot with `presentation.resultView: '<ns>:<name>'`: **or** you code-ship the kind's
|
|
84
84
|
palette entry via the `agentKinds` slot (as the example does, to give an existing kind a
|
|
85
85
|
bespoke window).
|
|
86
86
|
2. You contribute the component to `resultViews` under the SAME id.
|
|
@@ -107,7 +107,7 @@ nullish subject (the boot-time validation resolve passes `null`).
|
|
|
107
107
|
|
|
108
108
|
### External tools + workspace metadata (`externalTools`, `workspaceMetadataFields`)
|
|
109
109
|
|
|
110
|
-
Put your OWN web applications
|
|
110
|
+
Put your OWN web applications (a map editor, an asset pipeline, an admin console) in the
|
|
111
111
|
sidebar's **External tools** section, and open each one _already scoped to what the user is
|
|
112
112
|
looking at_. That second half is the point of the seam; a static link needs no registration.
|
|
113
113
|
|
|
@@ -132,25 +132,25 @@ workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork'
|
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
- **`url` is a string or a RESOLVER** `(ctx) => string | null`. The context carries `userId`,
|
|
135
|
-
`userEmail`, `workspaceId`, `workspaceName` and `metadata
|
|
135
|
+
`userEmail`, `workspaceId`, `workspaceName` and `metadata`: the custom workspace fields you
|
|
136
136
|
declared. It is read at CLICK time, so a value a teammate fills in while the sidebar is open
|
|
137
137
|
takes effect without a reload.
|
|
138
138
|
- **Clicking opens a separate page** (`target=_blank`, `noopener`). The resolved URL must be
|
|
139
139
|
`http(s)`: anything else is refused rather than handed to the browser, because the string
|
|
140
140
|
reaches `window.open` and a `javascript:` URL would run in the SPA's own origin.
|
|
141
141
|
- **Declare `requiredMetadata` for the fields your resolver needs.** An unconfigured workspace
|
|
142
|
-
then gets "fill in `gameId` on the Metadata tab" instead of a generic failure
|
|
142
|
+
then gets "fill in `gameId` on the Metadata tab" instead of a generic failure, and the tool
|
|
143
143
|
stays LISTED, because the person looking at the sidebar is usually the one who can fix it. A
|
|
144
144
|
resolver that returns `null` reports separately ("this tool gave no address"), since that one
|
|
145
145
|
is yours to fix, not the operator's.
|
|
146
146
|
- **Treat every `ctx.metadata` value as untrusted input.** A workspace admin types these in, so a
|
|
147
|
-
value is operator-supplied text that happens to be length-bounded
|
|
147
|
+
value is operator-supplied text that happens to be length-bounded, not a constant you chose.
|
|
148
148
|
Set it as a query parameter or an `encodeURIComponent`'d path segment, as above. Never build the
|
|
149
149
|
ORIGIN from one: `` `https://${ctx.metadata.region}.acme.dev` `` with `region` set to
|
|
150
150
|
`evil.com/x?a=` resolves to a URL on someone else's host, and the `http(s)` allow-list cannot
|
|
151
151
|
tell that apart from the link you meant.
|
|
152
152
|
- **A resolver that THROWS costs only its own item.** It is caught and reported as a fourth
|
|
153
|
-
reason (`resolver-failed`) with the cause logged to the console
|
|
153
|
+
reason (`resolver-failed`) with the cause logged to the console: the sidebar, the palette and
|
|
154
154
|
the toolbar all render from one catalog, so an uncaught throw would otherwise blank all three.
|
|
155
155
|
Do not rely on it: `requiredMetadata` is how you say a field must be there.
|
|
156
156
|
- **`gate` and `advanced`** work exactly as on a `nav` entry; both must pass.
|
|
@@ -158,27 +158,27 @@ workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork'
|
|
|
158
158
|
**The metadata half** is a deployment-declared FIELD list (here) whose VALUES are per workspace,
|
|
159
159
|
typed in under _Workspace settings → Metadata_ and persisted on the workspace settings row. The
|
|
160
160
|
tab appears only where a deployment declares fields. Keys must be identifier-shaped
|
|
161
|
-
(`^[A-Za-z][A-Za-z0-9_.-]{0,63}
|
|
161
|
+
(`^[A-Za-z][A-Za-z0-9_.-]{0,63}$`: the backend refuses anything else); a malformed or duplicate
|
|
162
162
|
key is dropped with a dev-console warning rather than rendered. `type: 'select'` renders a picker
|
|
163
163
|
over your `options`; everything is stored as a string.
|
|
164
164
|
|
|
165
165
|
Two rules the editor keeps, and any other writer of the bag should too: a CLEARED field drops its
|
|
166
166
|
key (so "unset" never reads as "set to nothing" in a resolver), and a save carries through any
|
|
167
|
-
stored key the current build does not declare
|
|
167
|
+
stored key the current build does not declare; the update replaces the whole bag, so a value
|
|
168
168
|
written under a field you have since retired must not be deleted by an unrelated save.
|
|
169
169
|
|
|
170
170
|
Values are readable anywhere in the SPA via `useWorkspaceSettingsStore().settings.metadata`.
|
|
171
171
|
|
|
172
172
|
### Custom task types (`taskTypes`)
|
|
173
173
|
|
|
174
|
-
Model a proprietary work item
|
|
174
|
+
Model a proprietary work item (an "incident", "pentest", "compliance-audit") as a first-class
|
|
175
175
|
task type, the create-task twin of an agent kind. Contribute `{ taskType: '<ns>:<name>',
|
|
176
176
|
presentation: { label, icon, color, description }, fields?, defaultPipelineId?, formPanel? }` to
|
|
177
177
|
the `taskTypes` slot (see `acme:incident` in the example module). The SPA merges it into the
|
|
178
178
|
create-task picker and the card-badge catalog:
|
|
179
179
|
|
|
180
180
|
- **`presentation`** drives the create-task picker entry and the `TaskCard` type badge (resolved
|
|
181
|
-
through the pure `taskTypeMeta` read-model
|
|
181
|
+
through the pure `taskTypeMeta` read-model: the `agentKindMeta` twin). An UNREGISTERED
|
|
182
182
|
namespaced type (a stale row after your extension is removed) degrades to the `feature`
|
|
183
183
|
presentation, so a leftover string never breaks a card.
|
|
184
184
|
- **`fields`** are descriptor-driven create-form inputs (`text` / `textarea` / `number` /
|
|
@@ -198,20 +198,20 @@ task created with it round-trips with zero host edits.
|
|
|
198
198
|
> (namespaced id, well-formed `formPanel`, a `defaultPipelineId` that resolves to a real pipeline).
|
|
199
199
|
> A CODE-shipped `taskTypes` entry is trusted and **not** validated (like a code-shipped agent kind):
|
|
200
200
|
> a malformed `taskType`/`formPanel` id or a `defaultPipelineId` naming no real pipeline fails
|
|
201
|
-
> silently
|
|
201
|
+
> silently; the type just won't pre-select a pipeline and an unpaired `formPanel` degrades to the
|
|
202
202
|
> descriptor `fields`. Prefer backend registration when you want the fail-fast guardrail.
|
|
203
203
|
|
|
204
204
|
### Top-level overlays (`appOverlays`)
|
|
205
205
|
|
|
206
|
-
A nav item's `run` closure
|
|
206
|
+
A nav item's `run` closure, or any consumer code, often needs to open a full-screen panel of
|
|
207
207
|
its own: a dashboard, a wizard, a settings surface. The layer's first-party modals are
|
|
208
208
|
hand-mounted in `pages/index.vue`, which a consumer can't edit, so the `appOverlays` slot + the
|
|
209
209
|
single `<AppOverlayHost>` are the seam:
|
|
210
210
|
|
|
211
211
|
1. Contribute `{ id: '<ns>:<name>', component }` to the `appOverlays` slot (see
|
|
212
212
|
`acme:security-dashboard-overlay` in the example module).
|
|
213
|
-
2. Open it from anywhere with the auto-imported `useAppOverlays().open('<ns>:<name>', subject?)
|
|
214
|
-
|
|
213
|
+
2. Open it from anywhere with the auto-imported `useAppOverlays().open('<ns>:<name>', subject?)`:
|
|
214
|
+
typically a nav item's `run` closure. The optional `subject` is any value your overlay
|
|
215
215
|
renders against (e.g. a block id); it reaches the component as a `subject` prop.
|
|
216
216
|
3. `<AppOverlayHost>` resolves the slot with `resolveComponentRegistry` (the same pick-one
|
|
217
217
|
primitive `resultViews` uses) and mounts the matching component, wiring its `close` emit to
|
|
@@ -219,16 +219,16 @@ single `<AppOverlayHost>` are the seam:
|
|
|
219
219
|
|
|
220
220
|
It is a **pick-one** host: opening a second overlay replaces the first, and `close()` clears it.
|
|
221
221
|
Compose the shared `ResultWindowShell` (via `#components`) for chrome so your overlay inherits
|
|
222
|
-
focus-trap / scroll-lock / shared-stack Escape
|
|
223
|
-
(`open('<ns>:x')` with no registered component
|
|
222
|
+
focus-trap / scroll-lock / shared-stack Escape: emit `close` from its `@close`. A dangling open
|
|
223
|
+
(`open('<ns>:x')` with no registered component, e.g. a stale closure after the extension was
|
|
224
224
|
removed) degrades to nothing (a dev-console warning names the id), never a crash. Duplicate ids
|
|
225
225
|
across modules throw at boot, like every other slot.
|
|
226
226
|
|
|
227
227
|
> **Scope.** This seam is for CONSUMER overlays. The layer's own ~34 first-party modals stay
|
|
228
|
-
> hand-mounted in `index.vue` and are migrated only opportunistically
|
|
228
|
+
> hand-mounted in `index.vue` and are migrated only opportunistically: don't reach for
|
|
229
229
|
> `appOverlays` to replace a first-party fast-path modal.
|
|
230
230
|
|
|
231
|
-
## Reuse the shared building blocks
|
|
231
|
+
## Reuse the shared building blocks: don't reinvent them
|
|
232
232
|
|
|
233
233
|
The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
|
|
234
234
|
re-deriving the "which run is this / how did the model do" facts. **Composables** (and the
|
|
@@ -236,25 +236,25 @@ re-deriving the "which run is this / how did the model do" facts. **Composables*
|
|
|
236
236
|
**components** must be named through the `#components` virtual module (see the boxed note
|
|
237
237
|
below). Compose these:
|
|
238
238
|
|
|
239
|
-
| Building block | Reference it as | What it gives you
|
|
240
|
-
| ----------------------------- | ----------------------------------------- |
|
|
241
|
-
| `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window
|
|
242
|
-
| `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar
|
|
243
|
-
| `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown.
|
|
244
|
-
| `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance.
|
|
245
|
-
| `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one.
|
|
246
|
-
| `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here.
|
|
247
|
-
| `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path
|
|
248
|
-
| `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`.
|
|
249
|
-
| `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays").
|
|
239
|
+
| Building block | Reference it as | What it gives you |
|
|
240
|
+
| ----------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
241
|
+
| `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window: backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. It also renders the universal per-step trailing sections (agent effort, pre-PR validation, binary outputs) off the ACTIVE step, so your window inherits them and must not re-render them itself. |
|
|
242
|
+
| `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar, never reinvent run metadata. |
|
|
243
|
+
| `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
|
|
244
|
+
| `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
|
|
245
|
+
| `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
|
|
246
|
+
| `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
|
|
247
|
+
| `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path (from a board card or the inspector) carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
|
|
248
|
+
| `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
|
|
249
|
+
| `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
|
|
250
250
|
|
|
251
251
|
> **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
|
|
252
252
|
> layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
|
|
253
253
|
> → `PanelsResultWindowShell`), and only rewrites bare `<ResultWindowShell>` tags inside the
|
|
254
254
|
> layer's own SFCs. A bare tag in a **consumer** SFC resolves to nothing and silently renders
|
|
255
|
-
> as an unknown element
|
|
255
|
+
> as an unknown element: its `<slot>` children still appear, so a shallow test can pass while
|
|
256
256
|
> the shared chrome (and its `data-testid`) never mounts. Import the ones you use from
|
|
257
|
-
> `#components` (Nuxt's stable virtual registry
|
|
257
|
+
> `#components` (Nuxt's stable virtual registry: **not** a deep path into the layer's
|
|
258
258
|
> `app/components/*`), aliasing them back to the short names for readable templates:
|
|
259
259
|
>
|
|
260
260
|
> ```ts
|
|
@@ -274,13 +274,13 @@ below). Compose these:
|
|
|
274
274
|
|
|
275
275
|
Some state the engine records on a step is deliberately NOT a result view's job, because the
|
|
276
276
|
record's scope is wider than any one kind: the agent's effort self-assessment, the pre-PR
|
|
277
|
-
validation report, and
|
|
277
|
+
validation report, and (for a `binary-output` generator) the artifacts it declared it stored
|
|
278
278
|
(`step.binaryOutputs`). `ResultWindowShell` resolves the active step itself and renders each as a
|
|
279
279
|
collapsible trailing section, and the generic step-detail panel renders the same components for a
|
|
280
280
|
step whose kind declares no window at all.
|
|
281
281
|
|
|
282
282
|
So a generator kind should declare a result view for its OWN output (or none), and leave the
|
|
283
|
-
artifact list alone
|
|
283
|
+
artifact list alone: you get it either way, on every entry point, with no id to register. A
|
|
284
284
|
window that renders it again just shows it twice.
|
|
285
285
|
|
|
286
286
|
The example `AcmeSecurityReport.vue` window is a full demonstration: it imports
|
|
@@ -293,19 +293,19 @@ reads the auditor's structured assessment straight off `step.custom`.
|
|
|
293
293
|
Ship your strings under your own namespace in the deployment's `i18n/locales/*.json` (e.g.
|
|
294
294
|
`acme.*`). `@nuxtjs/i18n` is layer-aware and **deep-merges** them into the layer catalog, so
|
|
295
295
|
`t('acme.securityReport.title')` resolves in your components with no config change. The
|
|
296
|
-
layer's typed-key and locale-parity guards govern only the layer's own keys
|
|
296
|
+
layer's typed-key and locale-parity guards govern only the layer's own keys: your namespace
|
|
297
297
|
is yours.
|
|
298
298
|
|
|
299
299
|
## Rules that hold across every seam
|
|
300
300
|
|
|
301
301
|
- **Namespacing.** Every consumer-authored id is `<ns>:<name>`. Built-ins are never
|
|
302
|
-
shadowable
|
|
302
|
+
shadowable: the merge logic drops a consumer entry whose id collides with a built-in
|
|
303
303
|
(see the agents store).
|
|
304
304
|
- **Fail fast at boot, degrade at runtime.** Duplicate ids across first-party + consumer
|
|
305
305
|
modules throw when the layer resolves the merged slots at startup; missing pairings and
|
|
306
306
|
unknown wire ids degrade with a dev-console warning, never a crash.
|
|
307
307
|
- **Never crash on stale data.** An id that arrives on the wire (a `resultView`, an agent
|
|
308
|
-
kind) after its extension was removed must degrade to a defined rendering
|
|
308
|
+
kind) after its extension was removed must degrade to a defined rendering: extensions get
|
|
309
309
|
uninstalled while persisted rows outlive them.
|
|
310
310
|
- **The remote manifest is DATA only.** Components never travel the wire; per-workspace
|
|
311
311
|
variability comes from which capabilities the snapshot lists, not from which modules are
|
|
@@ -609,16 +609,32 @@ export const navigationModule = defineModule({
|
|
|
609
609
|
slots: { nav: [...NAV_CONTRIBUTIONS] },
|
|
610
610
|
})
|
|
611
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Does a shell render this contribution under `gates`?
|
|
614
|
+
*
|
|
615
|
+
* The two axes a destination is gated on, in ONE place. They are independent and BOTH must
|
|
616
|
+
* pass: an `advanced` item is dropped in basic mode, and every item still answers to its own
|
|
617
|
+
* `gate`. Order doesn't matter (it's a conjunction) but the tier is checked first, since it's
|
|
618
|
+
* the cheaper read.
|
|
619
|
+
*
|
|
620
|
+
* Named rather than inlined in {@link navSlotFilter} because a second reader has to agree with
|
|
621
|
+
* it exactly: a tutorial tour whose step CLICKS a nav entry declares the requirement that
|
|
622
|
+
* renders it, and `tutorial-tours.spec.ts` pairs the two through this function. Spelling the
|
|
623
|
+
* conjunction out there instead would be a copy that keeps passing while this one changes —
|
|
624
|
+
* and the drift it would miss (an entry gaining a gate clause, or being marked `advanced` and
|
|
625
|
+
* so leaving the DEFAULT interface tier) is precisely a tour offered to a user who then finds
|
|
626
|
+
* no such control.
|
|
627
|
+
*/
|
|
628
|
+
export function navItemVisible(item: NavContribution, gates: NavGates): boolean {
|
|
629
|
+
return (item.advanced ? gates.advancedMode : true) && (item.gate ? item.gate(gates) : true)
|
|
630
|
+
}
|
|
631
|
+
|
|
612
632
|
/**
|
|
613
633
|
* Reactive RBAC/availability/interface-tier filter over the merged `nav` slot. Reads
|
|
614
634
|
* `deps.gates.*` (the reactive gate service) per item, so evaluated inside
|
|
615
635
|
* `useReactiveSlots` it re-runs when a permission, connection, or the interface
|
|
616
636
|
* mode flips. Passed to `installModularApp` as the global `slotFilter`.
|
|
617
637
|
*
|
|
618
|
-
* The two axes are independent and BOTH must pass: an `advanced` item is dropped in
|
|
619
|
-
* basic mode, and every item still answers to its own `gate`. Order doesn't matter
|
|
620
|
-
* (it's a conjunction) but the tier is checked first, since it's the cheaper read.
|
|
621
|
-
*
|
|
622
638
|
* Typed against `AppSlots` (not the generic `SlotFilter`) so it matches the
|
|
623
639
|
* filter shape the runtime infers for this registry. `deps` is widened to an
|
|
624
640
|
* optional `gates` to avoid importing `AppDeps` (which would be circular).
|
|
@@ -631,11 +647,7 @@ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppS
|
|
|
631
647
|
...slots,
|
|
632
648
|
// No gates service wired (tests / bare install) ⇒ show everything, matching
|
|
633
649
|
// the dev-open "absent access allows all" backend parity.
|
|
634
|
-
nav: gates
|
|
635
|
-
? nav.filter(
|
|
636
|
-
(i) => (i.advanced ? gates.advancedMode : true) && (i.gate ? i.gate(gates) : true),
|
|
637
|
-
)
|
|
638
|
-
: nav,
|
|
650
|
+
nav: gates ? nav.filter((i) => navItemVisible(i, gates)) : nav,
|
|
639
651
|
// `tutorialTours` is deliberately NOT filtered here, unlike every other gated slot. A
|
|
640
652
|
// `SlotFilter` can only DROP, and the tutorial catalogue's whole job is to explain what
|
|
641
653
|
// was dropped — which tour this board can't run yet, and what would unlock it. That is a
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
TUTORIAL_TOURS,
|
|
8
8
|
tutorialToursModule,
|
|
9
9
|
} from '~/modular/tutorial-tours'
|
|
10
|
-
import { resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
|
|
10
|
+
import { isLaunchOffer, resolveTourCatalogue, resolveTours } from '~/utils/tutorial'
|
|
11
11
|
import { isSafeTargetId } from '~/components/tutorial/TutorialOverlay.logic'
|
|
12
|
-
import
|
|
12
|
+
import { NAV_CONTRIBUTIONS, navItemVisible } from '~/modular/nav-contributions'
|
|
13
|
+
import type { NavContribution, NavGates } from '~/modular/nav-contributions'
|
|
14
|
+
import type { TutorialStep, TutorialTour } from '~/utils/tutorial'
|
|
13
15
|
|
|
14
16
|
const ALL_GATES: NavGates = {
|
|
15
17
|
canWriteBoard: true,
|
|
@@ -100,6 +102,90 @@ function declaredAnchors(): { label: string; id: string }[] {
|
|
|
100
102
|
return out
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Every step whose anchor IS a nav entry, paired with the contribution it points at.
|
|
107
|
+
*
|
|
108
|
+
* Those steps are the ones a tour's `requires` has to agree with, because the anchor only
|
|
109
|
+
* exists while the sidebar/palette renders that entry. An anchor that is NOT a nav entry (a
|
|
110
|
+
* control inside the modal the click opens) has no contribution to pair with and is left to
|
|
111
|
+
* the anchor guard above; `altTargets` are included, since a fallback anchor is reached on
|
|
112
|
+
* exactly the same terms as the primary one.
|
|
113
|
+
*/
|
|
114
|
+
function navAnchoredSteps(): {
|
|
115
|
+
tour: TutorialTour
|
|
116
|
+
step: TutorialStep
|
|
117
|
+
item: NavContribution
|
|
118
|
+
label: string
|
|
119
|
+
}[] {
|
|
120
|
+
const byTestId = new Map(
|
|
121
|
+
NAV_CONTRIBUTIONS.flatMap((item) => (item.testId ? [[item.testId, item] as const] : [])),
|
|
122
|
+
)
|
|
123
|
+
return TUTORIAL_TOURS.flatMap((tour) =>
|
|
124
|
+
tour.steps.flatMap((step) =>
|
|
125
|
+
[step.target, ...(step.altTargets ?? [])].flatMap((target) => {
|
|
126
|
+
const item = target === undefined ? undefined : byTestId.get(target)
|
|
127
|
+
return item ? [{ tour, step, item, label: `${tour.id}/${step.id} -> ${item.id}` }] : []
|
|
128
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Every combination of the {@link NavGates} booleans, yielded lazily so the 2^N gate sets are
|
|
135
|
+
* never all live at once.
|
|
136
|
+
*
|
|
137
|
+
* Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
|
|
138
|
+
* gate sets — anything that satisfies a tour must also render its entry — between two
|
|
139
|
+
* predicates written independently in two files, and nothing about their shape is guaranteed
|
|
140
|
+
* (either may be a conjunction, a disjunction, or read a field the other doesn't). At fifteen
|
|
141
|
+
* fields the whole matrix costs milliseconds, which is a fair price for a guard that needs no
|
|
142
|
+
* assumption about how either side is spelled.
|
|
143
|
+
*/
|
|
144
|
+
function* everyGateSet(): Generator<NavGates> {
|
|
145
|
+
const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]
|
|
146
|
+
for (let mask = 0; mask < 2 ** keys.length; mask++) {
|
|
147
|
+
const gates = {} as Record<keyof NavGates, boolean>
|
|
148
|
+
for (const [bit, key] of keys.entries()) gates[key] = (mask & (1 << bit)) !== 0
|
|
149
|
+
yield gates as NavGates
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The gate fields a witness has turned OFF — the interesting half of a failure message. */
|
|
154
|
+
function absentGates(gates: NavGates): readonly string[] {
|
|
155
|
+
return Object.entries(gates)
|
|
156
|
+
.filter(([, value]) => value === false)
|
|
157
|
+
.map(([key]) => key)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A gate set that OFFERS this tour while the entry its step clicks is NOT rendered, or
|
|
162
|
+
* `undefined` when no such set exists (what the guard wants). A step the gate set DROPS
|
|
163
|
+
* (`when`) needs no anchor, so it cannot be a counterexample.
|
|
164
|
+
*
|
|
165
|
+
* Of the many counterexamples one break produces, the one reported is the SMALLEST: the gate
|
|
166
|
+
* set closest to fully-permitted, so its absent fields are exactly the ones that matter. The
|
|
167
|
+
* first witness the matrix happens to reach names most of `NavGates` and reads as noise, which
|
|
168
|
+
* is the difference between a failure that says "declare `advancedMode`" and one that says
|
|
169
|
+
* "something about fourteen gates".
|
|
170
|
+
*/
|
|
171
|
+
function navRequirementDrift(
|
|
172
|
+
pair: ReturnType<typeof navAnchoredSteps>[number],
|
|
173
|
+
): NavGates | undefined {
|
|
174
|
+
let smallest: NavGates | undefined
|
|
175
|
+
let fewestAbsent = Number.POSITIVE_INFINITY
|
|
176
|
+
for (const gates of everyGateSet()) {
|
|
177
|
+
if (!(pair.tour.requires ?? []).every((requirement) => requirement.met(gates))) continue
|
|
178
|
+
if (pair.step.when && !pair.step.when(gates)) continue
|
|
179
|
+
if (navItemVisible(pair.item, gates)) continue
|
|
180
|
+
const absent = absentGates(gates).length
|
|
181
|
+
if (absent < fewestAbsent) {
|
|
182
|
+
fewestAbsent = absent
|
|
183
|
+
smallest = gates
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return smallest
|
|
187
|
+
}
|
|
188
|
+
|
|
103
189
|
/** Every static test id this layer actually renders. */
|
|
104
190
|
function renderedTestIds(): Set<string> {
|
|
105
191
|
const ids = new Set<string>()
|
|
@@ -190,14 +276,52 @@ describe('the built-in tutorial tour catalog', () => {
|
|
|
190
276
|
expect(missing).toEqual([])
|
|
191
277
|
})
|
|
192
278
|
|
|
279
|
+
it('requires, of every step that clicks a nav entry, whatever renders that entry', () => {
|
|
280
|
+
// The other drift guard, and the one the availability cases below CANNOT stand in for.
|
|
281
|
+
// Those assert this pairing by restating the permission in a hand-built gate set, which
|
|
282
|
+
// says nothing about the nav catalog: both edits that really break the pairing happen in
|
|
283
|
+
// `nav-contributions.ts` and touch no tour.
|
|
284
|
+
//
|
|
285
|
+
// - an entry's `gate` gains a clause the tour doesn't require;
|
|
286
|
+
// - an entry is marked `advanced: true`, which removes it from BASIC mode — and basic is
|
|
287
|
+
// the SHIPPED DEFAULT, so the tour would then be offered to nearly every user and find
|
|
288
|
+
// nothing.
|
|
289
|
+
//
|
|
290
|
+
// Both land on the same production failure: the tour is offered, hunts for an anchor that
|
|
291
|
+
// this user's sidebar never renders, skips the step (no `when`, so the miss COUNTS) and
|
|
292
|
+
// leaves them on a permanent "you missed N steps" notice about a walkthrough that could
|
|
293
|
+
// not have gone any other way. So the requirement is derived from the entry's OWN
|
|
294
|
+
// visibility rule (`navItemVisible`, the function `navSlotFilter` itself filters with)
|
|
295
|
+
// rather than spelled out a second time here.
|
|
296
|
+
const pairs = navAnchoredSteps()
|
|
297
|
+
// Guard the guard, twice over. A pairing that matched nothing would pass vacuously, and
|
|
298
|
+
// there is one per tour that opens a sidebar surface: `add-service` plus the four platform
|
|
299
|
+
// tours. And a gate field that is not a boolean would silently never vary across the
|
|
300
|
+
// matrix, leaving whatever it gates unexercised.
|
|
301
|
+
expect(pairs.length).toBeGreaterThanOrEqual(5)
|
|
302
|
+
expect(Object.values(ALL_GATES).every((value) => typeof value === 'boolean')).toBe(true)
|
|
303
|
+
|
|
304
|
+
const drifted = pairs.flatMap((pair) => {
|
|
305
|
+
const witness = navRequirementDrift(pair)
|
|
306
|
+
if (witness === undefined) return []
|
|
307
|
+
return [`${pair.label} is offered without ${absentGates(witness).join(' / ')}`]
|
|
308
|
+
})
|
|
309
|
+
expect(drifted).toEqual([])
|
|
310
|
+
})
|
|
311
|
+
|
|
193
312
|
it('is contributed to the tutorialTours slot by the module', () => {
|
|
194
313
|
expect(tutorialToursModule.slots?.tutorialTours).toEqual([...TUTORIAL_TOURS])
|
|
195
314
|
})
|
|
196
315
|
})
|
|
197
316
|
|
|
198
317
|
describe('tour availability across the catalog', () => {
|
|
199
|
-
/** The ids a board can START right now —
|
|
318
|
+
/** The ids a board can START right now — every startable tour, whatever offers it. */
|
|
200
319
|
const ready = (gates: NavGates) => resolveTours(TUTORIAL_TOURS, gates).map((t) => t.id)
|
|
320
|
+
/** The narrower set the LAUNCH PROMPT asks about (`useTutorialTours().offered`). */
|
|
321
|
+
const offered = (gates: NavGates) =>
|
|
322
|
+
resolveTours(TUTORIAL_TOURS, gates)
|
|
323
|
+
.filter(isLaunchOffer)
|
|
324
|
+
.map((t) => t.id)
|
|
201
325
|
/** The catalogue's own view: every tour, with what is holding each one back. */
|
|
202
326
|
const entry = (gates: NavGates, tourId: string) =>
|
|
203
327
|
resolveTourCatalogue(TUTORIAL_TOURS, gates).find((e) => e.tour.id === tourId)
|
|
@@ -207,14 +331,19 @@ describe('tour availability across the catalog', () => {
|
|
|
207
331
|
})
|
|
208
332
|
|
|
209
333
|
it('lists the whole catalog whatever the gates say, holding back rather than hiding', () => {
|
|
210
|
-
// The catalogue surface's contract. A fresh board can run two
|
|
211
|
-
//
|
|
212
|
-
//
|
|
334
|
+
// The catalogue surface's contract. A fresh board can run the two delivery-loop tours that
|
|
335
|
+
// need no board state plus the whole platform half; dropping the rest (all a slot filter
|
|
336
|
+
// could do) would misrepresent the product as shipping fewer walkthroughs than it does, to
|
|
337
|
+
// exactly the user who came looking for them.
|
|
213
338
|
const catalogue = resolveTourCatalogue(TUTORIAL_TOURS, FRESH_BOARD)
|
|
214
339
|
expect(catalogue.map((e) => e.tour.id)).toEqual(TUTORIAL_TOURS.map((t) => t.id))
|
|
215
340
|
expect(catalogue.filter((e) => e.availability === 'ready').map((e) => e.tour.id)).toEqual([
|
|
216
341
|
'board-basics',
|
|
217
342
|
'add-service',
|
|
343
|
+
'wire-models',
|
|
344
|
+
'design-pipeline',
|
|
345
|
+
'agent-standards',
|
|
346
|
+
'connect-systems',
|
|
218
347
|
])
|
|
219
348
|
})
|
|
220
349
|
|
|
@@ -234,18 +363,68 @@ describe('tour availability across the catalog', () => {
|
|
|
234
363
|
})
|
|
235
364
|
|
|
236
365
|
it('offers a brand-new board the orientation tour AND the way out of being empty', () => {
|
|
237
|
-
// The state the launch prompt actually auto-opens in
|
|
238
|
-
// new workspace with a tour of an empty canvas and no route
|
|
239
|
-
// is what `add-service` exists to fix — so it must survive exactly
|
|
240
|
-
|
|
366
|
+
// The state the launch prompt actually auto-opens in, asserted on what it ASKS ABOUT.
|
|
367
|
+
// Orientation alone would leave a new workspace with a tour of an empty canvas and no route
|
|
368
|
+
// to a first service, which is what `add-service` exists to fix — so it must survive exactly
|
|
369
|
+
// this gate set. And nothing else may join it here: this is a modal with one question, and
|
|
370
|
+
// the platform tours are all startable on a fresh board (they need only a permission), so
|
|
371
|
+
// without the offer/library split they would bury both of these four-to-two.
|
|
372
|
+
expect(offered(FRESH_BOARD)).toEqual(['board-basics', 'add-service'])
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('keeps the platform tours in the catalogue rather than in the launch offer', () => {
|
|
376
|
+
// The split, stated as a table so promoting a tour into the first-launch question has to be
|
|
377
|
+
// written down here. The delivery loop is the arc a first-time user is answering about; the
|
|
378
|
+
// platform half is reference material they go and get from the catalogue, where it is listed,
|
|
379
|
+
// counted and startable exactly like the rest.
|
|
380
|
+
const LAUNCH_ARC = [
|
|
381
|
+
'board-basics',
|
|
382
|
+
'add-service',
|
|
383
|
+
'first-task',
|
|
384
|
+
'run-task',
|
|
385
|
+
'answer-park',
|
|
386
|
+
'review-merge',
|
|
387
|
+
]
|
|
388
|
+
const CATALOGUE_ONLY = ['wire-models', 'design-pipeline', 'agent-standards', 'connect-systems']
|
|
389
|
+
expect(TUTORIAL_TOURS.filter(isLaunchOffer).map((t) => t.id)).toEqual(LAUNCH_ARC)
|
|
390
|
+
expect(TUTORIAL_TOURS.filter((t) => !isLaunchOffer(t)).map((t) => t.id)).toEqual(CATALOGUE_ONLY)
|
|
391
|
+
// Un-offered is not un-runnable: it thins the offer, never the library.
|
|
392
|
+
expect(ready(ALL_GATES)).toEqual(expect.arrayContaining(CATALOGUE_ONLY))
|
|
241
393
|
})
|
|
242
394
|
|
|
243
395
|
it('names the missing connection when no source control can list repositories', () => {
|
|
244
396
|
const noSource: NavGates = { ...FRESH_BOARD, githubAvailable: false }
|
|
245
|
-
expect(
|
|
397
|
+
expect(offered(noSource)).toEqual(['board-basics'])
|
|
246
398
|
expect(entry(noSource, 'add-service')?.unmet.map((r) => r.id)).toEqual(['source-control'])
|
|
247
399
|
})
|
|
248
400
|
|
|
401
|
+
it('holds each platform tour back on the permission its own sidebar entry needs', () => {
|
|
402
|
+
// Every one of these tours clicks a sidebar entry as its second step, so its requirement has
|
|
403
|
+
// to be the SAME fact that renders the entry. A weaker one offers the tour to a user with no
|
|
404
|
+
// such control: it would hunt for the anchor, skip the rest and report itself abridged.
|
|
405
|
+
const member: NavGates = {
|
|
406
|
+
...ALL_GATES,
|
|
407
|
+
canManageIntegrations: false,
|
|
408
|
+
canManageSettings: false,
|
|
409
|
+
}
|
|
410
|
+
expect(ready(member)).not.toContain('wire-models')
|
|
411
|
+
expect(ready(member)).not.toContain('connect-systems')
|
|
412
|
+
expect(ready(member)).not.toContain('agent-standards')
|
|
413
|
+
expect(entry(member, 'wire-models')?.unmet.map((r) => r.id)).toEqual(['integrations-manage'])
|
|
414
|
+
expect(entry(member, 'connect-systems')?.unmet.map((r) => r.id)).toEqual([
|
|
415
|
+
'integrations-manage',
|
|
416
|
+
])
|
|
417
|
+
expect(entry(member, 'agent-standards')?.unmet.map((r) => r.id)).toEqual(['settings-manage'])
|
|
418
|
+
// A viewer keeps the builder tour out too: it opens a board-write surface.
|
|
419
|
+
expect(ready({ ...ALL_GATES, canWriteBoard: false })).not.toContain('design-pipeline')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('names the disabled library when the deployment ships no fragment surface', () => {
|
|
423
|
+
const noLibrary: NavGates = { ...ALL_GATES, libraryAvailable: false }
|
|
424
|
+
expect(ready(noLibrary)).not.toContain('agent-standards')
|
|
425
|
+
expect(entry(noLibrary, 'agent-standards')?.unmet.map((r) => r.id)).toEqual(['library'])
|
|
426
|
+
})
|
|
427
|
+
|
|
249
428
|
it('offers the run tour once a task exists, and the review tour once a run finished', () => {
|
|
250
429
|
const withTask: NavGates = { ...FRESH_BOARD, boardHasService: true, boardHasTask: true }
|
|
251
430
|
expect(ready(withTask)).toContain('run-task')
|