@cat-factory/app 0.241.2 → 0.242.0
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/outcome/OutcomeSummaryWindow.vue +59 -45
- package/app/composables/api/spec.ts +14 -4
- package/app/docs/consumer-extensions.md +8 -0
- package/app/stores/serviceSpec.spec.ts +81 -0
- package/app/stores/serviceSpec.ts +55 -25
- package/app/utils/catalog.ts +7 -6
- package/app/utils/runOutcome.ts +38 -507
- package/i18n/locales/de.json +5 -6
- package/i18n/locales/en.json +5 -6
- package/i18n/locales/es.json +5 -6
- package/i18n/locales/fr.json +5 -6
- package/i18n/locales/he.json +5 -6
- package/i18n/locales/it.json +5 -6
- package/i18n/locales/ja.json +5 -6
- package/i18n/locales/pl.json +5 -6
- package/i18n/locales/tr.json +5 -6
- package/i18n/locales/uk.json +5 -6
- package/package.json +2 -2
- package/app/utils/runOutcome.spec.ts +0 -492
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// It composes NOTHING itself: `composeRunOutcome` (`~/utils/runOutcome`) is the pure reduction,
|
|
13
13
|
// so the rules that matter (a regression is an `established` requirement observed to fail; an
|
|
14
14
|
// absent producer never renders as a clean result) are unit-tested without mounting this. What
|
|
15
|
-
// lives here is presentation only, plus the ONE fetch the card owns: the
|
|
16
|
-
//
|
|
15
|
+
// lives here is presentation only, plus the ONE fetch the card owns: the spec THIS RUN was
|
|
16
|
+
// judged against, which turns the tester's requirement IDS into the TITLES a reader came for.
|
|
17
17
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
18
18
|
import type {
|
|
19
19
|
OutcomeCheckKind,
|
|
@@ -47,32 +47,43 @@ const { t } = useI18n()
|
|
|
47
47
|
const blobs = useArtifactBlobs()
|
|
48
48
|
onUnmounted(() => blobs.revokeAll())
|
|
49
49
|
|
|
50
|
-
// The shared seam contract.
|
|
51
|
-
|
|
52
|
-
// only show ids (which it then says, rather than letting an id read as a title).
|
|
53
|
-
const { open, blockId, instanceId, close } = useResultView('outcome', {
|
|
54
|
-
onOpen: (view) => {
|
|
55
|
-
const block = board.getBlock(view.blockId)
|
|
56
|
-
const service = block ? board.serviceOf(block) : undefined
|
|
57
|
-
if (service) void serviceSpec.load(service.id)
|
|
58
|
-
},
|
|
59
|
-
})
|
|
50
|
+
// The shared seam contract.
|
|
51
|
+
const { open, blockId, instanceId, close } = useResultView('outcome')
|
|
60
52
|
|
|
61
53
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
62
|
-
const
|
|
63
|
-
const instance = computed(() => {
|
|
54
|
+
const runId = computed(() => {
|
|
64
55
|
// The run carried by the opener, else the block's own live run: a card opened from a
|
|
65
56
|
// notification names the run, one opened from the board does not.
|
|
66
|
-
|
|
67
|
-
return id ? (execution.getInstance(id) ?? null) : null
|
|
57
|
+
return instanceId.value ?? block.value?.executionId ?? null
|
|
68
58
|
})
|
|
59
|
+
const instance = computed(() => (runId.value ? (execution.getInstance(runId.value) ?? null) : null))
|
|
60
|
+
|
|
61
|
+
// The ONE fetch this card owns: the spec THIS RUN was judged against. Requirement verdicts are
|
|
62
|
+
// keyed by the spec's own ids, and without the spec the coverage section can only show ids
|
|
63
|
+
// (which it then says, rather than letting an id read as a title).
|
|
64
|
+
//
|
|
65
|
+
// Keyed by the RUN, not by the enclosing service, and that is a correctness matter rather than a
|
|
66
|
+
// cache detail: the service read comes from the repo's default branch, so for as long as the
|
|
67
|
+
// run's pull request is open it is missing exactly the requirements the run added and the tester
|
|
68
|
+
// just ruled on. Every one of those verdicts joined against nothing and rendered as "not
|
|
69
|
+
// checked", and the card's counts contradicted `GET /api/v1/runs/:runId/outcome` for one run.
|
|
70
|
+
//
|
|
71
|
+
// A watch rather than the `onOpen` hook, because the run id can arrive after the block does (a
|
|
72
|
+
// card open on a task that starts a run) and the join must follow it.
|
|
73
|
+
watch(
|
|
74
|
+
runId,
|
|
75
|
+
(id) => {
|
|
76
|
+
if (id) void serviceSpec.loadForRun(id)
|
|
77
|
+
},
|
|
78
|
+
{ immediate: true },
|
|
79
|
+
)
|
|
69
80
|
|
|
70
81
|
const outcome = computed(() =>
|
|
71
82
|
block.value
|
|
72
83
|
? composeRunOutcome({
|
|
73
84
|
block: block.value,
|
|
74
85
|
instance: instance.value,
|
|
75
|
-
spec:
|
|
86
|
+
spec: runId.value ? serviceSpec.viewForRun(runId.value) : null,
|
|
76
87
|
})
|
|
77
88
|
: null,
|
|
78
89
|
)
|
|
@@ -110,6 +121,7 @@ const REQUIREMENTS_GAP_KEYS: Record<RequirementsGap, string> = {
|
|
|
110
121
|
no_tester_step: 'outcome.requirements.gap.no_tester_step',
|
|
111
122
|
tester_not_reported: 'outcome.requirements.gap.tester_not_reported',
|
|
112
123
|
no_verdicts: 'outcome.requirements.gap.no_verdicts',
|
|
124
|
+
no_requirements: 'outcome.requirements.gap.no_requirements',
|
|
113
125
|
}
|
|
114
126
|
const TESTS_GAP_KEYS: Record<TestsGap, string> = {
|
|
115
127
|
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
@@ -128,7 +140,6 @@ const VISUALS_GAP_KEYS: Record<VisualsGap, string> = {
|
|
|
128
140
|
*/
|
|
129
141
|
const SPEC_JOIN_KEYS: Record<Exclude<OutcomeSpecJoin, 'joined'>, string> = {
|
|
130
142
|
not_read: 'outcome.requirements.spec.not_read',
|
|
131
|
-
unmatched: 'outcome.requirements.spec.unmatched',
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
const VERDICT_META: Record<RequirementVerdictStatus, { color: string; key: string }> = {
|
|
@@ -186,9 +197,11 @@ const headerTitle = computed(() => outcome.value?.title ?? t('outcome.title'))
|
|
|
186
197
|
const disposition = computed(() => outcome.value?.disposition ?? 'not_run')
|
|
187
198
|
|
|
188
199
|
/**
|
|
189
|
-
* The note under the requirement counts when the
|
|
190
|
-
*
|
|
191
|
-
*
|
|
200
|
+
* The note under the requirement counts when the section was NOT counted against the service's
|
|
201
|
+
* `spec/`, null when it was. It is a statement about the DENOMINATOR, not about missing titles:
|
|
202
|
+
* an unjoined section counts only what the tester chose to rule on and says nothing about what it
|
|
203
|
+
* skipped. Resolved here so the `joined` exclusion is checked by the compiler once, rather than by
|
|
204
|
+
* a template condition that would silently render nothing if the union grew.
|
|
192
205
|
*/
|
|
193
206
|
const specNote = computed(() => {
|
|
194
207
|
const requirements = outcome.value?.requirements
|
|
@@ -198,19 +211,22 @@ const specNote = computed(() => {
|
|
|
198
211
|
return t(SPEC_JOIN_KEYS[requirements.spec])
|
|
199
212
|
})
|
|
200
213
|
|
|
214
|
+
/** The requirement rows, in the composer's severity-first order. */
|
|
215
|
+
const requirementRows = computed(() => {
|
|
216
|
+
const requirements = outcome.value?.requirements
|
|
217
|
+
return requirements?.status === 'reported' ? requirements.entries : []
|
|
218
|
+
})
|
|
219
|
+
|
|
201
220
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
221
|
+
* How many verdicts the tester returned against ids this service's `spec/` does not carry, or 0.
|
|
222
|
+
*
|
|
223
|
+
* Surfaced because the counts above are the SPEC's and this number is the difference between them
|
|
224
|
+
* and the tester's own tally. Unstated, a reader comparing the two reads the gap as one of the two
|
|
225
|
+
* being wrong, when it is really a spec that moved on under the tester.
|
|
206
226
|
*/
|
|
207
|
-
const
|
|
227
|
+
const unmatchedVerdicts = computed(() => {
|
|
208
228
|
const requirements = outcome.value?.requirements
|
|
209
|
-
|
|
210
|
-
return requirements.entries.map((entry) => ({
|
|
211
|
-
...entry,
|
|
212
|
-
idOnly: requirements.spec === 'joined' && entry.title === null,
|
|
213
|
-
}))
|
|
229
|
+
return requirements?.status === 'reported' ? requirements.unmatchedVerdicts : 0
|
|
214
230
|
})
|
|
215
231
|
|
|
216
232
|
/** The captured views, resolved to blobs as they arrive (the card shows them inline). */
|
|
@@ -373,8 +389,8 @@ function openTestReport() {
|
|
|
373
389
|
}}
|
|
374
390
|
</UBadge>
|
|
375
391
|
</div>
|
|
376
|
-
<!-- The
|
|
377
|
-
|
|
392
|
+
<!-- The coverage was not counted against the spec, so say what the numbers above do
|
|
393
|
+
and do not cover rather than letting them read as the whole picture. -->
|
|
378
394
|
<p
|
|
379
395
|
v-if="specNote"
|
|
380
396
|
class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
|
|
@@ -382,6 +398,16 @@ function openTestReport() {
|
|
|
382
398
|
>
|
|
383
399
|
{{ specNote }}
|
|
384
400
|
</p>
|
|
401
|
+
<!-- The tester ruled on ids the spec does not carry, so its own tally and the counts
|
|
402
|
+
above legitimately differ. Said out loud, because the alternative is a reader
|
|
403
|
+
deciding which of the two numbers to distrust. -->
|
|
404
|
+
<p
|
|
405
|
+
v-if="unmatchedVerdicts > 0"
|
|
406
|
+
class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
|
|
407
|
+
data-testid="outcome-unmatched-verdicts"
|
|
408
|
+
>
|
|
409
|
+
{{ t('outcome.requirements.unmatchedVerdicts', { count: unmatchedVerdicts }) }}
|
|
410
|
+
</p>
|
|
385
411
|
<ul class="space-y-1.5">
|
|
386
412
|
<li
|
|
387
413
|
v-for="req in requirementRows"
|
|
@@ -396,18 +422,6 @@ function openTestReport() {
|
|
|
396
422
|
<div class="min-w-0">
|
|
397
423
|
<div class="flex flex-wrap items-center gap-1.5">
|
|
398
424
|
<span class="text-[13px] text-slate-200">{{ req.title ?? req.id }}</span>
|
|
399
|
-
<!-- This row's id is standing in for a title the spec does not have for it,
|
|
400
|
-
beside rows that DO carry one. -->
|
|
401
|
-
<UBadge
|
|
402
|
-
v-if="req.idOnly"
|
|
403
|
-
color="neutral"
|
|
404
|
-
variant="subtle"
|
|
405
|
-
size="sm"
|
|
406
|
-
:title="t('outcome.requirements.idOnlyHint')"
|
|
407
|
-
data-testid="outcome-requirement-id-only"
|
|
408
|
-
>
|
|
409
|
-
{{ t('outcome.requirements.idOnly') }}
|
|
410
|
-
</UBadge>
|
|
411
425
|
<UBadge
|
|
412
426
|
v-if="req.regression"
|
|
413
427
|
color="error"
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import { getServiceSpecContract } from '@cat-factory/contracts'
|
|
1
|
+
import { getRunSpecContract, getServiceSpecContract } from '@cat-factory/contracts'
|
|
2
2
|
import type { ApiContext } from './context'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The two spec reads, which answer different questions and are paired here so a caller has to
|
|
6
|
+
* pick one deliberately.
|
|
7
|
+
*
|
|
8
|
+
* `getServiceSpec` reassembles the sharded `spec/` artifact from the service repo's DEFAULT
|
|
9
|
+
* branch: what the service requires today, for the inspector's "View Requirements" window.
|
|
10
|
+
* `getRunSpec` reads it from the branch ONE RUN pushed to: what that run's tester actually ruled
|
|
11
|
+
* against, for the outcome card's requirement join. While a pull request is open the two are
|
|
12
|
+
* different trees, and joining a run's verdicts against the first shows every requirement the
|
|
13
|
+
* run itself added as "not checked".
|
|
14
|
+
*
|
|
15
|
+
* Both are always 200: no spec, no repo or no VCS connected returns `{ present: false }`.
|
|
8
16
|
*/
|
|
9
17
|
export function specApi({ send, ws }: ApiContext) {
|
|
10
18
|
return {
|
|
11
19
|
getServiceSpec: (workspaceId: string, blockId: string) =>
|
|
12
20
|
send(getServiceSpecContract, { pathPrefix: ws(workspaceId), pathParams: { blockId } }),
|
|
21
|
+
getRunSpec: (workspaceId: string, executionId: string) =>
|
|
22
|
+
send(getRunSpecContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
|
|
13
23
|
}
|
|
14
24
|
}
|
|
@@ -229,6 +229,14 @@ Both links are absolute on purpose: this page ships inside the `@cat-factory/app
|
|
|
229
229
|
relative path out of it resolves only from a checkout, which is the one place its reader is not.
|
|
230
230
|
(`scripts/check-shipped-doc-links.mjs` fails CI on a shipped doc that grows one.)
|
|
231
231
|
|
|
232
|
+
The BACKEND half of an operation is registered with one dependency: the runtime facade you boot
|
|
233
|
+
through (`@cat-factory/node-server`, `@cat-factory/local-server` or `@cat-factory/worker`) exports
|
|
234
|
+
every registry constructor and every type a registration literal names. Do not reach below it for
|
|
235
|
+
`@cat-factory/kernel`, `@cat-factory/contracts` or `@cat-factory/prompt-fragments`: those publish at
|
|
236
|
+
EXACT versions, so floating one past what your facade pins resolves a second physical copy and your
|
|
237
|
+
registrations land where nothing reads them
|
|
238
|
+
([ADR 0044](https://github.com/kibertoad/cat-factory/blob/main/backend/docs/adr/0044-facade-extension-surface.md)).
|
|
239
|
+
|
|
232
240
|
The **same type can be delivered from the backend** instead of code-shipped: register it on the
|
|
233
241
|
deployment's app-owned `TaskTypeRegistry` and it arrives in the workspace snapshot's
|
|
234
242
|
`customTaskTypes`, folded into the SAME merged catalog (data over the wire, never components). The
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useServiceSpecStore } from '~/stores/serviceSpec'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { ServiceSpecView } from '~/types/spec'
|
|
5
|
+
|
|
6
|
+
// The store holds TWO spec reads, and which one a caller takes is a correctness matter rather
|
|
7
|
+
// than a caching detail. The service read is the repo's DEFAULT branch ("what does this service
|
|
8
|
+
// require", the inspector's requirements window); the run read is the branch ONE RUN pushed to
|
|
9
|
+
// ("what did this run rule on", the outcome card's requirement join). While a pull request is
|
|
10
|
+
// open they are different trees, and the card joining against the first showed every requirement
|
|
11
|
+
// the run itself added as "not checked" while `/api/v1/runs/:runId/outcome` said otherwise.
|
|
12
|
+
//
|
|
13
|
+
// So what is pinned here is that the two never answer for each other: one key space cannot serve
|
|
14
|
+
// the other's question even when a block id and an execution id happen to collide.
|
|
15
|
+
|
|
16
|
+
function view(service: string): ServiceSpecView {
|
|
17
|
+
return {
|
|
18
|
+
present: true,
|
|
19
|
+
spec: { service, summary: '', modules: [] },
|
|
20
|
+
features: [],
|
|
21
|
+
} as unknown as ServiceSpecView
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stubApi(over: Record<string, unknown> = {}) {
|
|
25
|
+
vi.stubGlobal('useApi', () => ({
|
|
26
|
+
getServiceSpec: () => Promise.resolve(view('from-default-branch')),
|
|
27
|
+
getRunSpec: () => Promise.resolve(view('from-run-branch')),
|
|
28
|
+
...over,
|
|
29
|
+
}))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
34
|
+
stubApi()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('serviceSpec store', () => {
|
|
38
|
+
it('reads a run’s spec from the run endpoint, not the service one', async () => {
|
|
39
|
+
const store = useServiceSpecStore()
|
|
40
|
+
await store.loadForRun('exec_1')
|
|
41
|
+
expect(store.viewForRun('exec_1')?.spec?.service).toBe('from-run-branch')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps the two reads in separate key spaces', async () => {
|
|
45
|
+
// Same id, two questions. Without the prefixes one load would satisfy the other's accessor
|
|
46
|
+
// and the card would silently join against the default branch again.
|
|
47
|
+
const store = useServiceSpecStore()
|
|
48
|
+
await store.load('id_1')
|
|
49
|
+
await store.loadForRun('id_1')
|
|
50
|
+
expect(store.viewFor('id_1')?.spec?.service).toBe('from-default-branch')
|
|
51
|
+
expect(store.viewForRun('id_1')?.spec?.service).toBe('from-run-branch')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('does not answer a run read out of the service cache', async () => {
|
|
55
|
+
const store = useServiceSpecStore()
|
|
56
|
+
await store.load('id_1')
|
|
57
|
+
expect(store.viewForRun('id_1')).toBeUndefined()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('coalesces overlapping loads of one run onto a single request', async () => {
|
|
61
|
+
let calls = 0
|
|
62
|
+
stubApi({
|
|
63
|
+
getRunSpec: () => {
|
|
64
|
+
calls += 1
|
|
65
|
+
return Promise.resolve(view('from-run-branch'))
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
const store = useServiceSpecStore()
|
|
69
|
+
await Promise.all([store.loadForRun('exec_1'), store.loadForRun('exec_1')])
|
|
70
|
+
expect(calls).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('records a failed run read without poisoning the cached view', async () => {
|
|
74
|
+
stubApi({ getRunSpec: () => Promise.reject(new Error('offline')) })
|
|
75
|
+
const store = useServiceSpecStore()
|
|
76
|
+
await store.loadForRun('exec_1')
|
|
77
|
+
// The card composes `spec: 'not_read'` off an absent view and says so; a fabricated empty
|
|
78
|
+
// view would have read as a service that declares nothing.
|
|
79
|
+
expect(store.viewForRun('exec_1')).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -4,69 +4,99 @@ import type { ServiceSpecView } from '~/types/spec'
|
|
|
4
4
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Service-spec read state
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Service-spec read state. The spec lives sharded in the service repo under `spec/`; the backend
|
|
8
|
+
* reassembles it and serves a {@link ServiceSpecView}. Read-only, fetched on demand, cached.
|
|
9
|
+
* Nothing is persisted client-side.
|
|
10
|
+
*
|
|
11
|
+
* TWO reads, because there are two questions and they have different answers while a pull
|
|
12
|
+
* request is open:
|
|
13
|
+
*
|
|
14
|
+
* - {@link load} / {@link viewFor}: what the SERVICE requires, from the repo's default branch,
|
|
15
|
+
* for the inspector's "View Requirements" window.
|
|
16
|
+
* - {@link loadForRun} / {@link viewForRun}: what ONE RUN was judged against, from the branch
|
|
17
|
+
* that run pushed to, for the outcome card's requirement join. The card used to read the
|
|
18
|
+
* first, so every verdict naming a requirement the run itself added joined against a spec
|
|
19
|
+
* that does not carry it yet and rendered as "not checked" — and the card's counts then
|
|
20
|
+
* disagreed with `GET /api/v1/runs/:runId/outcome`, which reads the run's branch.
|
|
21
|
+
*
|
|
22
|
+
* One cache, two key spaces: the entries are keyed by a PREFIXED id so a block id and an
|
|
23
|
+
* execution id can never collide, and every accessor mints its key through the same helper.
|
|
11
24
|
*/
|
|
12
25
|
export const useServiceSpecStore = defineStore('serviceSpec', () => {
|
|
13
26
|
const api = useApi()
|
|
14
27
|
const workspace = useWorkspaceStore()
|
|
15
28
|
|
|
16
|
-
|
|
29
|
+
const serviceKey = (blockId: string) => `service:${blockId}`
|
|
30
|
+
const runKey = (executionId: string) => `run:${executionId}`
|
|
31
|
+
|
|
32
|
+
/** The fetched view per cache key (undefined = not yet fetched). */
|
|
17
33
|
const views = ref<Record<string, ServiceSpecView>>({})
|
|
18
34
|
/**
|
|
19
|
-
* In-flight loads keyed by
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* in sync.
|
|
35
|
+
* In-flight loads keyed by cache key — the SINGLE source of truth for "is this loading". A
|
|
36
|
+
* reactive Map so `isLoading` (derived from `.has`) tracks set/delete, and it also coalesces
|
|
37
|
+
* overlapping loads onto one request: no separate loading-flag Set to keep in sync.
|
|
23
38
|
*/
|
|
24
39
|
const inFlight = reactive(new Map<string, Promise<void>>())
|
|
25
|
-
/**
|
|
26
|
-
const
|
|
40
|
+
/** Cache keys whose last fetch failed (network / unexpected error). */
|
|
41
|
+
const erroredByKey = ref<Set<string>>(new Set())
|
|
27
42
|
|
|
28
43
|
function viewFor(blockId: string): ServiceSpecView | undefined {
|
|
29
|
-
return views.value[blockId]
|
|
44
|
+
return views.value[serviceKey(blockId)]
|
|
45
|
+
}
|
|
46
|
+
function viewForRun(executionId: string): ServiceSpecView | undefined {
|
|
47
|
+
return views.value[runKey(executionId)]
|
|
30
48
|
}
|
|
31
49
|
function isLoading(blockId: string): boolean {
|
|
32
|
-
return inFlight.has(blockId)
|
|
50
|
+
return inFlight.has(serviceKey(blockId))
|
|
33
51
|
}
|
|
34
52
|
function isErrored(blockId: string): boolean {
|
|
35
|
-
return
|
|
53
|
+
return erroredByKey.value.has(serviceKey(blockId))
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
function setErrored(key: string, on: boolean) {
|
|
39
|
-
const next = new Set(
|
|
57
|
+
const next = new Set(erroredByKey.value)
|
|
40
58
|
if (on) next.add(key)
|
|
41
59
|
else next.delete(key)
|
|
42
|
-
|
|
60
|
+
erroredByKey.value = next
|
|
43
61
|
}
|
|
44
62
|
|
|
45
|
-
/** Fetch (and cache)
|
|
46
|
-
|
|
63
|
+
/** Fetch (and cache) one view, coalescing concurrent loads of the same key. */
|
|
64
|
+
function loadKeyed(key: string, fetch: (workspaceId: string) => Promise<ServiceSpecView>) {
|
|
47
65
|
if (!workspace.workspaceId) return
|
|
48
|
-
const pending = inFlight.get(
|
|
66
|
+
const pending = inFlight.get(key)
|
|
49
67
|
if (pending) return pending
|
|
50
|
-
setErrored(
|
|
68
|
+
setErrored(key, false)
|
|
51
69
|
const promise = (async () => {
|
|
52
70
|
try {
|
|
53
|
-
const view = await
|
|
54
|
-
views.value = { ...views.value, [
|
|
71
|
+
const view = await fetch(workspace.requireId())
|
|
72
|
+
views.value = { ...views.value, [key]: view }
|
|
55
73
|
} catch {
|
|
56
|
-
setErrored(
|
|
74
|
+
setErrored(key, true)
|
|
57
75
|
} finally {
|
|
58
|
-
inFlight.delete(
|
|
76
|
+
inFlight.delete(key)
|
|
59
77
|
}
|
|
60
78
|
})()
|
|
61
|
-
inFlight.set(
|
|
79
|
+
inFlight.set(key, promise)
|
|
62
80
|
return promise
|
|
63
81
|
}
|
|
64
82
|
|
|
83
|
+
/** Fetch the DEFAULT-branch spec view for a service frame block. */
|
|
84
|
+
async function load(blockId: string) {
|
|
85
|
+
return loadKeyed(serviceKey(blockId), (workspaceId) => api.getServiceSpec(workspaceId, blockId))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fetch the spec view ONE RUN was judged against, from that run's own branch. */
|
|
89
|
+
async function loadForRun(executionId: string) {
|
|
90
|
+
return loadKeyed(runKey(executionId), (workspaceId) => api.getRunSpec(workspaceId, executionId))
|
|
91
|
+
}
|
|
92
|
+
|
|
65
93
|
return {
|
|
66
94
|
views,
|
|
67
95
|
viewFor,
|
|
96
|
+
viewForRun,
|
|
68
97
|
isLoading,
|
|
69
98
|
isErrored,
|
|
70
99
|
load,
|
|
100
|
+
loadForRun,
|
|
71
101
|
}
|
|
72
102
|
})
|
package/app/utils/catalog.ts
CHANGED
|
@@ -523,13 +523,14 @@ export function isConsensusEligibleKind(kind: string): boolean {
|
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
/**
|
|
526
|
-
* Whether an agent kind is one of the Tester gate kinds (API or UI).
|
|
527
|
-
*
|
|
528
|
-
*
|
|
526
|
+
* Whether an agent kind is one of the Tester gate kinds (API or UI). Used by the pipeline builder
|
|
527
|
+
* to surface the test quality-control companion toggle only on Tester steps.
|
|
528
|
+
*
|
|
529
|
+
* Re-exported from `@cat-factory/contracts` rather than mirrored: it used to be a hand-written
|
|
530
|
+
* copy of the engine's rule with the two slugs spelled out as literals, which is the shape that
|
|
531
|
+
* silently stops matching the day a third tester kind ships.
|
|
529
532
|
*/
|
|
530
|
-
export
|
|
531
|
-
return kind === 'tester-api' || kind === 'tester-ui'
|
|
532
|
-
}
|
|
533
|
+
export { isTesterKind } from '@cat-factory/contracts'
|
|
533
534
|
|
|
534
535
|
/**
|
|
535
536
|
* Display metadata for the engine-driven "system" kinds — the gate/automation
|