@cat-factory/app 0.283.0 → 0.284.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.
Files changed (48) hide show
  1. package/README.md +19 -0
  2. package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
  3. package/app/components/board/BoardCanvas.logic.ts +92 -0
  4. package/app/components/board/BoardCanvas.vue +14 -27
  5. package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
  6. package/app/components/panels/AgentStepDetail.vue +27 -1
  7. package/app/components/panels/ResultWindowShell.vue +19 -0
  8. package/app/components/panels/RunDetailLoadState.vue +41 -0
  9. package/app/components/panels/inspector/TaskExecution.vue +3 -3
  10. package/app/components/pipeline/PipelineProgress.vue +7 -3
  11. package/app/composables/api/execution.ts +12 -0
  12. package/app/composables/useBlockDrag.ts +51 -5
  13. package/app/composables/useSingleFlight.spec.ts +42 -0
  14. package/app/composables/useSingleFlight.ts +37 -0
  15. package/app/composables/useStepApproval.ts +19 -0
  16. package/app/composables/useStepTimer.ts +70 -14
  17. package/app/composables/useUpsertList.spec.ts +73 -0
  18. package/app/composables/useUpsertList.ts +52 -6
  19. package/app/composables/useViewport.ts +13 -3
  20. package/app/stores/consensus.ts +8 -1
  21. package/app/stores/docInterview.ts +10 -1
  22. package/app/stores/execution/reconcile.ts +182 -0
  23. package/app/stores/execution/wholeRunReads.ts +139 -0
  24. package/app/stores/execution.spec.ts +297 -1
  25. package/app/stores/execution.ts +57 -110
  26. package/app/stores/kaizen.spec.ts +77 -14
  27. package/app/stores/kaizen.ts +75 -17
  28. package/app/stores/notifications.spec.ts +65 -0
  29. package/app/stores/notifications.ts +29 -0
  30. package/app/stores/observability/agentContext.ts +128 -0
  31. package/app/stores/observability/toolCalls.ts +30 -2
  32. package/app/stores/observability.spec.ts +98 -0
  33. package/app/stores/observability.ts +51 -79
  34. package/app/stores/requirements/settlement.ts +55 -0
  35. package/app/stores/requirements.ts +25 -23
  36. package/app/stores/workspace/hydrate.ts +11 -0
  37. package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
  38. package/i18n/locales/de.json +4 -0
  39. package/i18n/locales/en.json +4 -0
  40. package/i18n/locales/es.json +4 -0
  41. package/i18n/locales/fr.json +4 -0
  42. package/i18n/locales/he.json +4 -0
  43. package/i18n/locales/it.json +4 -0
  44. package/i18n/locales/ja.json +4 -0
  45. package/i18n/locales/pl.json +4 -0
  46. package/i18n/locales/tr.json +4 -0
  47. package/i18n/locales/uk.json +4 -0
  48. package/package.json +2 -2
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useObservabilityStore } from '~/stores/observability'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { LlmCallActivity } from '~/types/execution'
5
+
6
+ /**
7
+ * The two bounds on a store that would otherwise be a per-session accumulator: live events fold
8
+ * only into runs whose panel was OPENED, and a board switch drops every run. Both are about
9
+ * growth, so both are asserted on what SURVIVES rather than on any one row.
10
+ *
11
+ * There is deliberately NO third bound capping one run's list. That is asserted too, below: the
12
+ * rows a cap would evict are the ones the panel exists to show, so a long watched run keeps all
13
+ * of them.
14
+ */
15
+
16
+ /** A live `llmCall` event carrying only what `appendCall` materialises a row from. */
17
+ function activity(id: string, executionId = 'exec1'): LlmCallActivity {
18
+ return {
19
+ id,
20
+ executionId,
21
+ blockId: 'blk1',
22
+ agentKind: 'coder',
23
+ model: 'm',
24
+ ok: true,
25
+ phase: 'agent',
26
+ finishReason: 'stop',
27
+ } as unknown as LlmCallActivity
28
+ }
29
+
30
+ describe('observability store growth bounds', () => {
31
+ beforeEach(() => {
32
+ useWorkspaceStore().workspaceId = 'ws1'
33
+ vi.stubGlobal('useApi', () => ({
34
+ getLlmMetrics: () => Promise.resolve({ calls: [] }),
35
+ }))
36
+ })
37
+
38
+ it('folds live calls only into runs whose panel has been opened', async () => {
39
+ const store = useObservabilityStore()
40
+ store.appendCall(activity('never-opened'))
41
+ expect(store.callsFor('exec1')).toEqual([])
42
+
43
+ await store.load('exec1')
44
+ store.appendCall(activity('c1'))
45
+ expect(store.callsFor('exec1').map((c) => c.id)).toEqual(['c1'])
46
+ })
47
+
48
+ // A per-run cap was tried here and removed: whatever it evicted, the panel could no longer
49
+ // show, and no eviction rule can tell an operator which call they are now missing. The long
50
+ // watched run is exactly the one worth reading, so it keeps every row.
51
+ it('never evicts a row from an opened run, however long it runs', async () => {
52
+ const store = useObservabilityStore()
53
+ const burst = 1200
54
+ await store.load('exec1')
55
+ for (let i = 0; i < burst; i++) store.appendCall(activity(`c${i}`))
56
+
57
+ const held = store.callsFor('exec1')
58
+ expect(held).toHaveLength(burst)
59
+ // Newest-first, and the oldest call is still there to scroll back to.
60
+ expect(held[0]!.id).toBe(`c${burst - 1}`)
61
+ expect(held.at(-1)!.id).toBe('c0')
62
+ })
63
+
64
+ it('keeps every row a persisted read answered with when a live call lands on top', async () => {
65
+ const persisted = Array.from({ length: 300 }, (_, i) => ({
66
+ ...activity(`p${i}`),
67
+ turnIndex: null,
68
+ promptText: '',
69
+ promptPrefixCount: 0,
70
+ promptHash: '',
71
+ responseText: '',
72
+ reasoningText: '',
73
+ }))
74
+ vi.stubGlobal('useApi', () => ({
75
+ getLlmMetrics: () => Promise.resolve({ calls: persisted }),
76
+ }))
77
+ const store = useObservabilityStore()
78
+ await store.load('exec1')
79
+ expect(store.callsFor('exec1')).toHaveLength(300)
80
+
81
+ store.appendCall(activity('live'))
82
+ expect(store.callsFor('exec1')).toHaveLength(301)
83
+ expect(store.callsFor('exec1')[0]!.id).toBe('live')
84
+ })
85
+
86
+ it('evicts every per-run cache on a board switch', async () => {
87
+ const store = useObservabilityStore()
88
+ await store.load('exec1')
89
+ store.appendCall(activity('c1'))
90
+ expect(store.callsFor('exec1')).toHaveLength(1)
91
+
92
+ store.reset()
93
+ // Back to "never opened": the run is gone, so a stray live event for it folds nowhere.
94
+ store.appendCall(activity('c2'))
95
+ expect(store.callsFor('exec1')).toEqual([])
96
+ expect(store.callsByExecution).toEqual({})
97
+ })
98
+ })
@@ -1,13 +1,10 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
- import type {
4
- AgentContextSnapshot,
5
- AgentSearchQuery,
6
- LlmCallActivity,
7
- LlmCallMetric,
8
- } from '~/types/execution'
3
+ import type { LlmCallActivity, LlmCallMetric } from '~/types/execution'
9
4
  import { useWorkspaceStore } from '~/stores/workspace'
