@cat-factory/app 0.96.0 → 0.96.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.
- package/app/components/layout/ConnectionStatusBanner.vue +42 -24
- package/app/components/panels/ObservabilityPanel.vue +39 -3
- package/app/components/spec/ServiceSpecWindow.vue +16 -1
- package/app/composables/usePipelineErrorToast.ts +6 -0
- package/app/composables/useWorkspaceStream.ts +57 -14
- package/app/pages/index.vue +7 -1
- package/app/stores/board.ts +12 -1
- package/app/stores/observability.ts +15 -2
- package/app/stores/preview.ts +35 -3
- package/i18n/locales/en.json +4 -1
- package/i18n/locales/es.json +4 -1
- package/i18n/locales/fr.json +4 -1
- package/i18n/locales/he.json +4 -1
- package/i18n/locales/ja.json +4 -1
- package/i18n/locales/pl.json +4 -1
- package/i18n/locales/tr.json +4 -1
- package/i18n/locales/uk.json +4 -1
- package/package.json +1 -1
|
@@ -1,36 +1,44 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
3
3
|
|
|
4
|
-
// A slim top strip shown when the real-time WebSocket
|
|
4
|
+
// A slim top strip shown when the real-time WebSocket isn't delivering events, so a
|
|
5
5
|
// silently-frozen board (events stop arriving, nothing updates) is no longer indistinguishable
|
|
6
|
-
// from an idle one.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
6
|
+
// from an idle one. Two states:
|
|
7
|
+
// - RE-connecting: we were live and the socket dropped (`useWorkspaceStream` reconnects with
|
|
8
|
+
// exponential backoff and resyncs on reconnect — this just makes that state visible).
|
|
9
|
+
// - Offline: the very first handshake keeps failing (`connectionFailed`), so the board loaded
|
|
10
|
+
// over REST but will never go live — a user watching a run would otherwise see a frozen
|
|
11
|
+
// board with no hint why.
|
|
12
|
+
// The `connected` / `everConnected` / `connectionFailed` refs are passed in as props (the page
|
|
13
|
+
// owns the single stream instance; creating another here would open a second socket).
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
connected: boolean
|
|
16
|
+
everConnected: boolean
|
|
17
|
+
connectionFailed: boolean
|
|
18
|
+
}>()
|
|
11
19
|
|
|
12
20
|
const { t } = useI18n()
|
|
13
21
|
|
|
14
|
-
//
|
|
15
|
-
// later drop is a real interruption worth flagging. A short debounce rides out a quick socket
|
|
16
|
-
// flap so a momentary blip doesn't flash the strip.
|
|
17
|
-
const everConnected = ref(false)
|
|
22
|
+
// A short debounce rides out a quick socket flap so a momentary blip doesn't flash the strip.
|
|
18
23
|
const showAfterDelay = ref(false)
|
|
19
24
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
20
25
|
|
|
26
|
+
function clearTimer() {
|
|
27
|
+
if (timer) {
|
|
28
|
+
clearTimeout(timer)
|
|
29
|
+
timer = null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
watch(
|
|
22
34
|
() => props.connected,
|
|
23
35
|
(connected) => {
|
|
24
36
|
if (connected) {
|
|
25
|
-
everConnected.value = true
|
|
26
37
|
showAfterDelay.value = false
|
|
27
|
-
|
|
28
|
-
clearTimeout(timer)
|
|
29
|
-
timer = null
|
|
30
|
-
}
|
|
38
|
+
clearTimer()
|
|
31
39
|
return
|
|
32
40
|
}
|
|
33
|
-
if (
|
|
41
|
+
if (timer) return
|
|
34
42
|
timer = setTimeout(() => {
|
|
35
43
|
showAfterDelay.value = true
|
|
36
44
|
timer = null
|
|
@@ -39,24 +47,24 @@ watch(
|
|
|
39
47
|
{ immediate: true },
|
|
40
48
|
)
|
|
41
49
|
|
|
42
|
-
|
|
50
|
+
// Reconnection: only surface once we've been connected — a later drop is a real interruption.
|
|
51
|
+
const reconnecting = computed(() => props.everConnected && !props.connected && showAfterDelay.value)
|
|
52
|
+
// Offline: never connected and repeated attempts failed. No debounce — it already took several
|
|
53
|
+
// backoff cycles to flag, so it's not a flap.
|
|
54
|
+
const offline = computed(() => props.connectionFailed && !props.connected && !props.everConnected)
|
|
43
55
|
|
|
44
56
|
// Don't leave a pending debounce timer firing into a torn-down component.
|
|
45
|
-
onBeforeUnmount(
|
|
46
|
-
if (timer) {
|
|
47
|
-
clearTimeout(timer)
|
|
48
|
-
timer = null
|
|
49
|
-
}
|
|
50
|
-
})
|
|
57
|
+
onBeforeUnmount(clearTimer)
|
|
51
58
|
</script>
|
|
52
59
|
|
|
53
60
|
<template>
|
|
54
61
|
<Transition name="fade">
|
|
55
62
|
<div
|
|
56
|
-
v-if="
|
|
63
|
+
v-if="reconnecting || offline"
|
|
57
64
|
class="pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-2"
|
|
58
65
|
>
|
|
59
66
|
<div
|
|
67
|
+
v-if="reconnecting"
|
|
60
68
|
class="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-500/60 bg-amber-950/90 px-3 py-1.5 text-xs text-amber-100 shadow-lg backdrop-blur"
|
|
61
69
|
role="status"
|
|
62
70
|
aria-live="polite"
|
|
@@ -65,6 +73,16 @@ onBeforeUnmount(() => {
|
|
|
65
73
|
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
66
74
|
<span>{{ t('app.reconnecting') }}</span>
|
|
67
75
|
</div>
|
|
76
|
+
<div
|
|
77
|
+
v-else
|
|
78
|
+
class="pointer-events-auto flex items-center gap-2 rounded-full border border-rose-500/60 bg-rose-950/90 px-3 py-1.5 text-xs text-rose-100 shadow-lg backdrop-blur"
|
|
79
|
+
role="status"
|
|
80
|
+
aria-live="polite"
|
|
81
|
+
data-testid="stream-offline"
|
|
82
|
+
>
|
|
83
|
+
<UIcon name="i-lucide-wifi-off" class="h-3.5 w-3.5" />
|
|
84
|
+
<span>{{ t('app.offline') }}</span>
|
|
85
|
+
</div>
|
|
68
86
|
</div>
|
|
69
87
|
</Transition>
|
|
70
88
|
</template>
|
|
@@ -37,6 +37,16 @@ const exporting = computed(
|
|
|
37
37
|
const error = computed(() =>
|
|
38
38
|
executionId.value ? (observability.errors[executionId.value] ?? null) : null,
|
|
39
39
|
)
|
|
40
|
+
const contextError = computed(() =>
|
|
41
|
+
executionId.value ? (observability.contextErrors[executionId.value] ?? null) : null,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
function retryCalls() {
|
|
45
|
+
if (executionId.value) void observability.load(executionId.value)
|
|
46
|
+
}
|
|
47
|
+
function retryContext() {
|
|
48
|
+
if (executionId.value) void observability.loadContext(executionId.value)
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
// Which view is shown: per-call model activity, the complete provided context, or the
|
|
42
52
|
// performed web searches.
|
|
@@ -333,12 +343,22 @@ function exportJson() {
|
|
|
333
343
|
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
334
344
|
{{ t('observability.loadingActivity') }}
|
|
335
345
|
</p>
|
|
336
|
-
<
|
|
346
|
+
<div
|
|
337
347
|
v-else-if="error"
|
|
338
|
-
class="rounded-lg border border-dashed border-rose-900/60 py-6 text-center text-sm text-rose-400"
|
|
348
|
+
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-rose-900/60 py-6 text-center text-sm text-rose-400"
|
|
339
349
|
>
|
|
340
350
|
{{ error }}
|
|
341
|
-
|
|
351
|
+
<UButton
|
|
352
|
+
icon="i-lucide-rotate-cw"
|
|
353
|
+
color="neutral"
|
|
354
|
+
variant="soft"
|
|
355
|
+
size="xs"
|
|
356
|
+
:loading="loading"
|
|
357
|
+
@click="retryCalls"
|
|
358
|
+
>
|
|
359
|
+
{{ t('common.retry') }}
|
|
360
|
+
</UButton>
|
|
361
|
+
</div>
|
|
342
362
|
<p
|
|
343
363
|
v-else-if="!calls.length"
|
|
344
364
|
class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
|
|
@@ -493,6 +513,22 @@ function exportJson() {
|
|
|
493
513
|
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
494
514
|
{{ t('observability.loadingContext') }}
|
|
495
515
|
</p>
|
|
516
|
+
<div
|
|
517
|
+
v-else-if="contextError && !contextSnapshots.length"
|
|
518
|
+
class="flex flex-col items-center gap-3 rounded-lg border border-dashed border-rose-900/60 py-8 text-center text-sm text-rose-400"
|
|
519
|
+
>
|
|
520
|
+
{{ t('observability.contextError') }}
|
|
521
|
+
<UButton
|
|
522
|
+
icon="i-lucide-rotate-cw"
|
|
523
|
+
color="neutral"
|
|
524
|
+
variant="soft"
|
|
525
|
+
size="xs"
|
|
526
|
+
:loading="contextLoading"
|
|
527
|
+
@click="retryContext"
|
|
528
|
+
>
|
|
529
|
+
{{ t('common.retry') }}
|
|
530
|
+
</UButton>
|
|
531
|
+
</div>
|
|
496
532
|
<p
|
|
497
533
|
v-else-if="!contextSnapshots.length"
|
|
498
534
|
class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
|
|
@@ -88,6 +88,11 @@ function selectGroup(m: number, g: number) {
|
|
|
88
88
|
selected.value = { m, g }
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// Re-fetch after a load failure — the only escape used to be close-and-reopen.
|
|
92
|
+
function retry() {
|
|
93
|
+
if (blockId.value) void serviceSpec.load(blockId.value)
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
// Exhaustive priority → label/chip map. Literal `t()` keys keep the typed-key drift
|
|
92
97
|
// guard live, vs a runtime-built `spec.priority.${value}`.
|
|
93
98
|
const PRIORITY_META: Record<RequirementPriority, { label: string; chip: string }> = {
|
|
@@ -187,10 +192,20 @@ function kindLabel(item: RequirementItem): string {
|
|
|
187
192
|
<!-- error -->
|
|
188
193
|
<div
|
|
189
194
|
v-else-if="errored"
|
|
190
|
-
class="flex flex-1 flex-col items-center justify-center gap-
|
|
195
|
+
class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center text-sm text-slate-400"
|
|
191
196
|
>
|
|
192
197
|
<UIcon name="i-lucide-triangle-alert" class="h-6 w-6 text-amber-400" />
|
|
193
198
|
{{ t('spec.error') }}
|
|
199
|
+
<UButton
|
|
200
|
+
icon="i-lucide-rotate-cw"
|
|
201
|
+
color="neutral"
|
|
202
|
+
variant="soft"
|
|
203
|
+
size="xs"
|
|
204
|
+
:loading="loading"
|
|
205
|
+
@click="retry"
|
|
206
|
+
>
|
|
207
|
+
{{ t('common.retry') }}
|
|
208
|
+
</UButton>
|
|
194
209
|
</div>
|
|
195
210
|
|
|
196
211
|
<!-- empty: no spec on the repo's default branch yet -->
|
|
@@ -93,6 +93,9 @@ export function usePipelineErrorToast() {
|
|
|
93
93
|
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
94
94
|
color: 'error',
|
|
95
95
|
icon: 'i-lucide-cpu',
|
|
96
|
+
// Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
|
|
97
|
+
// auto-dismiss takes the one-click fix with it before the user can reach it.
|
|
98
|
+
duration: 0,
|
|
96
99
|
actions: [
|
|
97
100
|
{
|
|
98
101
|
label: t('errors.conflict.providersUnconfigured.action'),
|
|
@@ -117,6 +120,9 @@ export function usePipelineErrorToast() {
|
|
|
117
120
|
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
118
121
|
color: 'error',
|
|
119
122
|
icon: 'i-lucide-image',
|
|
123
|
+
// Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
|
|
124
|
+
// remedy reachable instead of letting it auto-dismiss.
|
|
125
|
+
duration: 0,
|
|
120
126
|
actions: [
|
|
121
127
|
{
|
|
122
128
|
label: t('errors.conflict.binaryStorageUnconfigured.action'),
|
|
@@ -31,6 +31,17 @@ export function useWorkspaceStream() {
|
|
|
31
31
|
const apiBase = useRuntimeConfig().public.apiBase
|
|
32
32
|
|
|
33
33
|
const connected = ref(false)
|
|
34
|
+
// Have we EVER been fully live (connected AND reconciled) for the current workspace? Drives the
|
|
35
|
+
// "reconnecting" vs "never connected" distinction in the banner. Set together with `connected`
|
|
36
|
+
// AFTER the on-open resync settles — NOT at `onopen` — so the initial resync window (socket open
|
|
37
|
+
// but not yet announced) can't be mistaken for a re-connection and flash the amber banner.
|
|
38
|
+
const everConnected = ref(false)
|
|
39
|
+
// The very first handshake keeps failing (proxy/firewall blocks WS while REST works, or the
|
|
40
|
+
// ticket mint throws) — the board loaded over REST but will never go live. Flagged after a
|
|
41
|
+
// few failed attempts so the banner can say "not receiving live updates" instead of nothing.
|
|
42
|
+
const connectionFailed = ref(false)
|
|
43
|
+
// Failed connect attempts before we ever go live gates the offline flag above.
|
|
44
|
+
const INITIAL_FAIL_ATTEMPTS = 3
|
|
34
45
|
|
|
35
46
|
let socket: WebSocket | null = null
|
|
36
47
|
let stopped = false
|
|
@@ -41,9 +52,29 @@ export function useWorkspaceStream() {
|
|
|
41
52
|
// http→ws, https→wss (apiBase is an absolute origin, see nuxt.config.ts).
|
|
42
53
|
const wsBase = String(apiBase).replace(/^http/, 'ws')
|
|
43
54
|
|
|
55
|
+
// A coarse board refresh (the resync on reconnect, and the `board` event fan-out) must not be
|
|
56
|
+
// left silently stale by ONE transient failure: retry a few times with backoff so a blip
|
|
57
|
+
// self-heals. Bounded (the socket-level reconnect + the offline banner are the backstop for a
|
|
58
|
+
// genuine outage). Aborts between attempts if the stream stopped or the workspace switched.
|
|
59
|
+
const REFRESH_MAX_ATTEMPTS = 4
|
|
60
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
|
61
|
+
async function refreshWithRetry(workspaceId: string): Promise<void> {
|
|
62
|
+
for (let i = 0; i < REFRESH_MAX_ATTEMPTS; i++) {
|
|
63
|
+
if (stopped || workspace.workspaceId !== workspaceId) return
|
|
64
|
+
try {
|
|
65
|
+
await workspace.refresh()
|
|
66
|
+
return
|
|
67
|
+
} catch {
|
|
68
|
+
if (i < REFRESH_MAX_ATTEMPTS - 1) await sleep(Math.min(4_000, 400 * 2 ** i))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
44
73
|
function debouncedBoardRefresh() {
|
|
74
|
+
const workspaceId = workspace.workspaceId
|
|
75
|
+
if (!workspaceId) return
|
|
45
76
|
if (boardDebounce) clearTimeout(boardDebounce)
|
|
46
|
-
boardDebounce = setTimeout(() => void
|
|
77
|
+
boardDebounce = setTimeout(() => void refreshWithRetry(workspaceId), 300)
|
|
47
78
|
}
|
|
48
79
|
|
|
49
80
|
function onMessage(raw: string) {
|
|
@@ -143,6 +174,7 @@ export function useWorkspaceStream() {
|
|
|
143
174
|
|
|
144
175
|
socket.onopen = () => {
|
|
145
176
|
attempt = 0
|
|
177
|
+
connectionFailed.value = false
|
|
146
178
|
// Resync on (re)connect BEFORE announcing `connected`: any event missed while
|
|
147
179
|
// disconnected is reconciled first. The snapshot carries `bootstrapJobs` +
|
|
148
180
|
// executions, so one refresh rehydrates agentRuns too — a missed terminal event
|
|
@@ -157,18 +189,20 @@ export function useWorkspaceStream() {
|
|
|
157
189
|
// live "bootstrapping…" badge flickers out with no further board event to restore
|
|
158
190
|
// it. Anything acting on a `connected` board (a user, or an e2e spec gating on
|
|
159
191
|
// `data-connected`) then does so only after this reconcile, so a lagging resync
|
|
160
|
-
// can't drop the state that action produces.
|
|
161
|
-
// (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
192
|
+
// can't drop the state that action produces. The resync RETRIES on a transient
|
|
193
|
+
// failure (`refreshWithRetry`) so a reconnect no longer presents as fully live while
|
|
194
|
+
// silently missing everything from the outage; `connected` is still set even if every
|
|
195
|
+
// retry fails (we ARE connected; a refresh error must not wedge the indicator/tests).
|
|
196
|
+
void refreshWithRetry(workspaceId).finally(() => {
|
|
197
|
+
// A workspace switch (or stop()) may have happened while the refresh was in
|
|
198
|
+
// flight — don't announce a connection for a socket we've since abandoned.
|
|
199
|
+
if (!stopped && socket && workspace.workspaceId === workspaceId) {
|
|
200
|
+
// Flip `everConnected` here (not at onopen): only now are we "fully live", so a later
|
|
201
|
+
// drop reads as a real re-connection while this initial resync window does not.
|
|
202
|
+
everConnected.value = true
|
|
203
|
+
connected.value = true
|
|
204
|
+
}
|
|
205
|
+
})
|
|
172
206
|
}
|
|
173
207
|
socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
|
|
174
208
|
socket.onclose = () => {
|
|
@@ -181,6 +215,10 @@ export function useWorkspaceStream() {
|
|
|
181
215
|
function scheduleReconnect() {
|
|
182
216
|
if (stopped) return
|
|
183
217
|
socket = null
|
|
218
|
+
// If we've never gone live and keep failing, flag the board as offline so the banner can
|
|
219
|
+
// surface a "not receiving live updates" state (a REST-only board otherwise looks fine but
|
|
220
|
+
// silently never updates). Reset the moment a socket opens (see `onopen`).
|
|
221
|
+
if (!everConnected.value && attempt + 1 >= INITIAL_FAIL_ATTEMPTS) connectionFailed.value = true
|
|
184
222
|
const delay = Math.min(30_000, 500 * 2 ** attempt) // 0.5s → 30s cap
|
|
185
223
|
attempt += 1
|
|
186
224
|
reconnectTimer = setTimeout(connect, delay)
|
|
@@ -188,6 +226,11 @@ export function useWorkspaceStream() {
|
|
|
188
226
|
|
|
189
227
|
function start() {
|
|
190
228
|
stopped = false
|
|
229
|
+
// Reset the per-workspace connection lifecycle so a switch to a NEW workspace whose socket
|
|
230
|
+
// fails is flagged offline on its own merits, not masked by the previous workspace's history.
|
|
231
|
+
attempt = 0
|
|
232
|
+
everConnected.value = false
|
|
233
|
+
connectionFailed.value = false
|
|
191
234
|
connect()
|
|
192
235
|
}
|
|
193
236
|
|
|
@@ -201,5 +244,5 @@ export function useWorkspaceStream() {
|
|
|
201
244
|
}
|
|
202
245
|
|
|
203
246
|
onScopeDispose(stop)
|
|
204
|
-
return { start, stop, connected }
|
|
247
|
+
return { start, stop, connected, everConnected, connectionFailed }
|
|
205
248
|
}
|
package/app/pages/index.vue
CHANGED
|
@@ -243,6 +243,8 @@ const stream = useWorkspaceStream()
|
|
|
243
243
|
// in the template would not unwrap, since `stream` is a plain object). Drives the headless
|
|
244
244
|
// `workspace-stream` readiness marker the e2e suite waits on.
|
|
245
245
|
const streamConnected = computed(() => stream.connected.value)
|
|
246
|
+
const streamEverConnected = computed(() => stream.everConnected.value)
|
|
247
|
+
const streamConnectionFailed = computed(() => stream.connectionFailed.value)
|
|
246
248
|
watch(
|
|
247
249
|
() => workspace.workspaceId,
|
|
248
250
|
(id) => {
|
|
@@ -320,7 +322,11 @@ watch(
|
|
|
320
322
|
/>
|
|
321
323
|
<BoardToolbar />
|
|
322
324
|
<SpendWarningBanner />
|
|
323
|
-
<ConnectionStatusBanner
|
|
325
|
+
<ConnectionStatusBanner
|
|
326
|
+
:connected="streamConnected"
|
|
327
|
+
:ever-connected="streamEverConnected"
|
|
328
|
+
:connection-failed="streamConnectionFailed"
|
|
329
|
+
/>
|
|
324
330
|
<InspectorPanel />
|
|
325
331
|
<!-- Code-split focus view. The fade lives here (not inside the component) so the
|
|
326
332
|
leave animation still plays when `focusBlockId` clears and the v-if unmounts
|
package/app/stores/board.ts
CHANGED
|
@@ -557,7 +557,18 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
557
557
|
const t = getBlock(targetId)
|
|
558
558
|
if (!t || !t.dependsOn.includes(sourceId)) return
|
|
559
559
|
// the backend exposes a single toggle; the edge exists, so toggling removes it
|
|
560
|
-
|
|
560
|
+
try {
|
|
561
|
+
upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
|
|
562
|
+
} catch (e) {
|
|
563
|
+
// Mirror `toggleDependency`: a failure must surface (and leave the edge visible) rather
|
|
564
|
+
// than rejecting unhandled with no feedback.
|
|
565
|
+
toast.add({
|
|
566
|
+
title: tr('board.toast.unlinkFailed'),
|
|
567
|
+
description: e instanceof Error ? e.message : String(e),
|
|
568
|
+
icon: 'i-lucide-triangle-alert',
|
|
569
|
+
color: 'error',
|
|
570
|
+
})
|
|
571
|
+
}
|
|
561
572
|
}
|
|
562
573
|
|
|
563
574
|
return {
|
|
@@ -28,6 +28,12 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
28
28
|
const contextByExecution = ref<Record<string, AgentContextSnapshot[]>>({})
|
|
29
29
|
/** Execution ids whose context is currently loading. */
|
|
30
30
|
const contextLoading = ref<Set<string>>(new Set())
|
|
31
|
+
/**
|
|
32
|
+
* Last context-load error message per execution id, or null. Distinguishes a genuine fetch
|
|
33
|
+
* failure from a run with no captured context: without this, a swallowed error rendered as
|
|
34
|
+
* the "no context stored" empty state — indistinguishable from success-with-nothing.
|
|
35
|
+
*/
|
|
36
|
+
const contextErrors = ref<Record<string, string | null>>({})
|
|
31
37
|
/** Per-execution-id performed-search-query list (newest first). */
|
|
32
38
|
const searchQueriesByExecution = ref<Record<string, AgentSearchQuery[]>>({})
|
|
33
39
|
/** Execution ids whose search queries are currently loading. */
|
|
@@ -133,11 +139,17 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
133
139
|
async function loadContext(executionId: string) {
|
|
134
140
|
if (!workspace.workspaceId) return
|
|
135
141
|
withFlag(contextLoading, executionId, true)
|
|
142
|
+
contextErrors.value = { ...contextErrors.value, [executionId]: null }
|
|
136
143
|
try {
|
|
137
144
|
const { snapshots } = await api.getAgentContext(workspace.requireId(), executionId)
|
|
138
145
|
contextByExecution.value = { ...contextByExecution.value, [executionId]: snapshots }
|
|
139
|
-
} catch {
|
|
140
|
-
//
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// Record the error so the panel can offer a retry instead of masquerading the failure as
|
|
148
|
+
// the "no context stored" empty state.
|
|
149
|
+
contextErrors.value = {
|
|
150
|
+
...contextErrors.value,
|
|
151
|
+
[executionId]: err instanceof Error ? err.message : 'Failed to load context',
|
|
152
|
+
}
|
|
141
153
|
} finally {
|
|
142
154
|
withFlag(contextLoading, executionId, false)
|
|
143
155
|
}
|
|
@@ -199,6 +211,7 @@ export const useObservabilityStore = defineStore('observability', () => {
|
|
|
199
211
|
appendCall,
|
|
200
212
|
downloadExport,
|
|
201
213
|
contextByExecution,
|
|
214
|
+
contextErrors,
|
|
202
215
|
contextFor,
|
|
203
216
|
isContextLoading,
|
|
204
217
|
loadContext,
|
package/app/stores/preview.ts
CHANGED
|
@@ -23,7 +23,11 @@ export const usePreviewStore = defineStore('preview', () => {
|
|
|
23
23
|
|
|
24
24
|
// Active poll timers while a preview is `starting`, so a settled/left preview stops polling.
|
|
25
25
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
26
|
+
// Consecutive poll-tick failures per frame while `starting`, so a transient blip keeps polling
|
|
27
|
+
// (self-heals) but a persistent failure eventually surfaces instead of spinning forever.
|
|
28
|
+
const pollErrors = new Map<string, number>()
|
|
26
29
|
const POLL_INTERVAL_MS = 2_500
|
|
30
|
+
const POLL_MAX_ERRORS = 5
|
|
27
31
|
|
|
28
32
|
function stopPolling(frameId: string) {
|
|
29
33
|
const timer = timers.get(frameId)
|
|
@@ -31,6 +35,7 @@ export const usePreviewStore = defineStore('preview', () => {
|
|
|
31
35
|
clearTimeout(timer)
|
|
32
36
|
timers.delete(frameId)
|
|
33
37
|
}
|
|
38
|
+
pollErrors.delete(frameId)
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
function apply(frameId: string, state: PreviewState) {
|
|
@@ -50,9 +55,36 @@ export const usePreviewStore = defineStore('preview', () => {
|
|
|
50
55
|
async function refresh(frameId: string): Promise<void> {
|
|
51
56
|
const ws = useWorkspaceStore()
|
|
52
57
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
58
|
+
const state = await api.getPreview(ws.requireId(), frameId)
|
|
59
|
+
pollErrors.delete(frameId)
|
|
60
|
+
// Clear any stale error from an earlier failed request/poll so a now-successful fetch
|
|
61
|
+
// doesn't render a working preview under a leftover error banner.
|
|
62
|
+
requestError.value[frameId] = undefined
|
|
63
|
+
apply(frameId, state)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// If we were polling a `starting` preview, a transient error must NOT silently wedge the
|
|
66
|
+
// amber "Starting…" forever with no recovery: keep polling (it self-heals when the runtime
|
|
67
|
+
// recovers) up to POLL_MAX_ERRORS, then give up.
|
|
68
|
+
const prev = byFrame.value[frameId]
|
|
69
|
+
if (prev?.status === 'starting') {
|
|
70
|
+
const n = (pollErrors.get(frameId) ?? 0) + 1
|
|
71
|
+
if (n <= POLL_MAX_ERRORS) {
|
|
72
|
+
pollErrors.set(frameId, n)
|
|
73
|
+
timers.set(
|
|
74
|
+
frameId,
|
|
75
|
+
setTimeout(() => void refresh(frameId), POLL_INTERVAL_MS),
|
|
76
|
+
)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
// Gave up: the preview never became reachable through the blips. Flip it out of the amber
|
|
80
|
+
// "Starting…" into a `failed` state carrying the error (with a Start to retry), rather than
|
|
81
|
+
// leaving the status claiming "Starting…" while polling has silently stopped.
|
|
82
|
+
byFrame.value[frameId] = {
|
|
83
|
+
...prev,
|
|
84
|
+
status: 'failed',
|
|
85
|
+
error: err instanceof Error ? err.message : String(err),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
56
88
|
stopPolling(frameId)
|
|
57
89
|
}
|
|
58
90
|
}
|
package/i18n/locales/en.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "Loading…",
|
|
4
4
|
"loadingBoard": "Loading board…",
|
|
5
5
|
"backendUnreachable": "Can't reach the backend",
|
|
6
|
-
"reconnecting": "Reconnecting…"
|
|
6
|
+
"reconnecting": "Reconnecting…",
|
|
7
|
+
"offline": "Not receiving live updates"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "Language",
|
|
@@ -96,6 +97,7 @@
|
|
|
96
97
|
"moveFailed": "Could not move",
|
|
97
98
|
"deleteFailed": "Could not delete",
|
|
98
99
|
"linkFailed": "Could not link tasks",
|
|
100
|
+
"unlinkFailed": "Could not remove dependency",
|
|
99
101
|
"recurringDeleteFailed": "Could not delete recurring pipeline",
|
|
100
102
|
"deleted": "Deleted \"{name}\"",
|
|
101
103
|
"moved": "Moved \"{name}\""
|
|
@@ -1095,6 +1097,7 @@
|
|
|
1095
1097
|
"loadingContext": "Loading provided context…",
|
|
1096
1098
|
"noCalls": "No model calls recorded for this run.",
|
|
1097
1099
|
"noContext": "No agent context stored for this run. It is captured per dispatch when the workspace has 'Store full agent context' enabled.",
|
|
1100
|
+
"contextError": "Could not load the provided context.",
|
|
1098
1101
|
"summary": {
|
|
1099
1102
|
"calls": "Calls",
|
|
1100
1103
|
"tokensInOut": "Tokens (in / out)",
|
package/i18n/locales/es.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "Cargando…",
|
|
4
4
|
"loadingBoard": "Cargando tablero…",
|
|
5
5
|
"backendUnreachable": "No se puede conectar con el backend",
|
|
6
|
-
"reconnecting": "Reconectando…"
|
|
6
|
+
"reconnecting": "Reconectando…",
|
|
7
|
+
"offline": "No se reciben actualizaciones en vivo"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "Idioma",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"moveFailed": "No se pudo mover",
|
|
82
83
|
"deleteFailed": "No se pudo eliminar",
|
|
83
84
|
"linkFailed": "No se pudieron vincular las tareas",
|
|
85
|
+
"unlinkFailed": "No se pudo eliminar la dependencia",
|
|
84
86
|
"recurringDeleteFailed": "No se pudo eliminar el pipeline recurrente",
|
|
85
87
|
"deleted": "\"{name}\" eliminado",
|
|
86
88
|
"moved": "\"{name}\" movido"
|
|
@@ -1052,6 +1054,7 @@
|
|
|
1052
1054
|
"loadingContext": "Cargando contexto proporcionado…",
|
|
1053
1055
|
"noCalls": "No se registraron llamadas al modelo en esta ejecución.",
|
|
1054
1056
|
"noContext": "No se almacenó contexto del agente para esta ejecución. Se captura por despacho cuando el espacio de trabajo tiene activado 'Almacenar contexto completo del agente'.",
|
|
1057
|
+
"contextError": "No se pudo cargar el contexto proporcionado.",
|
|
1055
1058
|
"summary": {
|
|
1056
1059
|
"calls": "Llamadas",
|
|
1057
1060
|
"tokensInOut": "Tokens (entrada / salida)",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "Chargement…",
|
|
4
4
|
"loadingBoard": "Chargement du tableau…",
|
|
5
5
|
"backendUnreachable": "Impossible de joindre le backend",
|
|
6
|
-
"reconnecting": "Reconnexion…"
|
|
6
|
+
"reconnecting": "Reconnexion…",
|
|
7
|
+
"offline": "Mises à jour en direct non reçues"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "Langue",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"moveFailed": "Impossible de déplacer",
|
|
82
83
|
"deleteFailed": "Impossible de supprimer",
|
|
83
84
|
"linkFailed": "Impossible de lier les tâches",
|
|
85
|
+
"unlinkFailed": "Impossible de supprimer la dépendance",
|
|
84
86
|
"recurringDeleteFailed": "Impossible de supprimer le pipeline récurrent",
|
|
85
87
|
"deleted": "\"{name}\" supprimé",
|
|
86
88
|
"moved": "\"{name}\" déplacé"
|
|
@@ -1052,6 +1054,7 @@
|
|
|
1052
1054
|
"loadingContext": "Chargement du contexte fourni…",
|
|
1053
1055
|
"noCalls": "Aucun appel de modèle enregistré pour cette exécution.",
|
|
1054
1056
|
"noContext": "Aucun contexte d'agent stocké pour cette exécution. Il est capturé à chaque dispatch lorsque l'espace de travail a activé 'Stocker le contexte complet de l'agent'.",
|
|
1057
|
+
"contextError": "Impossible de charger le contexte fourni.",
|
|
1055
1058
|
"summary": {
|
|
1056
1059
|
"calls": "Appels",
|
|
1057
1060
|
"tokensInOut": "Tokens (entrée / sortie)",
|
package/i18n/locales/he.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "טוען…",
|
|
4
4
|
"loadingBoard": "טוען לוח…",
|
|
5
5
|
"backendUnreachable": "לא ניתן להתחבר לבקאנד",
|
|
6
|
-
"reconnecting": "מתחבר מחדש…"
|
|
6
|
+
"reconnecting": "מתחבר מחדש…",
|
|
7
|
+
"offline": "לא מתקבלים עדכונים חיים"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "שפה",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"moveFailed": "לא ניתן היה להעביר",
|
|
82
83
|
"deleteFailed": "לא ניתן היה למחוק",
|
|
83
84
|
"linkFailed": "לא ניתן היה לקשר משימות",
|
|
85
|
+
"unlinkFailed": "לא ניתן להסיר את התלות",
|
|
84
86
|
"recurringDeleteFailed": "לא ניתן היה למחוק את הצינור החוזר",
|
|
85
87
|
"deleted": "\"{name}\" נמחק",
|
|
86
88
|
"moved": "\"{name}\" הועבר"
|
|
@@ -1052,6 +1054,7 @@
|
|
|
1052
1054
|
"loadingContext": "טוען את ההקשר שסופק…",
|
|
1053
1055
|
"noCalls": "לא נרשמו קריאות מודל עבור ריצה זו.",
|
|
1054
1056
|
"noContext": "לא אוחסן הקשר סוכן עבור ריצה זו. הוא נלכד בכל שיגור כשבסביבת העבודה מופעל 'אחסן הקשר סוכן מלא'.",
|
|
1057
|
+
"contextError": "לא ניתן לטעון את ההקשר שסופק.",
|
|
1055
1058
|
"summary": {
|
|
1056
1059
|
"calls": "קריאות",
|
|
1057
1060
|
"tokensInOut": "טוקנים (נכנס / יוצא)",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "読み込み中…",
|
|
4
4
|
"loadingBoard": "ボードを読み込み中…",
|
|
5
5
|
"backendUnreachable": "バックエンドに接続できません",
|
|
6
|
-
"reconnecting": "再接続中…"
|
|
6
|
+
"reconnecting": "再接続中…",
|
|
7
|
+
"offline": "ライブ更新を受信していません"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "言語",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"moveFailed": "移動できませんでした",
|
|
82
83
|
"deleteFailed": "削除できませんでした",
|
|
83
84
|
"linkFailed": "タスクをリンクできませんでした",
|
|
85
|
+
"unlinkFailed": "依存関係を削除できませんでした",
|
|
84
86
|
"recurringDeleteFailed": "定期パイプラインを削除できませんでした",
|
|
85
87
|
"deleted": "\"{name}\" を削除しました",
|
|
86
88
|
"moved": "\"{name}\" を移動しました"
|
|
@@ -1052,6 +1054,7 @@
|
|
|
1052
1054
|
"loadingContext": "提供されたコンテキストを読み込み中…",
|
|
1053
1055
|
"noCalls": "この実行で記録されたモデル呼び出しはありません。",
|
|
1054
1056
|
"noContext": "この実行で保存されたエージェントコンテキストはありません。ワークスペースで「エージェントコンテキストを完全保存」が有効な場合、ディスパッチごとに記録されます。",
|
|
1057
|
+
"contextError": "提供されたコンテキストを読み込めませんでした。",
|
|
1055
1058
|
"summary": {
|
|
1056
1059
|
"calls": "呼び出し",
|
|
1057
1060
|
"tokensInOut": "トークン (入力 / 出力)",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "Ładowanie…",
|
|
4
4
|
"loadingBoard": "Ładowanie tablicy…",
|
|
5
5
|
"backendUnreachable": "Nie można połączyć się z backendem",
|
|
6
|
-
"reconnecting": "Ponowne łączenie…"
|
|
6
|
+
"reconnecting": "Ponowne łączenie…",
|
|
7
|
+
"offline": "Brak aktualizacji na żywo"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "Język",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"moveFailed": "Nie udało się przenieść",
|
|
82
83
|
"deleteFailed": "Nie udało się usunąć",
|
|
83
84
|
"linkFailed": "Nie udało się powiązać zadań",
|
|
85
|
+
"unlinkFailed": "Nie udało się usunąć zależności",
|
|
84
86
|
"recurringDeleteFailed": "Nie udało się usunąć cyklicznego pipeline'u",
|
|
85
87
|
"deleted": "Usunięto \"{name}\"",
|
|
86
88
|
"moved": "Przeniesiono \"{name}\""
|
|
@@ -1052,6 +1054,7 @@
|
|
|
1052
1054
|
"loadingContext": "Ładowanie dostarczonego kontekstu…",
|
|
1053
1055
|
"noCalls": "Brak zarejestrowanych wywołań modelu dla tego uruchomienia.",
|
|
1054
1056
|
"noContext": "Brak zapisanego kontekstu agenta dla tego uruchomienia. Jest on przechwytywany przy każdym wysłaniu, gdy w obszarze roboczym włączono opcję 'Przechowuj pełny kontekst agenta'.",
|
|
1057
|
+
"contextError": "Nie udało się załadować dostarczonego kontekstu.",
|
|
1055
1058
|
"summary": {
|
|
1056
1059
|
"calls": "Wywołania",
|
|
1057
1060
|
"tokensInOut": "Tokeny (we / wy)",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "Yükleniyor…",
|
|
4
4
|
"loadingBoard": "Pano yükleniyor…",
|
|
5
5
|
"backendUnreachable": "Arka uca ulaşılamıyor",
|
|
6
|
-
"reconnecting": "Yeniden bağlanılıyor…"
|
|
6
|
+
"reconnecting": "Yeniden bağlanılıyor…",
|
|
7
|
+
"offline": "Canlı güncellemeler alınmıyor"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "Dil",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"moveFailed": "Taşınamadı",
|
|
82
83
|
"deleteFailed": "Silinemedi",
|
|
83
84
|
"linkFailed": "Görevler bağlanamadı",
|
|
85
|
+
"unlinkFailed": "Bağımlılık kaldırılamadı",
|
|
84
86
|
"recurringDeleteFailed": "Yinelenen pipeline silinemedi",
|
|
85
87
|
"deleted": "\"{name}\" silindi",
|
|
86
88
|
"moved": "\"{name}\" taşındı"
|
|
@@ -1052,6 +1054,7 @@
|
|
|
1052
1054
|
"loadingContext": "Sağlanan bağlam yükleniyor…",
|
|
1053
1055
|
"noCalls": "Bu çalışma için kaydedilmiş model çağrısı yok.",
|
|
1054
1056
|
"noContext": "Bu çalışma için saklanmış aracı bağlamı yok. Çalışma alanında 'Tam aracı bağlamını sakla' etkinleştirildiğinde her gönderim için yakalanır.",
|
|
1057
|
+
"contextError": "Sağlanan bağlam yüklenemedi.",
|
|
1055
1058
|
"summary": {
|
|
1056
1059
|
"calls": "Çağrılar",
|
|
1057
1060
|
"tokensInOut": "Token (giriş / çıkış)",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
"loading": "Завантаження…",
|
|
4
4
|
"loadingBoard": "Завантаження дошки…",
|
|
5
5
|
"backendUnreachable": "Не вдається зʼєднатися з бекендом",
|
|
6
|
-
"reconnecting": "Повторне зʼєднання…"
|
|
6
|
+
"reconnecting": "Повторне зʼєднання…",
|
|
7
|
+
"offline": "Оновлення в реальному часі не надходять"
|
|
7
8
|
},
|
|
8
9
|
"language": {
|
|
9
10
|
"switcher": "Мова",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"moveFailed": "Не вдалося перемістити",
|
|
82
83
|
"deleteFailed": "Не вдалося видалити",
|
|
83
84
|
"linkFailed": "Не вдалося звʼязати завдання",
|
|
85
|
+
"unlinkFailed": "Не вдалося видалити залежність",
|
|
84
86
|
"recurringDeleteFailed": "Не вдалося видалити повторюваний пайплайн",
|
|
85
87
|
"deleted": "\"{name}\" видалено",
|
|
86
88
|
"moved": "\"{name}\" переміщено"
|
|
@@ -1052,6 +1054,7 @@
|
|
|
1052
1054
|
"loadingContext": "Завантаження наданого контексту…",
|
|
1053
1055
|
"noCalls": "Для цього запуску не записано викликів моделі.",
|
|
1054
1056
|
"noContext": "Для цього запуску не збережено контексту агента. Він фіксується при кожному відправленні, коли для робочого простору ввімкнено 'Зберігати повний контекст агента'.",
|
|
1057
|
+
"contextError": "Не вдалося завантажити наданий контекст.",
|
|
1055
1058
|
"summary": {
|
|
1056
1059
|
"calls": "Виклики",
|
|
1057
1060
|
"tokensInOut": "Токени (вхід / вихід)",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.96.
|
|
3
|
+
"version": "0.96.1",
|
|
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",
|