@cat-factory/app 0.115.1 → 0.115.3
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/fragments/FragmentLibraryManager.vue +102 -46
- 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/slack/SlackPanel.vue +40 -10
- 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/utils/slackMemberMapping.spec.ts +94 -0
- package/app/utils/slackMemberMapping.ts +46 -0
- package/i18n/locales/de.json +20 -5
- package/i18n/locales/en.json +22 -4
- package/i18n/locales/es.json +20 -5
- package/i18n/locales/fr.json +20 -5
- package/i18n/locales/he.json +20 -5
- package/i18n/locales/it.json +20 -5
- package/i18n/locales/ja.json +20 -5
- package/i18n/locales/pl.json +20 -5
- package/i18n/locales/tr.json +20 -5
- package/i18n/locales/uk.json +20 -5
- package/package.json +6 -6
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|
package/app/stores/kaizen.ts
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { SlackMemberMappingEntry } from '~/types/slack'
|
|
3
|
+
import {
|
|
4
|
+
type MemberRow,
|
|
5
|
+
emptyMemberRow,
|
|
6
|
+
hasHalfFilledRow,
|
|
7
|
+
toMemberEntries,
|
|
8
|
+
toMemberRow,
|
|
9
|
+
} from './slackMemberMapping'
|
|
10
|
+
|
|
11
|
+
const row = (partial: Partial<MemberRow> & { uid: string }): MemberRow => ({
|
|
12
|
+
userId: '',
|
|
13
|
+
slackUserId: '',
|
|
14
|
+
role: 'engineering',
|
|
15
|
+
...partial,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('hasHalfFilledRow', () => {
|
|
19
|
+
it('is false for fully-filled rows', () => {
|
|
20
|
+
expect(hasHalfFilledRow([row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' })])).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is false for fully-empty rows (unused slots)', () => {
|
|
24
|
+
expect(hasHalfFilledRow([row({ uid: 'a' }), row({ uid: 'b' })])).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('is true when only the user id is filled', () => {
|
|
28
|
+
expect(hasHalfFilledRow([row({ uid: 'a', userId: 'usr_1' })])).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('is true when only the Slack id is filled', () => {
|
|
32
|
+
expect(hasHalfFilledRow([row({ uid: 'a', slackUserId: 'U1' })])).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('treats whitespace-only ids as blank', () => {
|
|
36
|
+
expect(hasHalfFilledRow([row({ uid: 'a', userId: ' ', slackUserId: 'U1' })])).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('flags a half-filled row among valid ones', () => {
|
|
40
|
+
expect(
|
|
41
|
+
hasHalfFilledRow([
|
|
42
|
+
row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' }),
|
|
43
|
+
row({ uid: 'b', userId: 'usr_2' }),
|
|
44
|
+
]),
|
|
45
|
+
).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('toMemberEntries', () => {
|
|
50
|
+
it('keeps fully-filled rows, drops empty slots, and strips uid', () => {
|
|
51
|
+
const rows: MemberRow[] = [
|
|
52
|
+
row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1', role: 'product' }),
|
|
53
|
+
row({ uid: 'b' }), // empty slot — dropped
|
|
54
|
+
row({ uid: 'c', userId: 'usr_2', slackUserId: 'U2' }),
|
|
55
|
+
]
|
|
56
|
+
expect(toMemberEntries(rows)).toEqual([
|
|
57
|
+
{ userId: 'usr_1', slackUserId: 'U1', role: 'product' },
|
|
58
|
+
{ userId: 'usr_2', slackUserId: 'U2', role: 'engineering' },
|
|
59
|
+
])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not leak the client-only uid onto the wire payload', () => {
|
|
63
|
+
const [entry] = toMemberEntries([row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' })])
|
|
64
|
+
expect(entry).not.toHaveProperty('uid')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('toMemberRow', () => {
|
|
69
|
+
it('stamps the uid and defaults a missing role', () => {
|
|
70
|
+
const entry: SlackMemberMappingEntry = { userId: 'usr_1', slackUserId: 'U1' }
|
|
71
|
+
expect(toMemberRow(entry, 'm1')).toEqual({
|
|
72
|
+
userId: 'usr_1',
|
|
73
|
+
slackUserId: 'U1',
|
|
74
|
+
role: 'engineering',
|
|
75
|
+
uid: 'm1',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('preserves an explicit role', () => {
|
|
80
|
+
const entry: SlackMemberMappingEntry = { userId: 'usr_1', slackUserId: 'U1', role: 'product' }
|
|
81
|
+
expect(toMemberRow(entry, 'm2').role).toBe('product')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('emptyMemberRow', () => {
|
|
86
|
+
it('builds a blank engineering row with the given uid', () => {
|
|
87
|
+
expect(emptyMemberRow('m9')).toEqual({
|
|
88
|
+
uid: 'm9',
|
|
89
|
+
userId: '',
|
|
90
|
+
slackUserId: '',
|
|
91
|
+
role: 'engineering',
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SlackMemberMappingEntry } from '~/types/slack'
|
|
2
|
+
|
|
3
|
+
// Pure helpers for the Slack member-mapping editor (SlackPanel.vue). Extracted so
|
|
4
|
+
// the save-time integrity rules (UX-23) can be unit-tested without mounting the panel.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An editable member-map row: the wire entry plus a client-only stable `uid` so a
|
|
8
|
+
* mid-list delete keys the `v-model` by identity, not the array index (index keys
|
|
9
|
+
* silently rebound a neighbour's inputs — UX-23).
|
|
10
|
+
*/
|
|
11
|
+
export type MemberRow = SlackMemberMappingEntry & { uid: string }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True when any row has exactly one of the two ids filled. A half-entered mapping
|
|
15
|
+
* used to be silently dropped on save (UX-23); the panel blocks the save instead so
|
|
16
|
+
* the user doesn't lose it. A fully-empty row is an unused slot, not half-filled.
|
|
17
|
+
*/
|
|
18
|
+
export function hasHalfFilledRow(
|
|
19
|
+
rows: readonly Pick<MemberRow, 'userId' | 'slackUserId'>[],
|
|
20
|
+
): boolean {
|
|
21
|
+
return rows.some((e) => Boolean(e.userId.trim()) !== Boolean(e.slackUserId.trim()))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The rows to persist: fully-filled only (empty slots dropped), with the client-only
|
|
26
|
+
* `uid` stripped from the wire payload.
|
|
27
|
+
*/
|
|
28
|
+
export function toMemberEntries(rows: readonly MemberRow[]): SlackMemberMappingEntry[] {
|
|
29
|
+
return rows
|
|
30
|
+
.filter((e) => e.userId.trim() && e.slackUserId.trim())
|
|
31
|
+
.map(({ uid: _uid, ...entry }) => entry)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap a stored wire entry as an editable row: stamp a stable `uid` and default the
|
|
36
|
+
* `role` (absent on older maps) so the initial load and the post-save reload produce
|
|
37
|
+
* identical rows rather than drifting on the default.
|
|
38
|
+
*/
|
|
39
|
+
export function toMemberRow(entry: SlackMemberMappingEntry, uid: string): MemberRow {
|
|
40
|
+
return { role: 'engineering', ...entry, uid }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A fresh, empty editable row stamped with the given stable `uid`. */
|
|
44
|
+
export function emptyMemberRow(uid: string): MemberRow {
|
|
45
|
+
return { uid, userId: '', slackUserId: '', role: 'engineering' }
|
|
46
|
+
}
|