10
5
  import { createToolCallSinkState } from '~/stores/observability/toolCalls'
6
+ import { createAgentContextSinkState } from '~/stores/observability/agentContext'
7
+ import { useSingleFlight } from '~/composables/useSingleFlight'
11
8
 
12
9
  /**
13
10
  * LLM observability state: the full per-call model activity for a run (prompts,
@@ -23,6 +20,13 @@ export const useObservabilityStore = defineStore('observability', () => {
23
20
  const api = useApi()
24
21
  const workspace = useWorkspaceStore()
25
22
 
23
+ /**
24
+ * One in-flight read per run for the call log below. Its load is triggered by a panel OPENING,
25
+ * and two openers in one tick is the normal case (the window and its shell, a deep link plus the
26
+ * click behind it), so it fired twice for one answer. The extracted sinks hold their own.
27
+ */
28
+ const loads = useSingleFlight<string, void>()
29
+
26
30
  /**
27
31
  * The TOOL-CALL sink, extracted whole: two reads at two different bounds, plus the rule that
28
32
  * keeps them apart (see `observability/toolCalls.ts`). The store owns the workspace binding and
@@ -34,22 +38,28 @@ export const useObservabilityStore = defineStore('observability', () => {
34
38
  fetchFailures: (executionId) => api.getToolCallFailures(workspace.requireId(), executionId),
35
39
  })
36
40
 
37
- /** Per-execution-id call list (newest first). */
38
- const callsByExecution = ref<Record<string, LlmCallMetric[]>>({})
39
- /** Per-execution-id provided-context snapshot list (newest first). */
40
- const contextByExecution = ref<Record<string, AgentContextSnapshot[]>>({})
41
- /** Execution ids whose context is currently loading. */
42
- const contextLoading = ref<Set<string>>(new Set())
43
41
  /**
44
- * Last context-load error message per execution id, or null. Distinguishes a genuine fetch
45
- * failure from a run with no captured context: without this, a swallowed error rendered as
46
- * the "no context stored" empty state — indistinguishable from success-with-nothing.
42
+ * The AGENT-CONTEXT and SEARCH-QUERY sinks, extracted as one pair for the same reason as the
43
+ * tool-call one beside it: both are per-dispatch records the panel loads on open and neither is
44
+ * pushed live (see `observability/agentContext.ts`).
47
45
  */
