@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
|
@@ -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
|
|
@@ -92,4 +92,92 @@ describe('provisioningLogs store — loadForExecution', () => {
|
|
|
92
92
|
expect(store.byExecution.exec1!.entries).toHaveLength(0)
|
|
93
93
|
expect(store.byExecution.exec1!.error).toBe('503')
|
|
94
94
|
})
|
|
95
|
+
|
|
96
|
+
it('a slower stale load never clobbers a newer one (monotonic guard)', async () => {
|
|
97
|
+
// Two loads race: the FIRST-issued resolves LAST with a stale timeline. Without the guard its
|
|
98
|
+
// `s.entries = entries` would overwrite the fresher second result — a card/row would vanish.
|
|
99
|
+
const deferred: Array<(r: { entries: ProvisioningLogEntry[] }) => void> = []
|
|
100
|
+
vi.stubGlobal('useApi', () => ({
|
|
101
|
+
listProvisioningLogs: () =>
|
|
102
|
+
new Promise<{ entries: ProvisioningLogEntry[] }>((res) => deferred.push(res)),
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
const store = useProvisioningLogsStore()
|
|
106
|
+
const first = store.loadForExecution('exec1', { silent: true }) // issued #1 (stale)
|
|
107
|
+
const second = store.loadForExecution('exec1', { silent: true }) // issued #2 (fresh)
|
|
108
|
+
|
|
109
|
+
// Resolve NEWEST first, then the older/staler one.
|
|
110
|
+
deferred[1]!({ entries: [entry({ id: 'fresh', operation: 'release' })] })
|
|
111
|
+
deferred[0]!({ entries: [entry({ id: 'stale', operation: 'dispatch' })] })
|
|
112
|
+
await Promise.all([first, second])
|
|
113
|
+
|
|
114
|
+
// The fresher result survives — the stale late-resolver was discarded.
|
|
115
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
116
|
+
expect(store.byExecution.exec1!.entries[0]!.id).toBe('fresh')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('a superseding visible load owns the final entries and clears the spinner', async () => {
|
|
120
|
+
// A visible load in flight, then a newer visible load; the OLDER resolves last and must neither
|
|
121
|
+
// clobber the entries nor leave a stuck spinner.
|
|
122
|
+
const deferred: Array<(r: { entries: ProvisioningLogEntry[] }) => void> = []
|
|
123
|
+
vi.stubGlobal('useApi', () => ({
|
|
124
|
+
listProvisioningLogs: () =>
|
|
125
|
+
new Promise<{ entries: ProvisioningLogEntry[] }>((res) => deferred.push(res)),
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
const store = useProvisioningLogsStore()
|
|
129
|
+
const first = store.loadForExecution('exec1')
|
|
130
|
+
const second = store.loadForExecution('exec1')
|
|
131
|
+
|
|
132
|
+
deferred[1]!({ entries: [entry({ id: 'fresh' })] })
|
|
133
|
+
deferred[0]!({ entries: [entry({ id: 'stale' })] })
|
|
134
|
+
await Promise.all([first, second])
|
|
135
|
+
|
|
136
|
+
expect(store.byExecution.exec1!.entries[0]!.id).toBe('fresh')
|
|
137
|
+
expect(store.byExecution.exec1!.loading).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("evict drops a run's accumulated state (map does not grow unbounded)", async () => {
|
|
141
|
+
vi.stubGlobal('useApi', () => ({
|
|
142
|
+
listProvisioningLogs: () => Promise.resolve({ entries: [entry()] }),
|
|
143
|
+
}))
|
|
144
|
+
|
|
145
|
+
const store = useProvisioningLogsStore()
|
|
146
|
+
await store.loadForExecution('exec1')
|
|
147
|
+
expect(store.byExecution.exec1).toBeDefined()
|
|
148
|
+
|
|
149
|
+
store.evict('exec1')
|
|
150
|
+
expect(store.byExecution.exec1).toBeUndefined()
|
|
151
|
+
|
|
152
|
+
// After eviction a fresh load re-seeds cleanly (the drawer re-fetches on re-mount) — and the
|
|
153
|
+
// per-execution ticket record was dropped too, so the guard still holds for the new lifecycle.
|
|
154
|
+
await store.loadForExecution('exec1')
|
|
155
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('a load in flight when evict runs cannot resurrect stale state after the drawer re-opens', async () => {
|
|
159
|
+
// Close-then-reopen while the pre-close fetch is still pending: the drawer unmounts (evict), then
|
|
160
|
+
// re-mounts and issues a fresh load, then the OLD pre-evict fetch finally resolves. With a global
|
|
161
|
+
// (never-reset) load ticket the re-opened load out-ranks the straggler, so the stale result is
|
|
162
|
+
// discarded rather than clobbering the fresh timeline (a per-execution counter reset on evict
|
|
163
|
+
// would let the two collide on the same seq and the straggler would win).
|
|
164
|
+
const deferred: Array<(r: { entries: ProvisioningLogEntry[] }) => void> = []
|
|
165
|
+
vi.stubGlobal('useApi', () => ({
|
|
166
|
+
listProvisioningLogs: () =>
|
|
167
|
+
new Promise<{ entries: ProvisioningLogEntry[] }>((res) => deferred.push(res)),
|
|
168
|
+
}))
|
|
169
|
+
|
|
170
|
+
const store = useProvisioningLogsStore()
|
|
171
|
+
const preEvict = store.loadForExecution('exec1', { silent: true }) // issued before close
|
|
172
|
+
store.evict('exec1') // drawer unmounts mid-flight
|
|
173
|
+
const reopened = store.loadForExecution('exec1', { silent: true }) // drawer re-opens, fresh load
|
|
174
|
+
|
|
175
|
+
// The re-opened load resolves first (fresh), then the stale pre-evict straggler.
|
|
176
|
+
deferred[1]!({ entries: [entry({ id: 'fresh', operation: 'release' })] })
|
|
177
|
+
deferred[0]!({ entries: [entry({ id: 'stale', operation: 'dispatch' })] })
|
|
178
|
+
await Promise.all([preEvict, reopened])
|
|
179
|
+
|
|
180
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
181
|
+
expect(store.byExecution.exec1!.entries[0]!.id).toBe('fresh')
|
|
182
|
+
})
|
|
95
183
|
})
|
|
@@ -30,6 +30,17 @@ export const useProvisioningLogsStore = defineStore('provisioningLogs', () => {
|
|
|
30
30
|
container: emptyState(),
|
|
31
31
|
})
|
|
32
32
|
const byExecution = reactive<Record<string, LogState>>({})
|
|
33
|
+
// Monotonic load-ordering guard: the drawer's silent background poll and a manual/visible refresh
|
|
34
|
+
// can be in flight at once, and each ends in a REPLACE-style `s.entries = entries`. Without ordering
|
|
35
|
+
// a slower/staler fetch resolving AFTER a newer one clobbers the fresher timeline (the same
|
|
36
|
+
// out-of-order-overwrite hazard the CLAUDE.md live-push rules warn about, and that
|
|
37
|
+
// stores/workspace.ts guards its full refresh with). Each load takes a ticket from a single
|
|
38
|
+
// ever-increasing counter; `latestLoad` records the newest ticket issued per execution, and only
|
|
39
|
+
// that load commits. The counter is GLOBAL (never reset on `evict`) so a drawer re-opened for an
|
|
40
|
+
// evicted execution always out-ranks any still-in-flight prior load — no seq collision can let a
|
|
41
|
+
// stale fetch win. NOT reactive — pure bookkeeping the UI never reads.
|
|
42
|
+
let loadTicket = 0
|
|
43
|
+
const latestLoad = new Map<string, number>()
|
|
33
44
|
|
|
34
45
|
async function load(subsystem: ProvisioningSubsystem) {
|
|
35
46
|
const ws = useWorkspaceStore()
|
|
@@ -60,23 +71,45 @@ export const useProvisioningLogsStore = defineStore('provisioningLogs', () => {
|
|
|
60
71
|
s.loading = true
|
|
61
72
|
s.error = null
|
|
62
73
|
}
|
|
74
|
+
const seq = ++loadTicket
|
|
75
|
+
latestLoad.set(executionId, seq)
|
|
63
76
|
try {
|
|
64
77
|
const { entries } = await api.listProvisioningLogs(ws.requireId(), {
|
|
65
78
|
executionId,
|
|
66
79
|
limit: 200,
|
|
67
80
|
})
|
|
81
|
+
// A newer load (silent poll or manual refresh) superseded this one while it was in flight —
|
|
82
|
+
// discard this staler result so it can't clobber the fresher timeline.
|
|
83
|
+
if (latestLoad.get(executionId) !== seq) return
|
|
68
84
|
s.entries = entries
|
|
69
85
|
s.error = null
|
|
70
86
|
} catch (err) {
|
|
87
|
+
if (latestLoad.get(executionId) !== seq) return
|
|
71
88
|
// A background poll keeps the last snapshot on a blip; only a visible load reports.
|
|
72
89
|
if (!opts?.silent) {
|
|
73
90
|
s.error = err instanceof Error ? err.message : 'Failed to load logs'
|
|
74
91
|
s.entries = []
|
|
75
92
|
}
|
|
76
93
|
} finally {
|
|
94
|
+
// The visible spinner is owned by the visible request that turned it on, so clear it in its
|
|
95
|
+
// own finally regardless of supersession — a superseded load still ends its own spinner.
|
|
77
96
|
if (!opts?.silent) s.loading = false
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
99
|
|
|
81
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Drop a run's accumulated log state (called by the drawer on unmount). `byExecution` would
|
|
102
|
+
* otherwise accrete one entry per execution viewed and never evict — a slow memory creep across
|
|
103
|
+
* a long board session. The drawer re-fetches on re-mount, so dropping a closed run's state is
|
|
104
|
+
* free; keeping it while OPEN is what the manual-refresh-after-terminal affordance relies on.
|
|
105
|
+
*/
|
|
106
|
+
function evict(executionId: string) {
|
|
107
|
+
delete byExecution[executionId]
|
|
108
|
+
// Drop the per-execution ticket record too (its map must not grow unbounded either). The global
|
|
109
|
+
// `loadTicket` counter is deliberately NOT reset, so a re-opened drawer still out-ranks any load
|
|
110
|
+
// that was in flight when this ran.
|
|
111
|
+
latestLoad.delete(executionId)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { bySubsystem, byExecution, load, loadForExecution, evict }
|
|
82
115
|
})
|
package/i18n/locales/de.json
CHANGED
|
@@ -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",
|
package/i18n/locales/en.json
CHANGED
|
@@ -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",
|
package/i18n/locales/es.json
CHANGED
|
@@ -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",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -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",
|
package/i18n/locales/he.json
CHANGED
|
@@ -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": "בריאות הצינור",
|
package/i18n/locales/it.json
CHANGED
|
@@ -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",
|