@cat-factory/app 0.115.1 → 0.115.2

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.
@@ -22,12 +22,23 @@ const props = withDefaults(
22
22
  const { t } = useI18n()
23
23
  const agentRuns = useAgentRunsStore()
24
24
  const toast = useToast()
25
+ const { confirm } = useConfirm()
25
26
  const stopping = ref(false)
26
27
 
27
28
  const displayLabel = computed(() => props.label ?? t('board.stop.label'))
28
29
 
29
30
  async function stop() {
30
31
  if (stopping.value) return
32
+ // Killing a running container discards its in-flight work — gate it behind a confirm,
33
+ // matching the confirm-then-mutate contract the task reset path uses.
34
+ const ok = await confirm({
35
+ title: t('board.stop.confirm.title'),
36
+ description: t('board.stop.confirm.body'),
37
+ confirmLabel: t('board.stop.confirm.confirm'),
38
+ variant: 'destructive',
39
+ icon: 'i-lucide-circle-stop',
40
+ })
41
+ if (!ok) return
31
42
  stopping.value = true
32
43
  try {
33
44
  const kind = await agentRuns.stop(props.runId)
@@ -100,6 +100,21 @@ const statusLabel = computed(() =>
100
100
 
101
101
  const runnable = computed(() => (block.value ? board.isRunnable(block.value.id) : false))
102
102
 
103
+ // A task runs only once every dependency has merged. When the Run trigger is locked
104
+ // it must say WHY — name the unfinished dependencies rather than showing a bare lock.
105
+ const unmetDepTitles = computed(() =>
106
+ block.value && isTask.value ? board.unmetDeps(block.value.id).map((b) => b.title) : [],
107
+ )
108
+ const runBlockedReason = computed(() =>
109
+ unmetDepTitles.value.length
110
+ ? t(
111
+ 'panels.inspector.runBlocked',
112
+ { count: unmetDepTitles.value.length, names: unmetDepTitles.value.join(', ') },
113
+ unmetDepTitles.value.length,
114
+ )
115
+ : null,
116
+ )
117
+
103
118
  // The delete control names what it removes, so selecting a task and deleting it
104
119
  // reads as "Delete task" rather than ambiguously removing the whole service.
105
120
  const deleteLabel = computed(() =>
@@ -532,6 +547,19 @@ const showOriginalDescription = ref(false)
532
547
  <!-- initiative: status + goal, run-planning + tracker controls -->
533
548
  <InitiativeInspector v-else-if="isInitiative" :block="block" />
534
549
 
550
+ <!-- Locked-run explanation: a disabled task Run button reads as a dead lock unless
551
+ it says what's holding it. Named here (and on the button title) so the blocking
552
+ dependencies are visible to pointer, keyboard, and touch alike — a native title
553
+ on a disabled button doesn't fire hover events. -->
554
+ <p
555
+ v-if="isTask && runBlockedReason"
556
+ class="flex items-start gap-1.5 text-[11px] text-amber-300/90"
557
+ data-testid="run-blocked-reason"
558
+ >
559
+ <UIcon name="i-lucide-lock" class="mt-px h-3 w-3 shrink-0" />
560
+ <span>{{ runBlockedReason }}</span>
561
+ </p>
562
+
535
563
  <!-- actions -->
536
564
  <div class="flex items-center gap-2">
537
565
  <UDropdownMenu v-if="isTask" :items="runMenu">
@@ -542,6 +570,7 @@ const showOriginalDescription = ref(false)
542
570
  :icon="runnable ? 'i-lucide-play' : 'i-lucide-lock'"
543
571
  trailing-icon="i-lucide-chevron-down"
544
572
  :disabled="!runnable"
573
+ :title="runBlockedReason ?? undefined"
545
574
  >
546
575
  {{ instance ? t('panels.inspector.reRun') : t('panels.inspector.run') }}
547
576
  </UButton>
@@ -11,6 +11,8 @@ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
11
11
  import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
12
12
  import EmptyState from '~/components/common/EmptyState.vue'
13
13
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
14
+ import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
15
+ import type { PipelineStep } from '~/types/execution'
14
16
 
15
17
  const props = defineProps<{ block: Block }>()
16
18
 
@@ -85,6 +87,13 @@ function stepFailed(s: { state: string }) {
85
87
  return runFailed.value && s.state === 'working'
86
88
  }
87
89
 
90
+ // A shared 1s tick drives every step's live elapsed clock, so a running step that
91
+ // hasn't yet emitted subtask counts reads as progressing rather than hung.
92
+ const nowTick = useNowTick()
93
+ function stepElapsed(s: PipelineStep): string | null {
94
+ return stepDurationLabel(s, nowTick.value, runFailed.value, instance.value?.failure?.occurredAt)
95
+ }
96
+
88
97
  /** A gated step parked for approval reads "Needs approval", not "Needs decision". */
89
98
  function labelForStep(s: {
90
99
  state: string
@@ -148,6 +157,17 @@ function openForkFor(i: number) {
148
157
  const stopping = ref(false)
149
158
  async function stopRun() {
150
159
  if (!instance.value || stopping.value) return
160
+ // Killing the running container discards its in-flight work — gate it behind the same
161
+ // confirm the board card's stop uses (via `AgentStopButton`), so every stop surface for
162
+ // a run is confirm-gated identically.
163
+ const ok = await confirm({
164
+ title: t('board.stop.confirm.title'),
165
+ description: t('board.stop.confirm.body'),
166
+ confirmLabel: t('board.stop.confirm.confirm'),
167
+ variant: 'destructive',
168
+ icon: 'i-lucide-circle-stop',
169
+ })
170
+ if (!ok) return
151
171
  stopping.value = true
152
172
  try {
153
173
  await execution.stop(instance.value.id)
@@ -306,6 +326,14 @@ async function mergePr() {
306
326
  >
307
327
  <UIcon v-if="stepFailed(s)" name="i-lucide-circle-x" class="h-3 w-3 shrink-0" />
308
328
  {{ labelForStep(s) }}
329
+ <!-- live elapsed clock: a running step counts up, a finished one shows total -->
330
+ <span
331
+ v-if="stepElapsed(s)"
332
+ class="inline-flex items-center gap-0.5 font-mono tabular-nums text-slate-500"
333
+ :title="t('inspector.execution.elapsedTooltip')"
334
+ >
335
+ · {{ stepElapsed(s) }}
336
+ </span>
309
337
  </span>
310
338
  <UButton
311
339
  v-if="s.decision && !s.decision.chosen"
@@ -12,6 +12,7 @@ import {
12
12
  containerPhaseLabel,
13
13
  } from '~/utils/pipelineRender'
14
14
  import StepMetricsBar from '~/components/observability/StepMetricsBar.vue'
15
+ import { useNowTick, stepDurationLabel } from '~/composables/useStepTimer'
15
16
 
16
17
  const props = defineProps<{ instance: ExecutionInstance }>()
17
18
  const emit = defineEmits<{
@@ -136,6 +137,13 @@ const STATUS_META = computed<Record<ExecutionInstance['status'], { label: string
136
137
  const steps = computed(() => props.instance.steps)
137
138
  const total = computed(() => steps.value.length)
138
139
 
140
+ // A shared 1s tick drives every step's live elapsed clock, so a step that hasn't yet
141
+ // emitted subtask counts still shows it is progressing rather than reading as hung.
142
+ const nowTick = useNowTick()
143
+ function stepElapsed(s: PipelineStep): string | null {
144
+ return stepDurationLabel(s, nowTick.value, runFailed.value, props.instance.failure?.occurredAt)
145
+ }
146
+
139
147
  // The conditionally-run companion (e.g. the Tester's `fixer`) each step drives, with
140
148
  // its possible/running/completed/skipped state — rendered as a distinct sub-node so a
141
149
  // human can see at a glance whether the fixer ran or was skipped.
@@ -322,8 +330,20 @@ const ITEM_ICON: Record<string, string> = {
322
330
  {{ t('pipeline.progress.companion') }}
323
331
  </span>
324
332
  </div>
325
- <div class="text-[10px] uppercase tracking-wide text-slate-500">
326
- {{ t('pipeline.progress.stepOf', { current: i + 1, total }) }}
333
+ <div
334
+ class="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-slate-500"
335
+ >
336
+ <span>{{ t('pipeline.progress.stepOf', { current: i + 1, total }) }}</span>
337
+ <!-- live elapsed clock: a running step counts up (so no-subtask steps
338
+ don't read as hung), a finished step shows its total duration -->
339
+ <span
340
+ v-if="stepElapsed(s)"
341
+ class="inline-flex items-center gap-0.5 font-mono normal-case tabular-nums text-slate-400"
342
+ :title="t('pipeline.progress.elapsedTooltip')"
343
+ >
344
+ <UIcon name="i-lucide-clock" class="h-2.5 w-2.5 shrink-0" />
345
+ {{ stepElapsed(s) }}
346
+ </span>
327
347
  </div>
328
348
  </div>
329
349
  <span
@@ -343,7 +363,7 @@ const ITEM_ICON: Record<string, string> = {
343
363
  color="neutral"
344
364
  variant="ghost"
345
365
  size="xs"
346
- class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
366
+ class="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
347
367
  :title="t('pipeline.progress.restartTooltip')"
348
368
  @click.stop="
349
369
  () => {
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { PipelineStep } from '~/types/execution'
3
+ import { stepDurationLabel, stepDurationMs, stepIsRunning } from '~/composables/useStepTimer'
4
+
5
+ // The pure helpers encode one freeze rule shared by the list surfaces (pipeline timeline,
6
+ // inspector run list) and the single-step overlay, so pin the precedence here:
7
+ // finishedAt > (runFailed ? failureAt ?? startedAt) > pausedAt > live now.
8
+ function step(overrides: Partial<PipelineStep> = {}): PipelineStep {
9
+ return { agentKind: 'coder', state: 'working', ...overrides } as PipelineStep
10
+ }
11
+
12
+ const NOW = 10_000
13
+
14
+ describe('stepIsRunning', () => {
15
+ it('is false for a null step', () => {
16
+ expect(stepIsRunning(null, false)).toBe(false)
17
+ })
18
+
19
+ it('is false until the step has started', () => {
20
+ expect(stepIsRunning(step({ startedAt: undefined }), false)).toBe(false)
21
+ })
22
+
23
+ it('is true for a started, unfinished, unparked step on a live run', () => {
24
+ expect(stepIsRunning(step({ startedAt: 1000 }), false)).toBe(true)
25
+ })
26
+
27
+ it('is false once finished, parked, or the run failed', () => {
28
+ expect(stepIsRunning(step({ startedAt: 1000, finishedAt: 2000 }), false)).toBe(false)
29
+ expect(stepIsRunning(step({ startedAt: 1000, pausedAt: 1500 }), false)).toBe(false)
30
+ expect(stepIsRunning(step({ startedAt: 1000 }), true)).toBe(false)
31
+ })
32
+ })
33
+
34
+ describe('stepDurationMs', () => {
35
+ it('is null until the step has started', () => {
36
+ expect(stepDurationMs(step({ startedAt: undefined }), NOW, false, null)).toBeNull()
37
+ expect(stepDurationMs(null, NOW, false, null)).toBeNull()
38
+ })
39
+
40
+ it('counts up to now while live', () => {
41
+ expect(stepDurationMs(step({ startedAt: 4000 }), NOW, false, null)).toBe(6000)
42
+ })
43
+
44
+ it('freezes at finishedAt once finished (ignoring now)', () => {
45
+ expect(stepDurationMs(step({ startedAt: 4000, finishedAt: 7000 }), NOW, false, null)).toBe(3000)
46
+ })
47
+
48
+ it('freezes at the run failure time when the run failed', () => {
49
+ expect(stepDurationMs(step({ startedAt: 4000 }), NOW, true, 6000)).toBe(2000)
50
+ })
51
+
52
+ it('falls back to startedAt (zero) when the run failed with no failure time', () => {
53
+ expect(stepDurationMs(step({ startedAt: 4000 }), NOW, true, null)).toBe(0)
54
+ })
55
+
56
+ it('freezes at the park time when parked on a human', () => {
57
+ expect(stepDurationMs(step({ startedAt: 4000, pausedAt: 5500 }), NOW, false, null)).toBe(1500)
58
+ })
59
+
60
+ it('prefers finishedAt over the failure time', () => {
61
+ expect(stepDurationMs(step({ startedAt: 4000, finishedAt: 5000 }), NOW, true, 6000)).toBe(1000)
62
+ })
63
+
64
+ it('never returns a negative duration', () => {
65
+ expect(stepDurationMs(step({ startedAt: 8000 }), NOW, true, 6000)).toBe(0)
66
+ })
67
+ })
68
+
69
+ describe('stepDurationLabel', () => {
70
+ it('is null until the step has started', () => {
71
+ expect(stepDurationLabel(step({ startedAt: undefined }), NOW, false, null)).toBeNull()
72
+ })
73
+
74
+ it('formats seconds and minutes', () => {
75
+ expect(stepDurationLabel(step({ startedAt: 4000 }), NOW, false, null)).toBe('6s')
76
+ expect(stepDurationLabel(step({ startedAt: 0, finishedAt: 90_000 }), NOW, false, null)).toBe(
77
+ '1m 30s',
78
+ )
79
+ })
80
+ })
@@ -1,6 +1,63 @@
1
1
  import { ref, computed, onMounted, onUnmounted } from 'vue'
2
2
  import type { PipelineStep } from '~/types/execution'
3
3
 
4
+ /**
5
+ * Whether a step is actively executing: it has started, hasn't finished, isn't
6
+ * parked on a human (`pausedAt`), and the run itself hasn't failed. A step in any
7
+ * of those states is not ticking — no spinner, no counting-up clock.
8
+ */
9
+ export function stepIsRunning(step: PipelineStep | null, runFailed: boolean): boolean {
10
+ return !!step?.startedAt && !step?.finishedAt && step?.pausedAt == null && !runFailed
11
+ }
12
+
13
+ /**
14
+ * Elapsed/total execution time in ms for a step at wall-clock `nowMs`, or null until
15
+ * the step has started. The clock freezes once the step stops working: at its finish,
16
+ * else at the run's failure time once the run has failed, else at the moment it parked
17
+ * on a human (`pausedAt`). Otherwise it is live, counting up to `nowMs`.
18
+ */
19
+ export function stepDurationMs(
20
+ step: PipelineStep | null,
21
+ nowMs: number,
22
+ runFailed: boolean,
23
+ failureAt: number | null | undefined,
24
+ ): number | null {
25
+ if (step?.startedAt == null) return null
26
+ const end =
27
+ step.finishedAt ?? (runFailed ? (failureAt ?? step.startedAt) : (step.pausedAt ?? nowMs))
28
+ return Math.max(0, end - step.startedAt)
29
+ }
30
+
31
+ /** Human-friendly elapsed label for a step at `nowMs`, or null until it has started. */
32
+ export function stepDurationLabel(
33
+ step: PipelineStep | null,
34
+ nowMs: number,
35
+ runFailed: boolean,
36
+ failureAt: number | null | undefined,
37
+ ): string | null {
38
+ const ms = stepDurationMs(step, nowMs, runFailed, failureAt)
39
+ return ms == null ? null : formatDuration(ms)
40
+ }
41
+
42
+ /**
43
+ * A shared 1s wall-clock tick for surfaces that render many steps' live durations
44
+ * at once (the pipeline timeline, the inspector run list). One interval drives every
45
+ * step's elapsed label instead of a per-step timer. Stays `0` until mounted so the
46
+ * first paint never reads a stale time.
47
+ */
48
+ export function useNowTick(intervalMs = 1000) {
49
+ const now = ref(0)
50
+ let timer: ReturnType<typeof setInterval> | undefined
51
+ onMounted(() => {
52
+ now.value = Date.now()
53
+ timer = setInterval(() => (now.value = Date.now()), intervalMs)
54
+ })
55
+ onUnmounted(() => {
56
+ if (timer) clearInterval(timer)
57
+ })
58
+ return now
59
+ }
60
+
4
61
  /**
5
62
  * Live elapsed-time clock for a single pipeline step. A 1s tick drives the
6
63
  * counting-up duration while the step is actively running; the clock freezes at
@@ -12,37 +69,16 @@ export function useStepTimer(opts: {
12
69
  runFailed: () => boolean
13
70
  failureAt: () => number | null | undefined
14
71
  }) {
15
- // A 1s tick so a still-running step's elapsed time counts up live while open.
16
- const nowTick = ref(0)
17
- let timer: ReturnType<typeof setInterval> | undefined
18
- onMounted(() => {
19
- nowTick.value = Date.now()
20
- timer = setInterval(() => (nowTick.value = Date.now()), 1000)
21
- })
22
- onUnmounted(() => {
23
- if (timer) clearInterval(timer)
24
- })
72
+ const nowTick = useNowTick()
25
73
 
26
74
  // A step that is finished, failed, or parked on a human is not actively
27
- // executing — no ticking clock or spinner. `pausedAt` is the "waiting on input"
28
- // freeze.
29
- const isRunning = computed(() => {
30
- const s = opts.step()
31
- return !!s?.startedAt && !s?.finishedAt && s?.pausedAt == null && !opts.runFailed()
32
- })
75
+ // executing — no ticking clock or spinner. `pausedAt` is the "waiting on input" freeze.
76
+ const isRunning = computed(() => stepIsRunning(opts.step(), opts.runFailed()))
33
77
 
34
78
  /** Elapsed/total execution time in ms — null until the step has started. */
35
- const durationMs = computed(() => {
36
- const s = opts.step()
37
- if (s?.startedAt == null) return null
38
- // Freeze the clock once the step stops working: at its finish, else at the
39
- // failure time once the run has failed, else at the moment it parked on a
40
- // human (`pausedAt`). Otherwise it is live, so count up to the current tick.
41
- const end =
42
- s.finishedAt ??
43
- (opts.runFailed() ? (opts.failureAt() ?? s.startedAt) : (s.pausedAt ?? nowTick.value))
44
- return Math.max(0, end - s.startedAt)
45
- })
79
+ const durationMs = computed(() =>
80
+ stepDurationMs(opts.step(), nowTick.value, opts.runFailed(), opts.failureAt()),
81
+ )
46
82
 
47
83
  const durationLabel = computed(() =>
48
84
  durationMs.value == null ? null : formatDuration(durationMs.value),
@@ -51,7 +87,7 @@ export function useStepTimer(opts: {
51
87
  return { isRunning, durationMs, durationLabel }
52
88
  }
53
89
 
54
- function formatDuration(ms: number): string {
90
+ export function formatDuration(ms: number): string {
55
91
  const totalSec = Math.round(ms / 1000)
56
92
  if (totalSec < 60) return `${totalSec}s`
57
93
  const m = Math.floor(totalSec / 60)
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useConsensusStore } from '~/stores/consensus'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { ConsensusSession } from '~/types/consensus'
5
+
6
+ /** Minimal session factory — only the fields the store reconciles/reads. */
7
+ function session(over: Partial<ConsensusSession> = {}): ConsensusSession {
8
+ return {
9
+ id: 's1',
10
+ blockId: 'blk1',
11
+ executionId: null,
12
+ stepIndex: 0,
13
+ agentKind: 'architect',
14
+ strategy: 'panel',
15
+ status: 'complete',
16
+ participants: [],
17
+ rounds: [],
18
+ synthesis: null,
19
+ createdAt: 1,
20
+ updatedAt: 1,
21
+ ...over,
22
+ } as ConsensusSession
23
+ }
24
+
25
+ describe('consensus store — load vs live-push reconcile', () => {
26
+ beforeEach(() => {
27
+ useWorkspaceStore().workspaceId = 'ws1'
28
+ })
29
+
30
+ it('load stores the fetched session', async () => {
31
+ vi.stubGlobal('useApi', () => ({
32
+ getConsensusSession: () => Promise.resolve({ session: session() }),
33
+ }))
34
+ const store = useConsensusStore()
35
+ await store.load('blk1')
36
+ expect(store.sessionFor('blk1')?.id).toBe('s1')
37
+ })
38
+
39
+ it('a stale load never regresses a fresher live-pushed session', async () => {
40
+ // A live `consensus` push delivers the newest transcript; a `load` that started earlier
41
+ // resolves later with a staler snapshot. It must NOT overwrite the fresher one.
42
+ let resolveFetch!: (r: { session: ConsensusSession | null }) => void
43
+ const pending = new Promise<{ session: ConsensusSession | null }>((res) => {
44
+ resolveFetch = res
45
+ })
46
+ vi.stubGlobal('useApi', () => ({ getConsensusSession: () => pending }))
47
+ const store = useConsensusStore()
48
+
49
+ const load = store.load('blk1')
50
+ store.upsert(session({ updatedAt: 10, synthesis: 'fresh' }))
51
+ resolveFetch({ session: session({ updatedAt: 2, synthesis: 'stale' }) })
52
+ await load
53
+
54
+ expect(store.sessionFor('blk1')?.synthesis).toBe('fresh')
55
+ })
56
+
57
+ it('a load returning "none" never clobbers an existing live session', async () => {
58
+ vi.stubGlobal('useApi', () => ({
59
+ getConsensusSession: () => Promise.resolve({ session: null }),
60
+ }))
61
+ const store = useConsensusStore()
62
+ store.upsert(session({ synthesis: 'live' }))
63
+ await store.load('blk1')
64
+ expect(store.sessionFor('blk1')?.synthesis).toBe('live')
65
+ })
66
+
67
+ it('a load returning "none" records the fetched-empty state when nothing is cached', async () => {
68
+ vi.stubGlobal('useApi', () => ({
69
+ getConsensusSession: () => Promise.resolve({ session: null }),
70
+ }))
71
+ const store = useConsensusStore()
72
+ await store.load('blk1')
73
+ expect('blk1' in store.sessions).toBe(true)
74
+ expect(store.sessionFor('blk1')).toBeNull()
75
+ })
76
+ })
@@ -46,7 +46,17 @@ export const useConsensusStore = defineStore('consensus', () => {
46
46
  loading.value = new Set(loading.value).add(blockId)
47
47
  try {
48
48
  const { session } = await api.getConsensusSession(wsId, blockId)
49
- sessions.value = { ...sessions.value, [blockId]: session }
49
+ // Reconcile rather than blind-replace: a `load` resolving AFTER a fresher live
50
+ // `consensus` push (or after a newer concurrent load) must not regress the transcript —
51
+ // the out-of-order-overwrite hazard the CLAUDE.md live-push rules warn about. Keep
52
+ // whichever session is newer by `updatedAt` (any id), and never overwrite an existing
53
+ // (possibly live-pushed) session with a raced "none".
54
+ const existing = sessions.value[blockId]
55
+ if (session) {
56
+ if (!existing || session.updatedAt >= existing.updatedAt) store(session)
57
+ } else if (existing === undefined) {
58
+ sessions.value = { ...sessions.value, [blockId]: null }
59
+ }
50
60
  } catch {
51
61
  // Consensus off / no session — leave the cache as-is; the window shows its empty state.
52
62
  } finally {
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useDocInterviewStore } from '~/stores/docInterview'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { DocInterviewSession } from '~/types/domain'
5
+
6
+ /** Minimal session factory — only the fields the store reconciles/reads. */
7
+ function session(over: Partial<DocInterviewSession> = {}): DocInterviewSession {
8
+ return {
9
+ id: 'd1',
10
+ blockId: 'blk1',
11
+ status: 'awaiting_answers',
12
+ round: 1,
13
+ maxRounds: 3,
14
+ qa: [],
15
+ brief: null,
16
+ model: null,
17
+ createdAt: 1,
18
+ updatedAt: 1,
19
+ ...over,
20
+ } as DocInterviewSession
21
+ }
22
+
23
+ // docInterview already routes its `load` through `upsert`'s newest-wins (`updatedAt`) guard —
24
+ // these specs pin that so a future refactor can't reintroduce a blind-replace clobber.
25
+ describe('docInterview store — load vs live-push reconcile', () => {
26
+ beforeEach(() => {
27
+ useWorkspaceStore().workspaceId = 'ws1'
28
+ })
29
+
30
+ it('load stores the fetched session', async () => {
31
+ vi.stubGlobal('useApi', () => ({
32
+ getDocInterview: () => Promise.resolve(session()),
33
+ }))
34
+ const store = useDocInterviewStore()
35
+ await store.load('blk1')
36
+ expect(store.forBlock('blk1')?.id).toBe('d1')
37
+ })
38
+
39
+ it('a stale load never regresses a fresher live-pushed session', async () => {
40
+ let resolveFetch!: (r: DocInterviewSession | null) => void
41
+ const pending = new Promise<DocInterviewSession | null>((res) => {
42
+ resolveFetch = res
43
+ })
44
+ vi.stubGlobal('useApi', () => ({ getDocInterview: () => pending }))
45
+ const store = useDocInterviewStore()
46
+
47
+ const load = store.load('blk1')
48
+ store.upsert(session({ updatedAt: 10, round: 2 }))
49
+ resolveFetch(session({ updatedAt: 2, round: 1 }))
50
+ await load
51
+
52
+ expect(store.forBlock('blk1')?.round).toBe(2)
53
+ })
54
+
55
+ it('a load returning "none" leaves the cache untouched', async () => {
56
+ vi.stubGlobal('useApi', () => ({
57
+ getDocInterview: () => Promise.resolve(null),
58
+ }))
59
+ const store = useDocInterviewStore()
60
+ store.upsert(session({ round: 2 }))
61
+ await store.load('blk1')
62
+ expect(store.forBlock('blk1')?.round).toBe(2)
63
+ })
64
+ })
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useKaizenStore } from '~/stores/kaizen'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { KaizenGrading } from '~/types/domain'
5
+
6
+ /** Minimal grading factory — only the fields the store reconciles/reads. */
7
+ function grading(over: Partial<KaizenGrading> = {}): KaizenGrading {
8
+ return {
9
+ id: 'g1',
10
+ executionId: 'exec1',
11
+ blockId: 'blk1',
12
+ stepIndex: 0,
13
+ agentKind: 'coder',
14
+ model: 'm',
15
+ promptVersion: 1,
16
+ comboKey: 'coder|m|1',
17
+ status: 'complete',
18
+ grade: 5,
19
+ summary: '',
20
+ recommendations: [],
21
+ graderModel: null,
22
+ error: null,
23
+ createdAt: 1,
24
+ updatedAt: 1,
25
+ ...over,
26
+ } as KaizenGrading
27
+ }
28
+
29
+ describe('kaizen store — live-push clobber guards', () => {
30
+ beforeEach(() => {
31
+ useWorkspaceStore().workspaceId = 'ws1'
32
+ })
33
+
34
+ it('loadForExecution stores the fetched gradings', async () => {
35
+ vi.stubGlobal('useApi', () => ({
36
+ getKaizenForExecution: () => Promise.resolve({ gradings: [grading()] }),
37
+ }))
38
+ const store = useKaizenStore()
39
+ await store.loadForExecution('exec1')
40
+ expect(store.byExecution.exec1).toHaveLength(1)
41
+ })
42
+
43
+ it('a slower stale loadForExecution never clobbers a newer one (monotonic guard)', async () => {
44
+ // Two loads race for the same execution: the FIRST-issued resolves LAST with a stale list.
45
+ // Without the ticket guard its REPLACE would overwrite the fresher second result.
46
+ const deferred: Array<(r: { gradings: KaizenGrading[] }) => void> = []
47
+ vi.stubGlobal('useApi', () => ({
48
+ getKaizenForExecution: () =>
49
+ new Promise<{ gradings: KaizenGrading[] }>((res) => deferred.push(res)),
50
+ }))
51
+ const store = useKaizenStore()
52
+ const first = store.loadForExecution('exec1') // issued #1 (stale)
53
+ const second = store.loadForExecution('exec1') // issued #2 (fresh)
54
+
55
+ deferred[1]!({ gradings: [grading({ id: 'fresh' })] })
56
+ deferred[0]!({ gradings: [grading({ id: 'stale' })] })
57
+ await Promise.all([first, second])
58
+
59
+ expect(store.byExecution.exec1).toHaveLength(1)
60
+ expect(store.byExecution.exec1![0]!.id).toBe('fresh')
61
+ })
62
+
63
+ it('a grading pushed live mid-load survives the load (merge, not blind-replace)', async () => {
64
+ // A load is in flight (server response predates the newest grading); a live `upsert` lands
65
+ // its grading; then the load resolves. A blind replace would drop the live-only grading.
66
+ let resolveFetch!: (r: { gradings: KaizenGrading[] }) => void
67
+ const pending = new Promise<{ gradings: KaizenGrading[] }>((res) => {
68
+ resolveFetch = res
69
+ })
70
+ vi.stubGlobal('useApi', () => ({ getKaizenForExecution: () => pending }))
71
+ const store = useKaizenStore()
72
+
73
+ const load = store.loadForExecution('exec1')
74
+ // A live stream event arrives while the fetch is in flight.
75
+ store.upsert(grading({ id: 'live', stepIndex: 1 }))
76
+ // The load's (staler) response comes back with only the earlier grading.
77
+ resolveFetch({ gradings: [grading({ id: 'g1', stepIndex: 0 })] })
78
+ await load
79
+
80
+ const ids = store.byExecution.exec1!.map((g) => g.id).sort()
81
+ expect(ids).toEqual(['g1', 'live'])
82
+ })
83
+
84
+ it('a shared-id load keeps whichever updatedAt is newer', async () => {
85
+ // Stub before the store is created — it captures `useApi()` at instantiation.
86
+ vi.stubGlobal('useApi', () => ({
87
+ getKaizenForExecution: () =>
88
+ Promise.resolve({ gradings: [grading({ id: 'g1', updatedAt: 2, summary: 'stale' })] }),
89
+ }))
90
+ const store = useKaizenStore()
91
+ // Seed a fresher live grading, then a load returns a staler copy of the SAME id.
92
+ store.upsert(grading({ id: 'g1', updatedAt: 5, summary: 'live' }))
93
+ await store.loadForExecution('exec1')
94
+ expect(store.byExecution.exec1![0]!.summary).toBe('live')
95
+ })
96
+
97
+ it('loadOverview preserves a live-pushed grading in history (merge, newest-first)', async () => {
98
+ vi.stubGlobal('useApi', () => ({
99
+ getKaizenOverview: () =>
100
+ Promise.resolve({
101
+ gradings: [grading({ id: 'old', createdAt: 1, updatedAt: 1 })],
102
+ verified: [],
103
+ }),
104
+ }))
105
+ const store = useKaizenStore()
106
+ // A grading arrives live before the overview list is fetched.
107
+ store.upsert(grading({ id: 'live', createdAt: 9, updatedAt: 9 }))
108
+ await store.loadOverview()
109
+
110
+ const ids = store.history.map((g) => g.id)
111
+ expect(ids).toContain('live')
112
+ expect(ids).toContain('old')
113
+ // The live (newest) grading stays at the front of the newest-first list.
114
+ expect(ids[0]).toBe('live')
115
+ })
116
+
117
+ it('a slower stale loadOverview never clobbers a newer one', async () => {
118
+ const deferred: Array<(r: { gradings: KaizenGrading[]; verified: [] }) => void> = []
119
+ vi.stubGlobal('useApi', () => ({
120
+ getKaizenOverview: () =>
121
+ new Promise<{ gradings: KaizenGrading[]; verified: [] }>((res) => deferred.push(res)),
122
+ }))
123
+ const store = useKaizenStore()
124
+ const first = store.loadOverview() // stale
125
+ const second = store.loadOverview() // fresh
126
+
127
+ deferred[1]!({ gradings: [grading({ id: 'fresh' })], verified: [] })
128
+ deferred[0]!({ gradings: [grading({ id: 'stale' })], verified: [] })
129
+ await Promise.all([first, second])
130
+
131
+ expect(store.history).toHaveLength(1)
132
+ expect(store.history[0]!.id).toBe('fresh')
133
+ })
134
+ })
@@ -23,6 +23,36 @@ export const useKaizenStore = defineStore('kaizen', () => {
23
23
  /** 503 ⇒ the Kaizen feature isn't configured on this deployment. */
24
24
  const available = ref<boolean | null>(null)
25
25
 
26
+ // Monotonic load-ordering guard. Both loads REPLACE state that also arrives live over the
27
+ // stream (`upsert`), so a slower/staler fetch resolving AFTER a newer one — or after a live
28
+ // push — would clobber the fresher gradings (the CLAUDE.md live-push out-of-order hazard,
29
+ // the same one `stores/provisioningLogs.ts` guards). Each load takes a ticket; only the
30
+ // newest-issued one commits. NOT reactive — pure bookkeeping the UI never reads.
31
+ let loadTicket = 0
32
+ let latestOverviewLoad = 0
33
+ const latestExecLoad = new Map<string, number>()
34
+
35
+ /**
36
+ * Fold a freshly-loaded grading list into the live cache WITHOUT dropping live-only rows:
37
+ * a grading pushed via `upsert` while the load was in flight may not be in the server's
38
+ * response yet, and a blind replace would silently drop it. Loaded rows are authoritative
39
+ * for the ids they carry (keeping whichever `updatedAt` is greater on a shared id), and any
40
+ * live-only rows the response hasn't caught up to are preserved. Gradings are append/update-
41
+ * only (never deleted), so preserving an unmatched live row can't resurrect stale state.
42
+ * Returns the reconciled loaded rows and the surviving live-only rows separately so each
43
+ * caller can splice them in its own order (execution cache appends; screen history, which is
44
+ * newest-first, prepends).
45
+ */
46
+ function reconcileWithLive(loaded: KaizenGrading[], existing: KaizenGrading[]) {
47
+ const loadedIds = new Set(loaded.map((g) => g.id))
48
+ const reconciled = loaded.map((l) => {
49
+ const live = existing.find((e) => e.id === l.id)
50
+ return live && live.updatedAt > l.updatedAt ? live : l
51
+ })
52
+ const liveOnly = existing.filter((e) => !loadedIds.has(e.id))
53
+ return { reconciled, liveOnly }
54
+ }
55
+
26
56
  function gradingsFor(executionId: string): KaizenGrading[] {
27
57
  return byExecution.value[executionId] ?? []
28
58
  }
@@ -35,11 +65,18 @@ export const useKaizenStore = defineStore('kaizen', () => {
35
65
  async function loadOverview() {
36
66
  const ws = useWorkspaceStore()
37
67
  loadingOverview.value = true
68
+ const seq = ++loadTicket
69
+ latestOverviewLoad = seq
38
70
  try {
39
71
  const overview = await api.getKaizenOverview(ws.requireId())
40
- history.value = overview.gradings
41
- verified.value = overview.verified
42
72
  available.value = true
73
+ // A newer overview load superseded this one while it was in flight — discard the staler
74
+ // result so it can't clobber the fresher history (and any grading live-pushed since).
75
+ if (latestOverviewLoad !== seq) return
76
+ verified.value = overview.verified
77
+ // History is newest-first; live-pushed gradings are the newest, so prepend the survivors.
78
+ const { reconciled, liveOnly } = reconcileWithLive(overview.gradings, history.value)
79
+ history.value = [...liveOnly, ...reconciled]
43
80
  } catch (e) {
44
81
  if ((e as { statusCode?: number; status?: number })?.statusCode === 503)
45
82
  available.value = false
@@ -52,10 +89,20 @@ export const useKaizenStore = defineStore('kaizen', () => {
52
89
  async function loadForExecution(executionId: string) {
53
90
  const ws = useWorkspaceStore()
54
91
  loadingExecution.value = new Set(loadingExecution.value).add(executionId)
92
+ const seq = ++loadTicket
93
+ latestExecLoad.set(executionId, seq)
55
94
  try {
56
95
  const { gradings } = await api.getKaizenForExecution(ws.requireId(), executionId)
57
- byExecution.value = { ...byExecution.value, [executionId]: gradings }
58
96
  available.value = true
97
+ // A newer load for this execution (or a live `upsert`) may have landed while this fetch
98
+ // was in flight — discard a superseded load, and merge rather than blind-replace so a
99
+ // grading pushed live mid-flight isn't dropped.
100
+ if (latestExecLoad.get(executionId) !== seq) return
101
+ const { reconciled, liveOnly } = reconcileWithLive(
102
+ gradings,
103
+ byExecution.value[executionId] ?? [],
104
+ )
105
+ byExecution.value = { ...byExecution.value, [executionId]: [...reconciled, ...liveOnly] }
59
106
  } catch (e) {
60
107
  if ((e as { statusCode?: number; status?: number })?.statusCode === 503)
61
108
  available.value = false
@@ -1174,7 +1174,8 @@
1174
1174
  "merged": "Gemergt",
1175
1175
  "open": "Öffnen",
1176
1176
  "mergePr": "PR mergen",
1177
- "chooseApproach": "Ansatz wählen"
1177
+ "chooseApproach": "Ansatz wählen",
1178
+ "elapsedTooltip": "Verstrichene Zeit für diesen Schritt"
1178
1179
  },
1179
1180
  "structure": {
1180
1181
  "title": "Struktur",
@@ -1466,7 +1467,8 @@
1466
1467
  "confirmArchive": {
1467
1468
  "title": "Diesen Dienst archivieren?",
1468
1469
  "body": "„{name}“ und seine Aufgaben werden vom Board ausgeblendet. Du kannst den Dienst jederzeit wiederherstellen."
1469
- }
1470
+ },
1471
+ "runBlocked": "Blockiert durch eine unerledigte Abhängigkeit: {names} | Blockiert durch {count} unerledigte Abhängigkeiten: {names}"
1470
1472
  }
1471
1473
  },
1472
1474
  "layout": {
@@ -2123,7 +2125,12 @@
2123
2125
  "bootstrapStopped": "Bootstrap gestoppt",
2124
2126
  "runStopped": "Lauf gestoppt",
2125
2127
  "stoppedDescription": "Der Container wurde beendet und der Lauf abgebrochen.",
2126
- "stopFailed": "Stoppen fehlgeschlagen"
2128
+ "stopFailed": "Stoppen fehlgeschlagen",
2129
+ "confirm": {
2130
+ "title": "Diesen Lauf stoppen?",
2131
+ "body": "Der laufende Container wird beendet. Der Lauf bleibt sichtbar und kann wiederholt werden.",
2132
+ "confirm": "Lauf stoppen"
2133
+ }
2127
2134
  },
2128
2135
  "frame": {
2129
2136
  "status": {
@@ -3011,7 +3018,8 @@
3011
3018
  "forkDecision": {
3012
3019
  "proposing": "Ansätze werden vorgeschlagen…",
3013
3020
  "choose": "Ansatz wählen"
3014
- }
3021
+ },
3022
+ "elapsedTooltip": "Verstrichene Zeit für diesen Schritt"
3015
3023
  },
3016
3024
  "health": {
3017
3025
  "title": "Pipeline-Zustand",
@@ -347,7 +347,12 @@
347
347
  "bootstrapStopped": "Bootstrap stopped",
348
348
  "runStopped": "Run stopped",
349
349
  "stoppedDescription": "The container was killed and the run was cancelled.",
350
- "stopFailed": "Stop failed"
350
+ "stopFailed": "Stop failed",
351
+ "confirm": {
352
+ "title": "Stop this run?",
353
+ "body": "The running container will be killed. The run stays visible and can be retried.",
354
+ "confirm": "Stop run"
355
+ }
351
356
  },
352
357
  "frame": {
353
358
  "status": {
@@ -913,7 +918,8 @@
913
918
  "merged": "Merged",
914
919
  "open": "Open",
915
920
  "mergePr": "Merge PR",
916
- "chooseApproach": "Choose approach"
921
+ "chooseApproach": "Choose approach",
922
+ "elapsedTooltip": "Elapsed time on this step"
917
923
  },
918
924
  "structure": {
919
925
  "title": "Structure",
@@ -1205,6 +1211,10 @@
1205
1211
  "confirmArchive": {
1206
1212
  "title": "Archive this service?",
1207
1213
  "body": "\"{name}\" and its tasks will be hidden from the board. You can restore it at any time."
1214
+ },
1215
+ "runBlocked": "Blocked by an unfinished dependency: {names} | Blocked by {count} unfinished dependencies: {names}",
1216
+ "@runBlocked": {
1217
+ "description": "Count-driven plural: {count} unfinished dependencies, {names} their comma-joined titles. Languages with more than two plural forms (e.g. Polish, Ukrainian: one/few/many) need the extra pipe-separated forms."
1208
1218
  }
1209
1219
  }
1210
1220
  },
@@ -3324,7 +3334,8 @@
3324
3334
  "forkDecision": {
3325
3335
  "proposing": "Proposing approaches…",
3326
3336
  "choose": "Choose an approach"
3327
- }
3337
+ },
3338
+ "elapsedTooltip": "Elapsed time on this step"
3328
3339
  },
3329
3340
  "health": {
3330
3341
  "title": "Pipeline health",
@@ -320,7 +320,12 @@
320
320
  "bootstrapStopped": "Arranque detenido",
321
321
  "runStopped": "Ejecución detenida",
322
322
  "stoppedDescription": "El contenedor se cerró y la ejecución se canceló.",
323
- "stopFailed": "No se pudo detener"
323
+ "stopFailed": "No se pudo detener",
324
+ "confirm": {
325
+ "title": "¿Detener esta ejecución?",
326
+ "body": "Se detendrá el contenedor en ejecución. La ejecución seguirá visible y podrá reintentarse.",
327
+ "confirm": "Detener ejecución"
328
+ }
324
329
  },
325
330
  "frame": {
326
331
  "status": {
@@ -859,7 +864,8 @@
859
864
  "title": "Aún no hay ejecuciones",
860
865
  "body": "Inicia un pipeline para ver el historial de ejecución aquí."
861
866
  },
862
- "chooseApproach": "Elegir enfoque"
867
+ "chooseApproach": "Elegir enfoque",
868
+ "elapsedTooltip": "Tiempo transcurrido en este paso"
863
869
  },
864
870
  "structure": {
865
871
  "title": "Estructura",
@@ -1151,7 +1157,8 @@
1151
1157
  "confirmArchive": {
1152
1158
  "title": "¿Archivar este servicio?",
1153
1159
  "body": "«{name}» y sus tareas se ocultarán del tablero. Puedes restaurarlo en cualquier momento."
1154
- }
1160
+ },
1161
+ "runBlocked": "Bloqueado por una dependencia sin terminar: {names} | Bloqueado por {count} dependencias sin terminar: {names}"
1155
1162
  }
1156
1163
  },
1157
1164
  "observability": {
@@ -3237,7 +3244,8 @@
3237
3244
  "forkDecision": {
3238
3245
  "proposing": "Proponiendo enfoques…",
3239
3246
  "choose": "Elegir un enfoque"
3240
- }
3247
+ },
3248
+ "elapsedTooltip": "Tiempo transcurrido en este paso"
3241
3249
  },
3242
3250
  "health": {
3243
3251
  "title": "Estado de los pipelines",
@@ -320,7 +320,12 @@
320
320
  "bootstrapStopped": "Initialisation arrêtée",
321
321
  "runStopped": "Exécution arrêtée",
322
322
  "stoppedDescription": "Le conteneur a été tué et l’exécution annulée.",
323
- "stopFailed": "Échec de l’arrêt"
323
+ "stopFailed": "Échec de l’arrêt",
324
+ "confirm": {
325
+ "title": "Arrêter cette exécution ?",
326
+ "body": "Le conteneur en cours sera arrêté. L'exécution reste visible et peut être relancée.",
327
+ "confirm": "Arrêter l'exécution"
328
+ }
324
329
  },
325
330
  "frame": {
326
331
  "status": {
@@ -859,7 +864,8 @@
859
864
  "title": "Aucune exécution pour l'instant",
860
865
  "body": "Lancez un pipeline pour voir l'historique d'exécution ici."
861
866
  },
862
- "chooseApproach": "Choisir l'approche"
867
+ "chooseApproach": "Choisir l'approche",
868
+ "elapsedTooltip": "Temps écoulé sur cette étape"
863
869
  },
864
870
  "structure": {
865
871
  "title": "Structure",
@@ -1151,7 +1157,8 @@
1151
1157
  "confirmArchive": {
1152
1158
  "title": "Archiver ce service ?",
1153
1159
  "body": "« {name} » et ses tâches seront masqués du tableau. Vous pouvez le restaurer à tout moment."
1154
- }
1160
+ },
1161
+ "runBlocked": "Bloqué par une dépendance non terminée : {names} | Bloqué par {count} dépendances non terminées : {names}"
1155
1162
  }
1156
1163
  },
1157
1164
  "observability": {
@@ -3237,7 +3244,8 @@
3237
3244
  "forkDecision": {
3238
3245
  "proposing": "Proposition d'approches…",
3239
3246
  "choose": "Choisir une approche"
3240
- }
3247
+ },
3248
+ "elapsedTooltip": "Temps écoulé sur cette étape"
3241
3249
  },
3242
3250
  "health": {
3243
3251
  "title": "État des pipelines",
@@ -320,7 +320,12 @@
320
320
  "bootstrapStopped": "האתחול נעצר",
321
321
  "runStopped": "הריצה נעצרה",
322
322
  "stoppedDescription": "הקונטיינר הופסק והריצה בוטלה.",
323
- "stopFailed": "העצירה נכשלה"
323
+ "stopFailed": "העצירה נכשלה",
324
+ "confirm": {
325
+ "title": "לעצור את ההרצה הזו?",
326
+ "body": "הקונטיינר הפעיל ייעצר. ההרצה תישאר גלויה וניתן יהיה לנסות אותה שוב.",
327
+ "confirm": "עצור הרצה"
328
+ }
324
329
  },
325
330
  "frame": {
326
331
  "status": {
@@ -859,7 +864,8 @@
859
864
  "title": "אין הרצות עדיין",
860
865
  "body": "התחל pipeline כדי לראות כאן את היסטוריית ההרצות."
861
866
  },
862
- "chooseApproach": "בחר גישה"
867
+ "chooseApproach": "בחר גישה",
868
+ "elapsedTooltip": "הזמן שחלף בשלב זה"
863
869
  },
864
870
  "structure": {
865
871
  "title": "מבנה",
@@ -1151,7 +1157,8 @@
1151
1157
  "confirmArchive": {
1152
1158
  "title": "להעביר שירות זה לארכיון?",
1153
1159
  "body": "\"{name}\" והמשימות שלו יוסתרו מהלוח. אפשר לשחזר אותו בכל עת."
1154
- }
1160
+ },
1161
+ "runBlocked": "חסום על ידי תלות אחת שלא הושלמה: {names} | חסום על ידי {count} תלויות שלא הושלמו: {names}"
1155
1162
  }
1156
1163
  },
1157
1164
  "observability": {
@@ -3248,7 +3255,8 @@
3248
3255
  "forkDecision": {
3249
3256
  "proposing": "מציע גישות…",
3250
3257
  "choose": "בחר גישה"
3251
- }
3258
+ },
3259
+ "elapsedTooltip": "הזמן שחלף בשלב זה"
3252
3260
  },
3253
3261
  "health": {
3254
3262
  "title": "בריאות הצינור",
@@ -1174,7 +1174,8 @@
1174
1174
  "merged": "Unita",
1175
1175
  "open": "Aperta",
1176
1176
  "mergePr": "Unisci PR",
1177
- "chooseApproach": "Scegli approccio"
1177
+ "chooseApproach": "Scegli approccio",
1178
+ "elapsedTooltip": "Tempo trascorso su questo passaggio"
1178
1179
  },
1179
1180
  "structure": {
1180
1181
  "title": "Struttura",
@@ -1466,7 +1467,8 @@
1466
1467
  "confirmArchive": {
1467
1468
  "title": "Archiviare questo servizio?",
1468
1469
  "body": "\"{name}\" e le sue attività verranno nascosti dalla board. Puoi ripristinarlo in qualsiasi momento."
1469
- }
1470
+ },
1471
+ "runBlocked": "Bloccato da una dipendenza non completata: {names} | Bloccato da {count} dipendenze non completate: {names}"
1470
1472
  }
1471
1473
  },
1472
1474
  "layout": {
@@ -2123,7 +2125,12 @@
2123
2125
  "bootstrapStopped": "Bootstrap interrotto",
2124
2126
  "runStopped": "Esecuzione interrotta",
2125
2127
  "stoppedDescription": "Il container è stato terminato e l'esecuzione è stata annullata.",
2126
- "stopFailed": "Interruzione fallita"
2128
+ "stopFailed": "Interruzione fallita",
2129
+ "confirm": {
2130
+ "title": "Interrompere questa esecuzione?",
2131
+ "body": "Il container in esecuzione verrà terminato. L'esecuzione resta visibile e può essere ripetuta.",
2132
+ "confirm": "Interrompi esecuzione"
2133
+ }
2127
2134
  },
2128
2135
  "frame": {
2129
2136
  "status": {
@@ -3011,7 +3018,8 @@
3011
3018
  "forkDecision": {
3012
3019
  "proposing": "Proposta di approcci…",
3013
3020
  "choose": "Scegli un approccio"
3014
- }
3021
+ },
3022
+ "elapsedTooltip": "Tempo trascorso su questo passaggio"
3015
3023
  },
3016
3024
  "health": {
3017
3025
  "title": "Stato delle pipeline",
@@ -320,7 +320,12 @@
320
320
  "bootstrapStopped": "ブートストラップを停止しました",
321
321
  "runStopped": "実行を停止しました",
322
322
  "stoppedDescription": "コンテナが終了され、実行はキャンセルされました。",
323
- "stopFailed": "停止に失敗しました"
323
+ "stopFailed": "停止に失敗しました",
324
+ "confirm": {
325
+ "title": "この実行を停止しますか?",
326
+ "body": "実行中のコンテナが終了します。実行は表示されたまま残り、再試行できます。",
327
+ "confirm": "実行を停止"
328
+ }
324
329
  },
325
330
  "frame": {
326
331
  "status": {
@@ -859,7 +864,8 @@
859
864
  "title": "実行履歴はまだありません",
860
865
  "body": "パイプラインを開始すると、ここに実行履歴が表示されます。"
861
866
  },
862
- "chooseApproach": "アプローチを選択"
867
+ "chooseApproach": "アプローチを選択",
868
+ "elapsedTooltip": "このステップの経過時間"
863
869
  },
864
870
  "structure": {
865
871
  "title": "構造",
@@ -1151,7 +1157,8 @@
1151
1157
  "confirmArchive": {
1152
1158
  "title": "このサービスをアーカイブしますか?",
1153
1159
  "body": "「{name}」とそのタスクはボードから非表示になります。いつでも復元できます。"
1154
- }
1160
+ },
1161
+ "runBlocked": "未完了の依存関係によってブロックされています: {names} | {count} 件の未完了の依存関係によってブロックされています: {names}"
1155
1162
  }
1156
1163
  },
1157
1164
  "observability": {
@@ -3249,7 +3256,8 @@
3249
3256
  "forkDecision": {
3250
3257
  "proposing": "アプローチを提案中…",
3251
3258
  "choose": "アプローチを選択"
3252
- }
3259
+ },
3260
+ "elapsedTooltip": "このステップの経過時間"
3253
3261
  },
3254
3262
  "health": {
3255
3263
  "title": "パイプラインの状態",
@@ -320,7 +320,12 @@
320
320
  "bootstrapStopped": "Zatrzymano inicjalizację",
321
321
  "runStopped": "Zatrzymano uruchomienie",
322
322
  "stoppedDescription": "Kontener został zabity, a uruchomienie anulowane.",
323
- "stopFailed": "Nie udało się zatrzymać"
323
+ "stopFailed": "Nie udało się zatrzymać",
324
+ "confirm": {
325
+ "title": "Zatrzymać to uruchomienie?",
326
+ "body": "Działający kontener zostanie zatrzymany. Uruchomienie pozostanie widoczne i można je ponowić.",
327
+ "confirm": "Zatrzymaj uruchomienie"
328
+ }
324
329
  },
325
330
  "frame": {
326
331
  "status": {
@@ -859,7 +864,8 @@
859
864
  "title": "Brak uruchomień",
860
865
  "body": "Uruchom pipeline, aby zobaczyć tutaj historię uruchomień."
861
866
  },
862
- "chooseApproach": "Wybierz podejście"
867
+ "chooseApproach": "Wybierz podejście",
868
+ "elapsedTooltip": "Czas, który upłynął na tym kroku"
863
869
  },
864
870
  "structure": {
865
871
  "title": "Struktura",
@@ -1151,7 +1157,8 @@
1151
1157
  "confirmArchive": {
1152
1158
  "title": "Zarchiwizować tę usługę?",
1153
1159
  "body": "„{name}” i jej zadania zostaną ukryte z tablicy. Możesz przywrócić usługę w dowolnej chwili."
1154
- }
1160
+ },
1161
+ "runBlocked": "Zablokowane przez niedokończoną zależność: {names} | Zablokowane przez {count} niedokończone zależności: {names} | Zablokowane przez {count} niedokończonych zależności: {names}"
1155
1162
  }
1156
1163
  },
1157
1164
  "observability": {
@@ -3237,7 +3244,8 @@
3237
3244
  "forkDecision": {
3238
3245
  "proposing": "Proponowanie podejść…",
3239
3246
  "choose": "Wybierz podejście"
3240
- }
3247
+ },
3248
+ "elapsedTooltip": "Czas, który upłynął na tym kroku"
3241
3249
  },
3242
3250
  "health": {
3243
3251
  "title": "Stan pipeline'ów",
@@ -320,7 +320,12 @@
320
320
  "bootstrapStopped": "Bootstrap durduruldu",
321
321
  "runStopped": "Çalıştırma durduruldu",
322
322
  "stoppedDescription": "Konteyner sonlandırıldı ve çalıştırma iptal edildi.",
323
- "stopFailed": "Durdurma başarısız oldu"
323
+ "stopFailed": "Durdurma başarısız oldu",
324
+ "confirm": {
325
+ "title": "Bu çalıştırma durdurulsun mu?",
326
+ "body": "Çalışan konteyner sonlandırılacak. Çalıştırma görünür kalır ve yeniden denenebilir.",
327
+ "confirm": "Çalıştırmayı durdur"
328
+ }
324
329
  },
325
330
  "frame": {
326
331
  "status": {
@@ -859,7 +864,8 @@
859
864
  "title": "Henüz çalıştırma yok",
860
865
  "body": "Çalıştırma geçmişini burada görmek için bir pipeline başlatın."
861
866
  },
862
- "chooseApproach": "Yaklaşım seç"
867
+ "chooseApproach": "Yaklaşım seç",
868
+ "elapsedTooltip": "Bu adımda geçen süre"
863
869
  },
864
870
  "structure": {
865
871
  "title": "Yapı",
@@ -1151,7 +1157,8 @@
1151
1157
  "confirmArchive": {
1152
1158
  "title": "Bu hizmet arşivlensin mi?",
1153
1159
  "body": "\"{name}\" ve görevleri panodan gizlenecek. İstediğin zaman geri yükleyebilirsin."
1154
- }
1160
+ },
1161
+ "runBlocked": "Tamamlanmamış bir bağımlılık tarafından engellendi: {names} | Tamamlanmamış {count} bağımlılık tarafından engellendi: {names}"
1155
1162
  }
1156
1163
  },
1157
1164
  "observability": {
@@ -3249,7 +3256,8 @@
3249
3256
  "forkDecision": {
3250
3257
  "proposing": "Yaklaşımlar öneriliyor…",
3251
3258
  "choose": "Bir yaklaşım seçin"
3252
- }
3259
+ },
3260
+ "elapsedTooltip": "Bu adımda geçen süre"
3253
3261
  },
3254
3262
  "health": {
3255
3263
  "title": "Pipeline sağlığı",
@@ -320,7 +320,12 @@
320
320
  "bootstrapStopped": "Ініціалізацію зупинено",
321
321
  "runStopped": "Запуск зупинено",
322
322
  "stoppedDescription": "Контейнер було зупинено, а запуск скасовано.",
323
- "stopFailed": "Не вдалося зупинити"
323
+ "stopFailed": "Не вдалося зупинити",
324
+ "confirm": {
325
+ "title": "Зупинити цей запуск?",
326
+ "body": "Запущений контейнер буде зупинено. Запуск залишиться видимим, і його можна повторити.",
327
+ "confirm": "Зупинити запуск"
328
+ }
324
329
  },
325
330
  "frame": {
326
331
  "status": {
@@ -859,7 +864,8 @@
859
864
  "title": "Ще немає запусків",
860
865
  "body": "Запустіть pipeline, щоб побачити тут історію запусків."
861
866
  },
862
- "chooseApproach": "Обрати підхід"
867
+ "chooseApproach": "Обрати підхід",
868
+ "elapsedTooltip": "Час, що минув на цьому кроці"
863
869
  },
864
870
  "structure": {
865
871
  "title": "Структура",
@@ -1151,7 +1157,8 @@
1151
1157
  "confirmArchive": {
1152
1158
  "title": "Заархівувати цей сервіс?",
1153
1159
  "body": "«{name}» та його завдання буде приховано з дошки. Ви можете відновити його будь-коли."
1154
- }
1160
+ },
1161
+ "runBlocked": "Заблоковано незавершеною залежністю: {names} | Заблоковано {count} незавершеними залежностями: {names} | Заблоковано {count} незавершеними залежностями: {names}"
1155
1162
  }
1156
1163
  },
1157
1164
  "observability": {
@@ -3237,7 +3244,8 @@
3237
3244
  "forkDecision": {
3238
3245
  "proposing": "Пропонування підходів…",
3239
3246
  "choose": "Оберіть підхід"
3240
- }
3247
+ },
3248
+ "elapsedTooltip": "Час, що минув на цьому кроці"
3241
3249
  },
3242
3250
  "health": {
3243
3251
  "title": "Стан пайплайнів",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.115.1",
3
+ "version": "0.115.2",
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",