48
- const contextErrors = ref<Record<string, string | null>>({})
49
- /** Per-execution-id performed-search-query list (newest first). */
50
- const searchQueriesByExecution = ref<Record<string, AgentSearchQuery[]>>({})
51
- /** Execution ids whose search queries are currently loading. */
52
- const searchQueriesLoading = ref<Set<string>>(new Set())
46
+ const agentContext = createAgentContextSinkState({
47
+ ready: () => !!workspace.workspaceId,
48
+ fetchContext: (executionId) => api.getAgentContext(workspace.requireId(), executionId),
49
+ fetchSearchQueries: (executionId) => api.getSearchQueries(workspace.requireId(), executionId),
50
+ })
51
+
52
+ /**
53
+ * Per-execution-id call list (newest first).
54
+ *
55
+ * DELIBERATELY UNCAPPED. A per-run cap was tried and removed: the rows it evicted are the ones
56
+ * this panel exists to show, and no eviction rule can tell an operator which call they now
57
+ * cannot read. What bounds this store instead costs nothing: {@link appendCall} folds live
58
+ * events only into runs whose panel has been OPENED, and {@link reset} drops every run on a
59
+ * board switch. What is left growing is one open run's own log while someone watches it, which
60
+ * is a list they asked for and are reading.
61
+ */
62
+ const callsByExecution = ref<Record<string, LlmCallMetric[]>>({})
53
63
  /** Execution ids currently loading. */
54
64
  const loading = ref<Set<string>>(new Set())
55
65
  /** Execution ids currently exporting. */
