@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.
- package/app/components/board/AgentStopButton.vue +11 -0
- package/app/components/panels/InspectorPanel.vue +29 -0
- package/app/components/panels/inspector/TaskExecution.vue +28 -0
- package/app/components/pipeline/PipelineProgress.vue +23 -3
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +6 -1
- package/app/composables/useStepTimer.spec.ts +80 -0
- package/app/composables/useStepTimer.ts +64 -28
- package/app/stores/consensus.spec.ts +76 -0
- package/app/stores/consensus.ts +11 -1
- package/app/stores/docInterview.spec.ts +64 -0
- package/app/stores/kaizen.spec.ts +134 -0
- package/app/stores/kaizen.ts +50 -3
- package/app/stores/provisioningLogs.spec.ts +88 -0
- package/app/stores/provisioningLogs.ts +34 -1
- package/i18n/locales/de.json +12 -4
- package/i18n/locales/en.json +14 -3
- package/i18n/locales/es.json +12 -4
- package/i18n/locales/fr.json +12 -4
- package/i18n/locales/he.json +12 -4
- package/i18n/locales/it.json +12 -4
- package/i18n/locales/ja.json +12 -4
- package/i18n/locales/pl.json +12 -4
- package/i18n/locales/tr.json +12 -4
- package/i18n/locales/uk.json +12 -4
- package/package.json +1 -1
|
@@ -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
|
|
326
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
+
})
|
package/app/stores/consensus.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|