@cat-factory/app 0.95.1 → 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/board/AgentFailureHistory.vue +3 -33
- package/app/components/board/FailureHistoryList.vue +46 -0
- package/app/components/clarity/ClarityReviewWindow.vue +83 -27
- package/app/components/layout/ConnectionStatusBanner.vue +42 -24
- package/app/components/panels/AgentStepDetail.vue +45 -0
- package/app/components/panels/ObservabilityPanel.vue +176 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +27 -12
- package/app/components/spec/ServiceSpecWindow.vue +16 -1
- package/app/composables/api/execution.ts +9 -0
- package/app/composables/usePipelineErrorToast.ts +6 -0
- package/app/composables/useResultView.ts +11 -1
- package/app/composables/useWorkspaceStream.ts +57 -14
- package/app/pages/index.vue +7 -1
- package/app/stores/board.ts +12 -1
- package/app/stores/execution.spec.ts +35 -0
- package/app/stores/execution.ts +38 -3
- package/app/stores/observability.ts +52 -2
- package/app/stores/preview.ts +35 -3
- package/app/types/execution.ts +3 -0
- package/i18n/locales/en.json +16 -3
- package/i18n/locales/es.json +16 -3
- package/i18n/locales/fr.json +16 -3
- package/i18n/locales/he.json +16 -3
- package/i18n/locales/ja.json +16 -3
- package/i18n/locales/pl.json +16 -3
- package/i18n/locales/tr.json +16 -3
- package/i18n/locales/uk.json +16 -3
- package/package.json +2 -2
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, reactive, ref, watch } from 'vue'
|
|
3
3
|
import { onKeyStroke } from '@vueuse/core'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AgentContextSnapshot,
|
|
6
|
+
AgentSearchQuery,
|
|
7
|
+
LlmCallMetric,
|
|
8
|
+
WebSearchProvider,
|
|
9
|
+
} from '~/types/execution'
|
|
5
10
|
import { agentKindMeta } from '~/utils/catalog'
|
|
6
11
|
import { formatMs, formatTokens, pct } from '~/utils/observability'
|
|
7
12
|
|
|
@@ -32,9 +37,20 @@ const exporting = computed(
|
|
|
32
37
|
const error = computed(() =>
|
|
33
38
|
executionId.value ? (observability.errors[executionId.value] ?? null) : null,
|
|
34
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
|
+
}
|
|
35
50
|
|
|
36
|
-
// Which view is shown:
|
|
37
|
-
|
|
51
|
+
// Which view is shown: per-call model activity, the complete provided context, or the
|
|
52
|
+
// performed web searches.
|
|
53
|
+
const view = ref<'calls' | 'context' | 'search'>('calls')
|
|
38
54
|
|
|
39
55
|
const contextSnapshots = computed<AgentContextSnapshot[]>(() =>
|
|
40
56
|
executionId.value ? observability.contextFor(executionId.value) : [],
|
|
@@ -43,6 +59,39 @@ const contextLoading = computed(
|
|
|
43
59
|
() => !!executionId.value && observability.isContextLoading(executionId.value),
|
|
44
60
|
)
|
|
45
61
|
|
|
62
|
+
const searchQueries = computed<AgentSearchQuery[]>(() =>
|
|
63
|
+
executionId.value ? observability.searchQueriesFor(executionId.value) : [],
|
|
64
|
+
)
|
|
65
|
+
const searchLoading = computed(
|
|
66
|
+
() => !!executionId.value && observability.isSearchQueriesLoading(executionId.value),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// Brand names, kept verbatim across locales (not translatable prose).
|
|
70
|
+
const PROVIDER_LABEL: Record<WebSearchProvider, string> = { brave: 'Brave', searxng: 'SearXNG' }
|
|
71
|
+
function providerLabel(provider: WebSearchProvider | null): string {
|
|
72
|
+
return provider ? PROVIDER_LABEL[provider] : ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Whether web search was available to this run's container agents, and which provider(s)
|
|
76
|
+
// served it — a static per-run fact set on each container step at dispatch (not gated by
|
|
77
|
+
// prompt-recording telemetry, unlike the performed queries below).
|
|
78
|
+
const searchAvailability = computed<{ available: boolean; providers: WebSearchProvider[] } | null>(
|
|
79
|
+
() => {
|
|
80
|
+
const steps = (instance.value?.steps ?? []).filter((s) => s.search)
|
|
81
|
+
if (!steps.length) return null
|
|
82
|
+
const available = steps.some((s) => s.search?.available)
|
|
83
|
+
const providers = [
|
|
84
|
+
...new Set(
|
|
85
|
+
steps
|
|
86
|
+
.map((s) => s.search)
|
|
87
|
+
.filter((x): x is NonNullable<typeof x> => !!x?.available && !!x.provider)
|
|
88
|
+
.map((x) => x.provider as WebSearchProvider),
|
|
89
|
+
),
|
|
90
|
+
]
|
|
91
|
+
return { available, providers }
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
46
95
|
// Load (and refresh) whenever a different run's panel opens. Reset to the calls view
|
|
47
96
|
// and load both the calls and the provided-context snapshots.
|
|
48
97
|
watch(
|
|
@@ -52,6 +101,7 @@ watch(
|
|
|
52
101
|
view.value = 'calls'
|
|
53
102
|
void observability.load(id)
|
|
54
103
|
void observability.loadContext(id)
|
|
104
|
+
void observability.loadSearchQueries(id)
|
|
55
105
|
}
|
|
56
106
|
},
|
|
57
107
|
// Lazy v-if mount: the panel mounts with executionId already set, so load immediately.
|
|
@@ -182,6 +232,17 @@ function exportJson() {
|
|
|
182
232
|
>
|
|
183
233
|
{{ t('observability.providedContext') }}
|
|
184
234
|
</button>
|
|
235
|
+
<button
|
|
236
|
+
class="rounded-md px-2.5 py-1 transition"
|
|
237
|
+
:class="
|
|
238
|
+
view === 'search'
|
|
239
|
+
? 'bg-slate-800 text-slate-100'
|
|
240
|
+
: 'text-slate-400 hover:text-slate-200'
|
|
241
|
+
"
|
|
242
|
+
@click="view = 'search'"
|
|
243
|
+
>
|
|
244
|
+
{{ t('observability.webSearch') }}
|
|
245
|
+
</button>
|
|
185
246
|
</div>
|
|
186
247
|
<UButton
|
|
187
248
|
v-if="view === 'calls'"
|
|
@@ -282,12 +343,22 @@ function exportJson() {
|
|
|
282
343
|
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
283
344
|
{{ t('observability.loadingActivity') }}
|
|
284
345
|
</p>
|
|
285
|
-
<
|
|
346
|
+
<div
|
|
286
347
|
v-else-if="error"
|
|
287
|
-
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"
|
|
288
349
|
>
|
|
289
350
|
{{ error }}
|
|
290
|
-
|
|
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>
|
|
291
362
|
<p
|
|
292
363
|
v-else-if="!calls.length"
|
|
293
364
|
class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
|
|
@@ -434,7 +505,7 @@ function exportJson() {
|
|
|
434
505
|
</div>
|
|
435
506
|
|
|
436
507
|
<!-- Provided context: the complete context each container agent was given. -->
|
|
437
|
-
<div v-else class="mx-auto max-w-4xl space-y-5">
|
|
508
|
+
<div v-else-if="view === 'context'" class="mx-auto max-w-4xl space-y-5">
|
|
438
509
|
<p
|
|
439
510
|
v-if="contextLoading && !contextSnapshots.length"
|
|
440
511
|
class="flex items-center justify-center gap-2 py-8 text-center text-sm text-slate-500"
|
|
@@ -442,6 +513,22 @@ function exportJson() {
|
|
|
442
513
|
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
443
514
|
{{ t('observability.loadingContext') }}
|
|
444
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>
|
|
445
532
|
<p
|
|
446
533
|
v-else-if="!contextSnapshots.length"
|
|
447
534
|
class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
|
|
@@ -553,6 +640,88 @@ function exportJson() {
|
|
|
553
640
|
</li>
|
|
554
641
|
</ul>
|
|
555
642
|
</div>
|
|
643
|
+
|
|
644
|
+
<div v-else class="mx-auto max-w-4xl space-y-5">
|
|
645
|
+
<!-- Availability header: a static per-run fact (not telemetry-gated). -->
|
|
646
|
+
<section
|
|
647
|
+
v-if="searchAvailability"
|
|
648
|
+
class="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-slate-800 bg-slate-900/50 px-4 py-3 text-[13px]"
|
|
649
|
+
>
|
|
650
|
+
<span class="text-[11px] uppercase tracking-wide text-slate-500">
|
|
651
|
+
{{ t('observability.webSearch') }}
|
|
652
|
+
</span>
|
|
653
|
+
<span
|
|
654
|
+
class="inline-flex items-center gap-1.5"
|
|
655
|
+
:class="searchAvailability.available ? 'text-emerald-300' : 'text-slate-400'"
|
|
656
|
+
>
|
|
657
|
+
<UIcon
|
|
658
|
+
:name="searchAvailability.available ? 'i-lucide-globe' : 'i-lucide-globe-lock'"
|
|
659
|
+
class="h-4 w-4"
|
|
660
|
+
/>
|
|
661
|
+
{{
|
|
662
|
+
searchAvailability.available
|
|
663
|
+
? t('observability.search.available')
|
|
664
|
+
: t('observability.search.unavailable')
|
|
665
|
+
}}
|
|
666
|
+
</span>
|
|
667
|
+
<span v-if="searchAvailability.providers.length" class="text-slate-400 tabular-nums">
|
|
668
|
+
{{ t('observability.search.provider') }}:
|
|
669
|
+
{{ searchAvailability.providers.map(providerLabel).join(', ') }}
|
|
670
|
+
</span>
|
|
671
|
+
</section>
|
|
672
|
+
|
|
673
|
+
<p
|
|
674
|
+
v-if="searchLoading && !searchQueries.length"
|
|
675
|
+
class="flex items-center justify-center gap-2 py-8 text-center text-sm text-slate-500"
|
|
676
|
+
>
|
|
677
|
+
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
678
|
+
{{ t('observability.loadingSearch') }}
|
|
679
|
+
</p>
|
|
680
|
+
<p
|
|
681
|
+
v-else-if="!searchQueries.length"
|
|
682
|
+
class="rounded-lg border border-dashed border-slate-800 py-8 text-center text-sm text-slate-500"
|
|
683
|
+
>
|
|
684
|
+
{{ t('observability.noSearch') }}
|
|
685
|
+
</p>
|
|
686
|
+
|
|
687
|
+
<div v-else>
|
|
688
|
+
<div class="mb-2 text-[11px] uppercase tracking-wide text-slate-500">
|
|
689
|
+
{{ t('observability.search.queriesTitle') }}
|
|
690
|
+
</div>
|
|
691
|
+
<ul class="space-y-2">
|
|
692
|
+
<li
|
|
693
|
+
v-for="q in searchQueries"
|
|
694
|
+
:key="q.id"
|
|
695
|
+
class="flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-900/40 px-4 py-2.5"
|
|
696
|
+
>
|
|
697
|
+
<UIcon
|
|
698
|
+
:name="agentMeta(q.agentKind).icon"
|
|
699
|
+
class="h-4 w-4 shrink-0"
|
|
700
|
+
:style="{ color: agentMeta(q.agentKind).color }"
|
|
701
|
+
:title="agentMeta(q.agentKind).label"
|
|
702
|
+
/>
|
|
703
|
+
<span class="min-w-0 flex-1 truncate text-[13px] text-slate-200" :title="q.query">
|
|
704
|
+
{{ q.query }}
|
|
705
|
+
</span>
|
|
706
|
+
<div
|
|
707
|
+
class="flex shrink-0 items-center gap-2.5 text-[11px] tabular-nums text-slate-400"
|
|
708
|
+
>
|
|
709
|
+
<span v-if="q.provider" class="hidden sm:inline">{{
|
|
710
|
+
providerLabel(q.provider)
|
|
711
|
+
}}</span>
|
|
712
|
+
<span>{{
|
|
713
|
+
t(
|
|
714
|
+
'observability.search.resultsCount',
|
|
715
|
+
{ count: q.resultCount },
|
|
716
|
+
q.resultCount,
|
|
717
|
+
)
|
|
718
|
+
}}</span>
|
|
719
|
+
<span class="hidden text-slate-600 md:inline">{{ clock(q.createdAt) }}</span>
|
|
720
|
+
</div>
|
|
721
|
+
</li>
|
|
722
|
+
</ul>
|
|
723
|
+
</div>
|
|
724
|
+
</div>
|
|
556
725
|
</div>
|
|
557
726
|
</div>
|
|
558
727
|
</Transition>
|
|
@@ -59,6 +59,10 @@ const { open, blockId, instanceId, stepIndex, close } = useResultView('requireme
|
|
|
59
59
|
docCollapsedOverride.value = null
|
|
60
60
|
void requirements.load(id)
|
|
61
61
|
},
|
|
62
|
+
// Closing the window (X, backdrop, Escape) must not silently drop an answer the user typed
|
|
63
|
+
// but never blurred out of. Flush before the view tears down; flushDrafts captures the
|
|
64
|
+
// review up front so the persist survives blockId going null on close (UX-33).
|
|
65
|
+
onClose: () => void flushDrafts(),
|
|
62
66
|
})
|
|
63
67
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
64
68
|
const review = computed<RequirementReview | null>(() =>
|
|
@@ -167,23 +171,29 @@ function notifyError(title: string, e: unknown) {
|
|
|
167
171
|
// the recorded reply (see the watch below); editing and blurring persists it. Persist only
|
|
168
172
|
// when the trimmed draft actually differs from what's already recorded, so blurring an
|
|
169
173
|
// untouched field is a no-op.
|
|
170
|
-
async function persistDraft(
|
|
171
|
-
|
|
174
|
+
async function persistDraft(
|
|
175
|
+
item: RequirementReviewItem,
|
|
176
|
+
r: RequirementReview | null = review.value,
|
|
177
|
+
) {
|
|
178
|
+
if (!r || frozen.value) return
|
|
172
179
|
const text = (drafts.value[item.id] ?? '').trim()
|
|
173
180
|
if (!text || text === (item.reply ?? '').trim()) return
|
|
174
181
|
try {
|
|
175
|
-
await requirements.reply(
|
|
182
|
+
await requirements.reply(r, item.id, text)
|
|
176
183
|
} catch (e) {
|
|
177
184
|
notifyError(t('requirements.errors.saveAnswer'), e)
|
|
178
185
|
}
|
|
179
186
|
}
|
|
180
187
|
|
|
181
|
-
// Persist every dirty draft before an action that consumes the answers
|
|
182
|
-
// user typed but never blurred out of isn't lost.
|
|
188
|
+
// Persist every dirty draft before an action that consumes the answers (or on window close),
|
|
189
|
+
// so a value the user typed but never blurred out of isn't lost. Snapshots the review up front
|
|
190
|
+
// and threads it through, so the persist completes even if the window closes mid-flush (the
|
|
191
|
+
// reactive `review` goes null the moment the view tears down).
|
|
183
192
|
async function flushDrafts() {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
193
|
+
const r = review.value
|
|
194
|
+
if (!r) return
|
|
195
|
+
for (const item of r.items) {
|
|
196
|
+
if (item.status === 'open' || item.status === 'answered') await persistDraft(item, r)
|
|
187
197
|
}
|
|
188
198
|
}
|
|
189
199
|
|
|
@@ -483,7 +493,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
483
493
|
</div>
|
|
484
494
|
</header>
|
|
485
495
|
|
|
486
|
-
<div class="flex min-h-0 flex-1">
|
|
496
|
+
<div class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
|
487
497
|
<!-- main column -->
|
|
488
498
|
<div class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
|
489
499
|
<i18n-t
|
|
@@ -842,10 +852,15 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
842
852
|
</template>
|
|
843
853
|
</div>
|
|
844
854
|
|
|
845
|
-
<!-- right action
|
|
846
|
-
|
|
855
|
+
<!-- action rail: a right-hand column on wide screens, a bottom action bar below `lg`
|
|
856
|
+
(never hidden — the gate is otherwise unadvanceable on a laptop split-screen /
|
|
857
|
+
tablet, UX-32). The informational stats collapse away below `lg` to keep the
|
|
858
|
+
bottom bar compact; the actions themselves always show. -->
|
|
859
|
+
<aside
|
|
860
|
+
class="flex w-full shrink-0 flex-col border-t border-slate-800 lg:w-72 lg:border-s lg:border-t-0"
|
|
861
|
+
>
|
|
847
862
|
<div class="flex flex-col gap-4 px-4 py-5">
|
|
848
|
-
<div v-if="review" class="space-y-2 text-xs text-slate-400">
|
|
863
|
+
<div v-if="review" class="hidden space-y-2 text-xs text-slate-400 lg:block">
|
|
849
864
|
<div class="flex items-center justify-between">
|
|
850
865
|
<span>{{ t('requirements.stats.findings') }}</span>
|
|
851
866
|
<span class="text-slate-300">{{ review.items.length }}</span>
|
|
@@ -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 -->
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
exportExecutionLlmMetricsContract,
|
|
5
5
|
getExecutionAgentContextContract,
|
|
6
6
|
getExecutionLlmMetricsContract,
|
|
7
|
+
getExecutionSearchQueriesContract,
|
|
7
8
|
mergeBlockContract,
|
|
8
9
|
rejectStepContract,
|
|
9
10
|
requestStepChangesContract,
|
|
@@ -145,6 +146,14 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
|
145
146
|
pathParams: { executionId },
|
|
146
147
|
}),
|
|
147
148
|
|
|
149
|
+
// The web searches each container agent performed in a run (query, provider,
|
|
150
|
+
// result count). Empty when not wired / storing is off.
|
|
151
|
+
getSearchQueries: (workspaceId: string, executionId: string) =>
|
|
152
|
+
send(getExecutionSearchQueriesContract, {
|
|
153
|
+
pathPrefix: ws(workspaceId),
|
|
154
|
+
pathParams: { executionId },
|
|
155
|
+
}),
|
|
156
|
+
|
|
148
157
|
// ---- spend safeguard --------------------------------------------------
|
|
149
158
|
resumeSpend: (workspaceId: string) =>
|
|
150
159
|
send(resumeSpendContract, { pathPrefix: ws(workspaceId) }),
|
|
@@ -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'),
|
|
@@ -12,8 +12,17 @@
|
|
|
12
12
|
*
|
|
13
13
|
* A synchronous window (one that reads its data straight off the execution step, like the
|
|
14
14
|
* test report) simply omits `onOpen`.
|
|
15
|
+
*
|
|
16
|
+
* `onClose` runs on EVERY close path — the X button, backdrop click, and the Escape key
|
|
17
|
+
* handled here — BEFORE the view is torn down, so a window with unsaved draft input (the
|
|
18
|
+
* review windows) can flush it in one place instead of every caller having to remember to.
|
|
19
|
+
* It runs synchronously; if it kicks off async work it must capture whatever it needs first,
|
|
20
|
+
* because `blockId`/the derived state go null the moment the view closes.
|
|
15
21
|
*/
|
|
16
|
-
export function useResultView(
|
|
22
|
+
export function useResultView(
|
|
23
|
+
viewId: string,
|
|
24
|
+
opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
|
|
25
|
+
) {
|
|
17
26
|
const ui = useUiStore()
|
|
18
27
|
|
|
19
28
|
const open = computed(() => ui.resultView?.view === viewId)
|
|
@@ -26,6 +35,7 @@ export function useResultView(viewId: string, opts?: { onOpen?: (blockId: string
|
|
|
26
35
|
const stage = computed(() => (open.value ? (ui.resultView!.stage ?? null) : null))
|
|
27
36
|
|
|
28
37
|
function close() {
|
|
38
|
+
if (open.value) opts?.onClose?.()
|
|
29
39
|
ui.closeResultView()
|
|
30
40
|
}
|
|
31
41
|
|
|
@@ -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 {
|
|
@@ -76,6 +76,41 @@ describe('execution store snapshot/event reconcile', () => {
|
|
|
76
76
|
expect(store.getInstance('e1')?.rev).toBe(2)
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
+
it('drops a superseded failed run when a retry replaces it under a new id (same block)', () => {
|
|
80
|
+
// A failed run for a block is cached...
|
|
81
|
+
store.hydrate(
|
|
82
|
+
[{ id: 'e_old', blockId: 'b1', steps: [], status: 'failed', rev: 1 } as never],
|
|
83
|
+
'ws1',
|
|
84
|
+
)
|
|
85
|
+
// ...then a retry mints a FRESH run (new id) for the SAME block and deletes the old one
|
|
86
|
+
// server-side. The post-retry snapshot carries only the new running run.
|
|
87
|
+
store.hydrate(
|
|
88
|
+
[{ id: 'e_new', blockId: 'b1', steps: [], status: 'running', rev: 1 } as never],
|
|
89
|
+
'ws1',
|
|
90
|
+
)
|
|
91
|
+
// The dead predecessor must not linger and shadow the running run in the by-block projection.
|
|
92
|
+
expect(store.getInstance('e_old')).toBeUndefined()
|
|
93
|
+
expect(store.getInstance('e_new')?.status).toBe('running')
|
|
94
|
+
expect(store.getByBlock('b1')?.id).toBe('e_new')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('keeps a live-added running run when a stale snapshot still lists its block predecessor', () => {
|
|
98
|
+
// A retry already minted e_new (running) for b1 — a live event added it to the cache...
|
|
99
|
+
store.hydrate(
|
|
100
|
+
[{ id: 'e_new', blockId: 'b1', steps: [], status: 'running', rev: 1 } as never],
|
|
101
|
+
'ws1',
|
|
102
|
+
)
|
|
103
|
+
// ...but a reconnect resync fetched BEFORE the retry resolves late (under load) and still
|
|
104
|
+
// carries the now-deleted predecessor e_old (failed) for the same block.
|
|
105
|
+
store.hydrate(
|
|
106
|
+
[{ id: 'e_old', blockId: 'b1', steps: [], status: 'failed', rev: 1 } as never],
|
|
107
|
+
'ws1',
|
|
108
|
+
)
|
|
109
|
+
// The live running run must survive — only a TERMINAL cached run is a superseded predecessor.
|
|
110
|
+
expect(store.getInstance('e_new')?.status).toBe('running')
|
|
111
|
+
expect(store.getByBlock('b1')?.id).toBe('e_new')
|
|
112
|
+
})
|
|
113
|
+
|
|
79
114
|
it('a workspace switch replaces the cache outright (no cross-board leak)', () => {
|
|
80
115
|
store.hydrate([run('e1', 1, 'running')], 'ws1')
|
|
81
116
|
store.upsert(run('e2', 1, 'running'))
|