@@ -75,7 +85,11 @@ export const useObservabilityStore = defineStore('observability', () => {
75
85
  }
76
86
 
77
87
  /** Load (or refresh) the per-call detail for a run. */
78
- async function load(executionId: string) {
88
+ function load(executionId: string): Promise<void> {
89
+ return loads.run(`calls:${executionId}`, () => fetchCalls(executionId))
90
+ }
91
+
92
+ async function fetchCalls(executionId: string) {
79
93
  if (!workspace.workspaceId) return
80
94
  withFlag(loading, executionId, true)
81
95
  errors.value = { ...errors.value, [executionId]: null }
@@ -141,58 +155,23 @@ export const useObservabilityStore = defineStore('observability', () => {
141
155
  responseText: '',
142
156
  reasoningText: '',
143
157
  }
144
- callsByExecution.value = { ...callsByExecution.value, [executionId]: [row, ...existing] }
145
- }
146
-
147
- function contextFor(executionId: string): AgentContextSnapshot[] {
148
- return contextByExecution.value[executionId] ?? []
149
- }
150
- function isContextLoading(executionId: string): boolean {
151
- return contextLoading.value.has(executionId)
152
- }
153
-
154
- /** Load (or refresh) the per-dispatch provided-context snapshots for a run. */
155
- async function loadContext(executionId: string) {
156
- if (!workspace.workspaceId) return
157
- withFlag(contextLoading, executionId, true)
158
- contextErrors.value = { ...contextErrors.value, [executionId]: null }
159
- try {
160
- const { snapshots } = await api.getAgentContext(workspace.requireId(), executionId)
161
- contextByExecution.value = { ...contextByExecution.value, [executionId]: snapshots }
162
- } catch (err) {
163
- // Record the error so the panel can offer a retry instead of masquerading the failure as
164
- // the "no context stored" empty state.
165
- contextErrors.value = {
166
- ...contextErrors.value,
167
- [executionId]: err instanceof Error ? err.message : 'Failed to load context',
168
- }
169
- } finally {
170
- withFlag(contextLoading, executionId, false)
158
+ callsByExecution.value = {
159
+ ...callsByExecution.value,
160
+ [executionId]: [row, ...existing],
171
161
  }
172
162
  }
173
163
 
174
- function searchQueriesFor(executionId: string): AgentSearchQuery[] {
175
- return searchQueriesByExecution.value[executionId] ?? []
176
- }
177
- function isSearchQueriesLoading(executionId: string): boolean {
178
- return searchQueriesLoading.value.has(executionId)
179
- }
180
-
181
- /** Load (or refresh) the performed web-search queries for a run. */
182
- async function loadSearchQueries(executionId: string) {
183
- if (!workspace.workspaceId) return
184
- withFlag(searchQueriesLoading, executionId, true)
185
- try {
186
- const { searchQueries } = await api.getSearchQueries(workspace.requireId(), executionId)
187
- searchQueriesByExecution.value = {
188
- ...searchQueriesByExecution.value,
189
- [executionId]: searchQueries,
190
- }
191
- } catch {
192
- // Best-effort: the panel shows an empty state; nothing is persisted client-side.
193
- } finally {
194
- withFlag(searchQueriesLoading, executionId, false)
195
- }
164
+ /**
165
+ * Drop every per-run cache. Called on a board SWITCH: an execution id is scoped to the board
166
+ * that owns it, nothing here is part of the snapshot, and no id was ever evicted otherwise, so
167
+ * without this the session accumulates every run of every board it visits. Each panel re-loads
168
+ * on open, which is how these were populated in the first place.
169
+ */
170
+ function reset() {
171
+ callsByExecution.value = {}
172
+ errors.value = {}
173
+ agentContext.resetAgentContext()
174
+ toolCalls.resetToolCalls()
196
175
  }
197
176
 
198
177
  /**
@@ -220,21 +199,14 @@ export const useObservabilityStore = defineStore('observability', () => {
220
199
  return {
221
200
  callsByExecution,
222
201
  callsFor,
202
+ reset,
223
203
  isLoading,
224
204
  isExporting,
225
205
  errors,
226
206
  load,
227
207
  appendCall,
228
208
  downloadExport,
229
- contextByExecution,
230
- contextErrors,
231
- contextFor,
232
- isContextLoading,
233
- loadContext,
234
- searchQueriesByExecution,
235
- searchQueriesFor,
236
- isSearchQueriesLoading,
237
- loadSearchQueries,
209
+ ...agentContext,
238
210
  ...toolCalls,
239
211
  }
240
212
  })
@@ -0,0 +1,55 @@
1
+ import type { RequirementReview } from '~/types/requirements'
2
+
3
+ /**
4
+ * What a requirements review's findings ADD UP TO: how many still need a human, how many the human
5
+ * answered, and the three dispositions the window's action rail derives from that pair.
6
+ *
7
+ * Pure functions of one review, so they live beside the store rather than inside it. They also
8
+ * share ONE pass over the findings, memoised on the review OBJECT: they compose (`canIncorporate`
9
+ * asks `allSettled`, which asks `openCount`, and then asks `answeredCount`), so a card rendering a
10
+ * review's state filtered its item list up to five times. Keying on identity is safe and
11
+ * self-invalidating because the store REPLACES the review object on every write, so a tallied
12
+ * object can never change under the cache; a `WeakMap` also lets a superseded review be collected
13
+ * along with its tally.
14
+ */
15
+ const tallies = new WeakMap<RequirementReview, { open: number; answered: number }>()
16
+
17
+ function tally(review: RequirementReview): { open: number; answered: number } {
18
+ let counts = tallies.get(review)
19
+ if (!counts) {
20
+ counts = { open: 0, answered: 0 }
21
+ for (const item of review.items) {
22
+ if (item.status === 'open') counts.open++
23
+ else if (item.status === 'answered' || item.status === 'resolved') counts.answered++
24
+ }
25
+ tallies.set(review, counts)
26
+ }
27
+ return counts
28
+ }
29
+
30
+ /** Findings still needing a human (status `open`). */
31
+ export function openCount(review: RequirementReview): number {
32
+ return tally(review).open
33
+ }
34
+
35
+ /** Findings the human answered (a reply recorded), which the companion folds in. */
36
+ export function answeredCount(review: RequirementReview): number {
37
+ return tally(review).answered
38
+ }
39
+
40
+ /** Every finding is settled (answered or dismissed), none still open. */
41
+ export function allSettled(review: RequirementReview): boolean {
42
+ return tally(review).open === 0
43
+ }
44
+
45
+ /** Incorporation is possible: all findings settled AND at least one was answered. */
46
+ export function canIncorporate(review: RequirementReview): boolean {
47
+ const { open, answered } = tally(review)
48
+ return open === 0 && answered > 0
49
+ }
50
+
51
+ /** Proceed (skip the companion) is possible: all findings settled but none answered. */
52
+ export function canProceed(review: RequirementReview): boolean {
53
+ const { open, answered } = tally(review)
54
+ return open === 0 && answered === 0
55
+ }
@@ -1,11 +1,21 @@
1
1
  import { defineStore } from 'pinia'
2
- import { ref } from 'vue'
2
+ import { computed, ref } from 'vue'
3
3
  import type {
4
4
  RequirementReview,
5
5
  ResolveRequirementsExceededChoice,
6
6
  ReviewItemStatus,
7
7
  } from '~/types/requirements'
8
8
  import { useWorkspaceStore } from '~/stores/workspace'
9
+ // The settlement derivations over one review's findings (`stores/requirements/settlement.ts`):
10
+ // pure, memoised per review object, and re-exported below so every caller keeps reading them off
11
+ // the store.
12
+ import {
13
+ allSettled,
14
+ answeredCount,
15
+ canIncorporate,
16
+ canProceed,
17
+ openCount,
18
+ } from '~/stores/requirements/settlement'
9
19
  import { createRecommendationCommands } from '~/stores/requirements/recommendations'
10
20
 
11
21
  /**
@@ -48,8 +58,21 @@ export const useRequirementsStore = defineStore('requirements', () => {
48
58
  /** Whether the Requirement Writer is still producing recommendations for a block (a `pending`
49
59
  * placeholder exists). Server-derived, so the "Recommending…" state survives the window closing
50
60
  * and a page reload — the client-local `recommending` set only covers the request round-trip. */
61
+ /**
62
+ * Blocks whose stored review still carries a `pending` recommendation placeholder, derived once
63
+ * per change to `reviews` rather than per call. {@link backgroundStage} asks this on the per-CARD
64
+ * path, so as a function it re-scanned one review's recommendation list for every card on the
65
+ * board on every event.
66
+ */
67
+ const blocksAwaitingRecommendations = computed(() => {
68
+ const blocks = new Set<string>()
69
+ for (const [blockId, review] of Object.entries(reviews.value)) {
70
+ if ((review?.recommendations ?? []).some((r) => r.status === 'pending')) blocks.add(blockId)
71
+ }
72
+ return blocks
73
+ })
51
74
  function hasPendingRecommendations(blockId: string): boolean {
52
- return (reviews.value[blockId]?.recommendations ?? []).some((r) => r.status === 'pending')
75
+ return blocksAwaitingRecommendations.value.has(blockId)
53
76
  }
54
77
  /**
55
78
  * The async background stage a block's review is in, or null. While the driver folds the
@@ -73,27 +96,6 @@ export const useRequirementsStore = defineStore('requirements', () => {
73
96
  return incorporating.value.has(reviewId)
74
97
  }
75
98
 
76
- /** Findings still needing a human (status `open`). */
77
- function openCount(review: RequirementReview): number {
78
- return review.items.filter((i) => i.status === 'open').length
79
- }
80
- /** Findings the human answered (a reply recorded), which the companion folds in. */
81
- function answeredCount(review: RequirementReview): number {
82
- return review.items.filter((i) => i.status === 'answered' || i.status === 'resolved').length
83
- }
84
- /** Every finding is settled (answered or dismissed) — none still open. */
85
- function allSettled(review: RequirementReview): boolean {
86
- return openCount(review) === 0
87
- }
88
- /** Incorporation is possible: all findings settled AND at least one was answered. */
89
- function canIncorporate(review: RequirementReview): boolean {
90
- return allSettled(review) && answeredCount(review) > 0
91
- }
92
- /** Proceed (skip the companion) is possible: all findings settled but none answered. */
93
- function canProceed(review: RequirementReview): boolean {
94
- return allSettled(review) && answeredCount(review) === 0
95
- }
96
-
97
99
  function store(review: RequirementReview) {
98
100
  reviews.value = { ...reviews.value, [review.blockId]: review }
99
101
  }
@@ -11,6 +11,8 @@ import { useExecutionStore } from '~/stores/execution'
11
11
  import { useFragmentsStore } from '~/stores/fragments'
12
12
  import { useGitHubStore } from '~/stores/github'
13
13
  import { useInitiativesStore } from '~/stores/initiative'
14
+ import { useKaizenStore } from '~/stores/kaizen'
15
+ import { useObservabilityStore } from '~/stores/observability'
14
16
  import { useModelPresetsStore } from '~/stores/modelPresets'
15
17
  import { useConsensusGroupsStore } from '~/stores/consensusGroups'
16
18
  import { useNotificationsStore } from '~/stores/notifications'
@@ -43,6 +45,15 @@ export function resetPerBoardCaches() {
43
45
  useGitHubStore().reset()
44
46
  useInitiativesStore().reset()
45
47
  useDocInterviewStore().reset()
48
+ // The per-RUN observability + Kaizen caches. An execution id belongs to the board that owns it
49
+ // and neither store is part of the snapshot, so nothing else ever evicted a key: a session that
50
+ // visited several boards kept every run it had ever opened a panel on.
51
+ useObservabilityStore().reset()
52
+ useKaizenStore().reset()
53
+ // The whole-run reads behind the step-detail overlays. The runs themselves ride the snapshot
54
+ // (`hydrate` replaces them), but the pending/failed marks and the requests still in flight are
55
+ // keyed by run ids the switched-to board does not have.
56
+ useExecutionStore().resetFullReads()
46
57
  // The fragment picker catalog is per-board (the merged tenant catalog), so drop
47
58
  // it too — the next inspector open re-fetches it for the switched-to board rather
48
59
  // than showing the previous board's (or a raw-id placeholder for) fragments.
@@ -176,8 +176,21 @@ describe('refresh funnel', () => {
176
176
  * the funnel holding a fetch that never settles and every later caller queued behind it forever.
177
177
  */
178
178
  describe('deadline', () => {
179
+ /**
180
+ * The deadline is per FUNNEL, so a test that both times a fetch out AND then drives a second
181
+ * one to completion needs a value that satisfies both halves. The first half is satisfied by
182
+ * any value (its fetch never settles, so the timer always wins in the end, it just waits that
183
+ * long); the second is satisfied only while every turn AFTER the timeout fits inside the same
184
+ * budget. Sized at 5ms that budget was two macrotask turns plus assertions, which a loaded CI
185
+ * runner overruns: the recovery fetch timed out instead, the test failed on its own deadline
186
+ * and the abandoned promise surfaced as an unhandled rejection. So this is deliberately
187
+ * generous and must NOT be tightened for speed: it costs one wait, and what it buys is a
188
+ * timing assumption the runner cannot break.
189
+ */
190
+ const GENEROUS_DEADLINE_MS = 250
191
+
179
192
  it('fails the caller, aborts the request and frees the slot when a fetch never settles', async () => {
180
- const h = harness('ws1', 5)
193
+ const h = harness('ws1', GENEROUS_DEADLINE_MS)
181
194
  const stalled = expect(h.funnel.refresh()).rejects.toThrow(/timed out/)
182
195
  await stalled
183
196
  expect(h.aborted()).toBe(true)
@@ -193,6 +206,7 @@ describe('refresh funnel', () => {
193
206
  expect(h.applied()).toEqual(['recovered'])
194
207
  })
195
208
 
209
+ // Nothing here outlives the timeout, so this one can stay fast.
196
210
  it('does not count a timed-out fetch as coverage', async () => {
197
211
  const h = harness('ws1', 5)
198
212
  const mark = h.funnel.refreshMark()
@@ -2291,6 +2291,10 @@
2291
2291
  "dryRun": "Probelauf: nichts zusammenführen",
2292
2292
  "dryRunForced": "Probelauf: Läufe deiner Rolle werden hier nie zusammengeführt",
2293
2293
  "dryRunHint": "Dieser Lauf öffnet einen Pull Request und führt nichts zusammen."
2294
+ },
2295
+ "runDetail": {
2296
+ "loading": "Vollständiger Lauf wird geladen…",
2297
+ "loadFailed": "Vollständiger Lauf konnte nicht geladen werden: {reason}"
2294
2298
  }
2295
2299
  },
2296
2300
  "layout": {
@@ -1809,6 +1809,10 @@
1809
1809
  "dryRun": "Dry run: merge nothing",
1810
1810
  "dryRunForced": "Dry run: your role's runs never merge here",
1811
1811
  "dryRunHint": "This run opens a pull request and merges nothing."
1812
+ },
1813
+ "runDetail": {
1814
+ "loading": "Loading the full run…",
1815
+ "loadFailed": "Couldn't load the full run: {reason}"
1812
1816
  }
1813
1817
  },
1814
1818
  "observability": {
@@ -1705,6 +1705,10 @@
1705
1705
  "dryRun": "Ejecución de prueba: no fusionar nada",
1706
1706
  "dryRunForced": "Ejecución de prueba: las ejecuciones de tu rol nunca se fusionan aquí",
1707
1707
  "dryRunHint": "Esta ejecución abre una pull request y no fusiona nada."
1708
+ },
1709
+ "runDetail": {
1710
+ "loading": "Cargando la ejecución completa…",
1711
+ "loadFailed": "No se pudo cargar la ejecución completa: {reason}"
1708
1712
  }
1709
1713
  },
1710
1714
  "observability": {
@@ -1705,6 +1705,10 @@
1705
1705
  "dryRun": "Exécution à blanc : ne rien fusionner",
1706
1706
  "dryRunForced": "Exécution à blanc : les exécutions de votre rôle ne sont jamais fusionnées ici",
1707
1707
  "dryRunHint": "Cette exécution ouvre une pull request et ne fusionne rien."
1708
+ },
1709
+ "runDetail": {
1710
+ "loading": "Chargement de l'exécution complète…",
1711
+ "loadFailed": "Impossible de charger l'exécution complète : {reason}"
1708
1712
  }
1709
1713
  },
1710
1714
  "observability": {
@@ -1705,6 +1705,10 @@
1705
1705
  "dryRun": "הרצת יבש: לא למזג דבר",
1706
1706
  "dryRunForced": "הרצת יבש: הרצות של התפקיד שלך לעולם אינן ממוזגות כאן",
1707
1707
  "dryRunHint": "ההרצה הזו פותחת בקשת משיכה ואינה ממזגת דבר."
1708
+ },
1709
+ "runDetail": {
1710
+ "loading": "טוען את ההרצה המלאה…",
1711
+ "loadFailed": "לא ניתן לטעון את ההרצה המלאה: {reason}"
1708
1712
  }
1709
1713
  },
1710
1714
  "observability": {
@@ -2291,6 +2291,10 @@
2291
2291
  "dryRun": "Prova: non unire nulla",
2292
2292
  "dryRunForced": "Prova: le esecuzioni del tuo ruolo qui non vengono mai unite",
2293
2293
  "dryRunHint": "Questa esecuzione apre una pull request e non unisce nulla."
2294
+ },
2295
+ "runDetail": {
2296
+ "loading": "Caricamento dell'esecuzione completa…",
2297
+ "loadFailed": "Impossibile caricare l'esecuzione completa: {reason}"
2294
2298
  }
2295
2299
  },
2296
2300
  "layout": {
@@ -1705,6 +1705,10 @@
1705
1705
  "dryRun": "ドライラン: マージしない",
1706
1706
  "dryRunForced": "ドライラン: あなたのロールの実行はここではマージされません",
1707
1707
  "dryRunHint": "この実行はプルリクエストを開きますが、マージは行いません。"
1708
+ },
1709
+ "runDetail": {
1710
+ "loading": "実行の全体を読み込んでいます…",
1711
+ "loadFailed": "実行の全体を読み込めませんでした: {reason}"
1708
1712
  }
1709
1713
  },
1710
1714
  "observability": {
@@ -1705,6 +1705,10 @@
1705
1705
  "dryRun": "Uruchomienie próbne: nic nie scalaj",
1706
1706
  "dryRunForced": "Uruchomienie próbne: uruchomienia twojej roli nigdy nie są tu scalane",
1707
1707
  "dryRunHint": "To uruchomienie otwiera pull request i niczego nie scala."
1708
+ },
1709
+ "runDetail": {
1710
+ "loading": "Wczytywanie pełnego przebiegu…",
1711
+ "loadFailed": "Nie udało się wczytać pełnego przebiegu: {reason}"
1708
1712
  }
1709
1713
  },
1710
1714
  "observability": {
@@ -1705,6 +1705,10 @@
1705
1705
  "dryRun": "Prova çalışması: hiçbir şeyi birleştirme",
1706
1706
  "dryRunForced": "Prova çalışması: rolünün başlattığı çalıştırmalar burada asla birleştirilmez",
1707
1707
  "dryRunHint": "Bu çalıştırma bir pull request açar ve hiçbir şeyi birleştirmez."
1708
+ },
1709
+ "runDetail": {
1710
+ "loading": "Çalıştırmanın tamamı yükleniyor…",
1711
+ "loadFailed": "Çalıştırmanın tamamı yüklenemedi: {reason}"
1708
1712
  }
1709
1713
  },
1710
1714
  "observability": {
@@ -1705,6 +1705,10 @@
1705
1705
  "dryRun": "Пробний запуск: нічого не зливати",
1706
1706
  "dryRunForced": "Пробний запуск: запуски твоєї ролі тут ніколи не зливаються",
1707
1707
  "dryRunHint": "Цей запуск відкриває pull request і нічого не зливає."
1708
+ },
1709
+ "runDetail": {
1710
+ "loading": "Завантаження повного запуску…",
1711
+ "loadFailed": "Не вдалося завантажити повний запуск: {reason}"
1708
1712
  }
1709
1713
  },
1710
1714
  "observability": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.283.0",
3
+ "version": "0.284.0",
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.41",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.325.0"
43
+ "@cat-factory/contracts": "0.326.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",