@cat-factory/app 0.147.4 → 0.147.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,17 @@ over the WebSocket. How that sync works is written up in
52
52
  | `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
53
53
  | `utils/` | Small pure helpers. |
54
54
 
55
+ ## Extending the layer (consumer modules)
56
+
57
+ A deployment can contribute its own components — result windows, nav entries, inspector
58
+ panels, agent-kind palette data — **without forking**, through the auto-imported
59
+ `registerAppModule` seam (the frontend analogue of the backend's `registerAgentKind` /
60
+ `registerGate` registries). The authoring walkthrough, the reusable shared building blocks
61
+ (`ResultWindowShell`, the `StepRunMeta` run-metadata block, `useResultView`, …), and the
62
+ namespacing / degradation rules are in
63
+ [`app/docs/consumer-extensions.md`](./app/docs/consumer-extensions.md); a full worked
64
+ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security` module).
65
+
55
66
  ## Key UI surfaces
56
67
 
57
68
  - **Board canvas** (`components/board`) — `BoardCanvas` + `nodes/` (`BlockNode`,
@@ -27,7 +27,7 @@ const props = defineProps<{
27
27
  const models = useModelsStore()
28
28
  const { t, d } = useI18n()
29
29
 
30
- const { isRunning, durationLabel } = useStepTimer({
30
+ const { isRunning, durationLabel, activityAgoLabel } = useStepTimer({
31
31
  step: () => props.step,
32
32
  runFailed: () => props.runFailed ?? false,
33
33
  failureAt: () => props.failureAt,
@@ -74,6 +74,19 @@ async function copyRunId() {
74
74
  </p>
75
75
  </div>
76
76
 
77
+ <!-- Liveness: time since the agent's last sign of life (the harness heartbeat), distinct from
78
+ the elapsed clock above — a long, quiet phase keeps this small while elapsed climbs, so a
79
+ genuinely-active-but-quiet run reads apart from a wedged one. Only while actively running. -->
80
+ <div v-if="isRunning && activityAgoLabel" data-testid="step-activity">
81
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
82
+ {{ t('panels.stepMeta.activity') }}
83
+ </h4>
84
+ <p class="flex items-center gap-1.5 text-[12px] tabular-nums text-slate-300">
85
+ <span class="h-1.5 w-1.5 rounded-full bg-emerald-400" />
86
+ {{ t('panels.stepMeta.activityAgo', { duration: activityAgoLabel }) }}
87
+ </p>
88
+ </div>
89
+
77
90
  <div v-if="formatClock(step.startedAt)">
78
91
  <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
79
92
  {{ t('panels.stepMeta.started') }}
@@ -844,6 +844,7 @@ function setSize(value: InstanceSize) {
844
844
  :model-value="customManifestId"
845
845
  :items="customTypeItems"
846
846
  size="xs"
847
+ class="w-full"
847
848
  :placeholder="t('inspector.testConfig.customManifestIdPlaceholder')"
848
849
  @update:model-value="(v: string) => setCustomManifestId(v)"
849
850
  />
@@ -861,7 +862,7 @@ function setSize(value: InstanceSize) {
861
862
  <UInput
862
863
  :model-value="customManifestPath"
863
864
  size="xs"
864
- class="font-mono"
865
+ class="w-full font-mono"
865
866
  @blur="(e: FocusEvent) => setCustomManifestPath((e.target as HTMLInputElement).value)"
866
867
  @keydown.enter="
867
868
  (e: KeyboardEvent) => setCustomManifestPath((e.target as HTMLInputElement).value)
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { PipelineStep } from '~/types/execution'
3
- import { stepDurationLabel, stepDurationMs, stepIsRunning } from '~/composables/useStepTimer'
3
+ import {
4
+ stepActivityAgoMs,
5
+ stepDurationLabel,
6
+ stepDurationMs,
7
+ stepIsRunning,
8
+ } from '~/composables/useStepTimer'
4
9
 
5
10
  // The pure helpers encode one freeze rule shared by the list surfaces (pipeline timeline,
6
11
  // inspector run list) and the single-step overlay, so pin the precedence here:
@@ -66,6 +71,22 @@ describe('stepDurationMs', () => {
66
71
  })
67
72
  })
68
73
 
74
+ describe('stepActivityAgoMs (liveness clock, distinct from elapsed)', () => {
75
+ it('is null when the step has no heartbeat (non-container / not yet polled / old image)', () => {
76
+ expect(stepActivityAgoMs(step({ startedAt: 4000 }), NOW)).toBeNull()
77
+ expect(stepActivityAgoMs(null, NOW)).toBeNull()
78
+ })
79
+
80
+ it('measures time since the last heartbeat, not since the step started', () => {
81
+ // Started long ago (elapsed 6s) but active 1s ago — the whole point: a quiet run reads active.
82
+ expect(stepActivityAgoMs(step({ startedAt: 4000, lastActivityAt: 9000 }), NOW)).toBe(1000)
83
+ })
84
+
85
+ it('clamps to 0 so container/server clock skew never renders a negative value', () => {
86
+ expect(stepActivityAgoMs(step({ startedAt: 4000, lastActivityAt: NOW + 5000 }), NOW)).toBe(0)
87
+ })
88
+ })
89
+
69
90
  describe('stepDurationLabel', () => {
70
91
  it('is null until the step has started', () => {
71
92
  expect(stepDurationLabel(step({ startedAt: undefined }), NOW, false, null)).toBeNull()
@@ -39,6 +39,21 @@ export function stepDurationLabel(
39
39
  return ms == null ? null : formatDuration(ms)
40
40
  }
41
41
 
42
+ /**
43
+ * Milliseconds since the container agent's last observed sign of life (`lastActivityAt`, the
44
+ * harness liveness heartbeat), at wall-clock `nowMs` — or null when the step has none (a
45
+ * non-container step, one not yet polled, or an older harness image). Clamped at 0 so minor
46
+ * container/server clock skew never renders a negative "active in the future" value. This is the
47
+ * LIVENESS clock (time since the agent last did anything), distinct from {@link stepDurationMs}'s
48
+ * ELAPSED clock (total time the step has been running) — a long, quiet phase keeps the elapsed
49
+ * clock climbing while this stays small, which is exactly how a genuinely-active-but-quiet run is
50
+ * told apart from a wedged one.
51
+ */
52
+ export function stepActivityAgoMs(step: PipelineStep | null, nowMs: number): number | null {
53
+ if (step?.lastActivityAt == null) return null
54
+ return Math.max(0, nowMs - step.lastActivityAt)
55
+ }
56
+
42
57
  /**
43
58
  * A shared 1s wall-clock tick for surfaces that render many steps' live durations
44
59
  * at once (the pipeline timeline, the inspector run list). One interval drives every
@@ -84,7 +99,16 @@ export function useStepTimer(opts: {
84
99
  durationMs.value == null ? null : formatDuration(durationMs.value),
85
100
  )
86
101
 
87
- return { isRunning, durationMs, durationLabel }
102
+ // Time since the agent's last sign of life (the liveness heartbeat), ticking only while the
103
+ // step is actively running — a finished/parked/failed step isn't "active", so it reads null.
104
+ const activityAgoMs = computed(() =>
105
+ isRunning.value ? stepActivityAgoMs(opts.step(), nowTick.value) : null,
106
+ )
107
+ const activityAgoLabel = computed(() =>
108
+ activityAgoMs.value == null ? null : formatDuration(activityAgoMs.value),
109
+ )
110
+
111
+ return { isRunning, durationMs, durationLabel, activityAgoMs, activityAgoLabel }
88
112
  }
89
113
 
90
114
  export function formatDuration(ms: number): string {
@@ -0,0 +1,171 @@
1
+ # Extending the SPA from a consumer deployment
2
+
3
+ A deployment that consumes this layer (`extends: ['@cat-factory/app']`) can contribute
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
+ backend's public registries (`registerAgentKind`, `registerGate`; see
7
+ [`backend/docs/custom-agents.md`](../../../backend/docs/custom-agents.md)). The governing
8
+ principle is the same: **zero host edits for a consumer extension**.
9
+
10
+ A worked, end-to-end example ships in the template deployment —
11
+ [`deploy/frontend/app/`](../../../deploy/frontend) (the `acme:security` module) — the
12
+ frontend analogue of the backend
13
+ [`@cat-factory/example-custom-agent`](../../../backend/internal/example-custom-agent)
14
+ package. Read this guide alongside it.
15
+
16
+ > This is the **landed** surface (modular-vue adoption slices 1–5). The larger consumer
17
+ > extension programme (custom task types, generic interactive phases, overlays, consumer
18
+ > notification kinds, stream events, and the hardened public export surface) is tracked in
19
+ > [`docs/initiatives/frontend-extension-mechanism.md`](../../../docs/initiatives/frontend-extension-mechanism.md).
20
+
21
+ ## The one seam: `registerAppModule`
22
+
23
+ Everything is one call from your own Nuxt plugin. A module is a plain descriptor; each
24
+ capability is a slot contribution.
25
+
26
+ ```ts
27
+ // deploy/frontend/app/plugins/acme.client.ts
28
+ import { defineModule } from '@modular-vue/core'
29
+ import AcmeSecurityReport from '../components/acme/AcmeSecurityReport.vue'
30
+
31
+ export default defineNuxtPlugin(() => {
32
+ registerAppModule(
33
+ defineModule({
34
+ id: 'acme:security', // namespaced — see "Rules" below
35
+ version: '1.0.0',
36
+ slots: {
37
+ resultViews: [{ id: 'acme:security-report', component: AcmeSecurityReport }],
38
+ agentKinds: [
39
+ /* palette entries — see "Agent kinds" */
40
+ ],
41
+ nav: [
42
+ /* sidebar / command-palette destinations — see "Navigation" */
43
+ ],
44
+ inspectorPanels: [
45
+ /* per-block detail panels — see "Inspector panels" */
46
+ ],
47
+ },
48
+ }),
49
+ )
50
+ })
51
+ ```
52
+
53
+ - **`registerAppModule` is auto-imported** from the layer (`app/utils/modular.ts`), so you
54
+ need no deep import into the layer's internals.
55
+ - **`enforce: 'post'` is load-bearing.** The layer's own install plugin is `enforce:
56
+ 'post'`, and Nuxt runs layer plugins before the consuming app's plugins within one
57
+ enforce bucket. So your registration plugin must run in the **default** (or `pre`) bucket
58
+ — i.e. **do not** put `enforce: 'post'` on it, or it registers too late and is silently
59
+ missed.
60
+ - **`defineModule` / the slot-entry types come from `@modular-vue/core`** — add it to your
61
+ deployment's `dependencies`.
62
+
63
+ ## The landed seams
64
+
65
+ | Seam | Slot key | Entry shape | Host |
66
+ | ----------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------- |
67
+ | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
68
+ | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
69
+ | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
70
+ | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
71
+ | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
72
+ | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
73
+
74
+ ### Run-detail windows (`resultViews` + `agentKinds`)
75
+
76
+ Backend data selects a frontend component, joined by a namespaced id:
77
+
78
+ 1. A backend agent kind (registered on `AgentKindRegistry`, e.g.
79
+ `@cat-factory/example-custom-agent`'s `security-auditor`) arrives in the workspace
80
+ snapshot with `presentation.resultView: '<ns>:<name>'` — **or** you code-ship the kind's
81
+ palette entry via the `agentKinds` slot (as the example does, to give an existing kind a
82
+ bespoke window).
83
+ 2. You contribute the component to `resultViews` under the SAME id.
84
+ 3. When a step of that kind is opened, `dispatchStepView` resolves the kind's `resultView`
85
+ id, and `StepResultViewHost` mounts your paired component.
86
+
87
+ An unpaired id degrades to the generic prose panel (a dev-console warning names the dangling
88
+ id); a structured kind with no bespoke window gets the built-in `generic-structured` viewer
89
+ for free.
90
+
91
+ ### Navigation
92
+
93
+ A consumer nav item carries its own `run` closure (first-party items use a typed `action`
94
+ id instead). Optional `gate: (g) => g.canManageIntegrations` hides it reactively without the
95
+ permission. `surfaces` picks which shells render it (`'sidebar' | 'command' | 'toolbar'`),
96
+ and `sidebar` / `command` / `toolbar` place it within each.
97
+
98
+ ### Inspector panels
99
+
100
+ Contribute `PanelEntry<Block>` entries; each `when(block)` predicate decides which blocks
101
+ show the panel, and `order` places it among the built-ins. Your panel component reads the
102
+ selected block via `usePanelSubject<Block>()` (`@modular-vue/core`). `when` must tolerate a
103
+ nullish subject (the boot-time validation resolve passes `null`).
104
+
105
+ ## Reuse the shared building blocks — don't reinvent them
106
+
107
+ The layer ships window/inspector primitives you compose instead of hand-rolling chrome or
108
+ re-deriving the "which run is this / how did the model do" facts. **Composables** (and the
109
+ `registerAppModule` helper) are auto-imported into your consumer code with zero imports;
110
+ **components** must be named through the `#components` virtual module (see the boxed note
111
+ below). Compose these:
112
+
113
+ | Building block | Reference it as | What it gives you |
114
+ | ---------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
115
+ | `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. |
116
+ | `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. |
117
+ | `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
118
+ | `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
119
+ | `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
120
+ | `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. |
121
+ | `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
122
+
123
+ > **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
124
+ > layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
125
+ > → `PanelsResultWindowShell`), and only rewrites bare `<ResultWindowShell>` tags inside the
126
+ > layer's own SFCs. A bare tag in a **consumer** SFC resolves to nothing and silently renders
127
+ > as an unknown element — its `<slot>` children still appear, so a shallow test can pass while
128
+ > the shared chrome (and its `data-testid`) never mounts. Import the ones you use from
129
+ > `#components` (Nuxt's stable virtual registry — **not** a deep path into the layer's
130
+ > `app/components/*`), aliasing them back to the short names for readable templates:
131
+ >
132
+ > ```ts
133
+ > import {
134
+ > PanelsResultWindowShell as ResultWindowShell,
135
+ > PanelsStepRunMeta as StepRunMeta,
136
+ > CommonMarkdownProse as MarkdownProse,
137
+ > } from '#components'
138
+ > ```
139
+ >
140
+ > Composables (`useResultView`, `useI18n`, the Pinia stores) and `registerAppModule` are
141
+ > auto-imported across layers by Nuxt's separate imports mechanism, so those need no import.
142
+ > Hardening these building blocks into an explicitly exported, location-independent public
143
+ > surface is slice G of the initiative.
144
+
145
+ The example `AcmeSecurityReport.vue` window is a full demonstration: it imports
146
+ `ResultWindowShell` + `StepRunMeta` + `MarkdownProse` from `#components`, composes them with
147
+ the auto-imported `useResultView`, adds only its own bespoke body (the security findings), and
148
+ reads the auditor's structured assessment straight off `step.custom`.
149
+
150
+ ## i18n
151
+
152
+ Ship your strings under your own namespace in the deployment's `i18n/locales/*.json` (e.g.
153
+ `acme.*`). `@nuxtjs/i18n` is layer-aware and **deep-merges** them into the layer catalog, so
154
+ `t('acme.securityReport.title')` resolves in your components with no config change. The
155
+ layer's typed-key and locale-parity guards govern only the layer's own keys — your namespace
156
+ is yours.
157
+
158
+ ## Rules that hold across every seam
159
+
160
+ - **Namespacing.** Every consumer-authored id is `<ns>:<name>`. Built-ins are never
161
+ shadowable — the merge logic drops a consumer entry whose id collides with a built-in
162
+ (see the agents store).
163
+ - **Fail fast at boot, degrade at runtime.** Duplicate ids across first-party + consumer
164
+ modules throw when the layer resolves the merged slots at startup; missing pairings and
165
+ unknown wire ids degrade with a dev-console warning, never a crash.
166
+ - **Never crash on stale data.** An id that arrives on the wire (a `resultView`, an agent
167
+ kind) after its extension was removed must degrade to a defined rendering — extensions get
168
+ uninstalled while persisted rows outlive them.
169
+ - **The remote manifest is DATA only.** Components never travel the wire; per-workspace
170
+ variability comes from which capabilities the snapshot lists, not from which modules are
171
+ registered (registration is boot-static).
@@ -1386,6 +1386,8 @@
1386
1386
  "stateLabel": "Status",
1387
1387
  "duration": "Dauer",
1388
1388
  "elapsed": "vergangen",
1389
+ "activity": "Letzte Aktivität",
1390
+ "activityAgo": "vor {duration} aktiv",
1389
1391
  "step": "Schritt",
1390
1392
  "stepOf": "{number} von {total}",
1391
1393
  "started": "Gestartet",
@@ -1161,6 +1161,8 @@
1161
1161
  "stateLabel": "State",
1162
1162
  "duration": "Duration",
1163
1163
  "elapsed": "elapsed",
1164
+ "activity": "Last activity",
1165
+ "activityAgo": "active {duration} ago",
1164
1166
  "step": "Step",
1165
1167
  "stepOf": "{number} of {total}",
1166
1168
  "started": "Started",
@@ -1104,6 +1104,8 @@
1104
1104
  "stateLabel": "Estado",
1105
1105
  "duration": "Duración",
1106
1106
  "elapsed": "transcurrido",
1107
+ "activity": "Última actividad",
1108
+ "activityAgo": "activo hace {duration}",
1107
1109
  "step": "Paso",
1108
1110
  "stepOf": "{number} de {total}",
1109
1111
  "started": "Iniciado",
@@ -1104,6 +1104,8 @@
1104
1104
  "stateLabel": "État",
1105
1105
  "duration": "Durée",
1106
1106
  "elapsed": "écoulé",
1107
+ "activity": "Dernière activité",
1108
+ "activityAgo": "actif il y a {duration}",
1107
1109
  "step": "Étape",
1108
1110
  "stepOf": "{number} sur {total}",
1109
1111
  "started": "Démarré",
@@ -1104,6 +1104,8 @@
1104
1104
  "stateLabel": "מצב",
1105
1105
  "duration": "משך",
1106
1106
  "elapsed": "חלף",
1107
+ "activity": "פעילות אחרונה",
1108
+ "activityAgo": "פעיל לפני {duration}",
1107
1109
  "step": "שלב",
1108
1110
  "stepOf": "{number} מתוך {total}",
1109
1111
  "started": "התחיל",
@@ -1386,6 +1386,8 @@
1386
1386
  "stateLabel": "Stato",
1387
1387
  "duration": "Durata",
1388
1388
  "elapsed": "trascorso",
1389
+ "activity": "Ultima attività",
1390
+ "activityAgo": "attivo {duration} fa",
1389
1391
  "step": "Passaggio",
1390
1392
  "stepOf": "{number} di {total}",
1391
1393
  "started": "Iniziato",
@@ -1104,6 +1104,8 @@
1104
1104
  "stateLabel": "状態",
1105
1105
  "duration": "所要時間",
1106
1106
  "elapsed": "経過",
1107
+ "activity": "最終アクティビティ",
1108
+ "activityAgo": "{duration}前にアクティブ",
1107
1109
  "step": "ステップ",
1108
1110
  "stepOf": "{total} 中 {number}",
1109
1111
  "started": "開始",
@@ -1104,6 +1104,8 @@
1104
1104
  "stateLabel": "Stan",
1105
1105
  "duration": "Czas trwania",
1106
1106
  "elapsed": "upłynęło",
1107
+ "activity": "Ostatnia aktywność",
1108
+ "activityAgo": "aktywny {duration} temu",
1107
1109
  "step": "Krok",
1108
1110
  "stepOf": "{number} z {total}",
1109
1111
  "started": "Rozpoczęto",
@@ -1104,6 +1104,8 @@
1104
1104
  "stateLabel": "Durum",
1105
1105
  "duration": "Süre",
1106
1106
  "elapsed": "geçen süre",
1107
+ "activity": "Son etkinlik",
1108
+ "activityAgo": "{duration} önce etkindi",
1107
1109
  "step": "Adım",
1108
1110
  "stepOf": "{total} adımdan {number}.",
1109
1111
  "started": "Başladı",
@@ -1104,6 +1104,8 @@
1104
1104
  "stateLabel": "Стан",
1105
1105
  "duration": "Тривалість",
1106
1106
  "elapsed": "минуло",
1107
+ "activity": "Остання активність",
1108
+ "activityAgo": "активний {duration} тому",
1107
1109
  "step": "Крок",
1108
1110
  "stepOf": "{number} з {total}",
1109
1111
  "started": "Розпочато",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.147.4",
3
+ "version": "0.147.6",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.154.1"
43
+ "@cat-factory/contracts": "0.154.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",