@cat-factory/app 0.115.0 → 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
  () => {
@@ -76,7 +76,12 @@ watch(
76
76
  )
77
77
 
78
78
  onMounted(() => reload())
79
- onBeforeUnmount(stopPolling)
79
+ onBeforeUnmount(() => {
80
+ stopPolling()
81
+ // Drop this run's accumulated log state so the per-execution map doesn't grow for the app's
82
+ // lifetime (a re-opened drawer re-fetches on mount). Subsystem mode keeps its fixed-size state.
83
+ if (props.executionId) store.evict(props.executionId)
84
+ })
80
85
 
81
86
  // Exhaustive enum→label maps of literal `t(...)` keys (keeps the typed-key drift guard
82
87
  // live for these runtime-indexed lookups).
@@ -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
+ })