@cat-factory/app 0.115.0 → 0.115.1
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.
|
@@ -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).
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.115.
|
|
3
|
+
"version": "0.115.1",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|