@cat-factory/app 0.208.2 → 0.210.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.
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useCapabilityCredentialsStore } from '~/stores/capabilityCredentials'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { CapabilityCredentialsView } from '~/types/capabilityCredentials'
5
+
6
+ /**
7
+ * Two behaviours carry this store, and both are about a list that is empty for more than one
8
+ * reason:
9
+ *
10
+ * - the probe. A 503 ("no encryption key on this deployment") and a 403 ("you may not manage
11
+ * secrets") are ANSWERS and resolve normally, hiding the tab; anything else propagates,
12
+ * because the panel is the surface that can tell a reader the list could not be fetched.
13
+ * - `hasSurface`. An empty checklist with a COMPLETE declaration read means this deployment
14
+ * registers no capability that wants a credential, so there is nothing to type; the same
15
+ * empty checklist with an INCOMPLETE read is an outage the panel has to state.
16
+ */
17
+ function view(over: Partial<CapabilityCredentialsView> = {}): CapabilityCredentialsView {
18
+ return {
19
+ declared: [],
20
+ orphaned: [],
21
+ environmentFallback: true,
22
+ declarationsIncomplete: false,
23
+ ...over,
24
+ }
25
+ }
26
+
27
+ function declared(key: string, stored = false) {
28
+ return {
29
+ key,
30
+ declaredBy: [{ subject: 'tool-server' as const, id: 'srv', label: 'Search' }],
31
+ required: true,
32
+ stored,
33
+ ...(stored ? { updatedAt: 1000 } : {}),
34
+ }
35
+ }
36
+
37
+ describe('capabilityCredentials store', () => {
38
+ beforeEach(() => {
39
+ useWorkspaceStore().workspaceId = 'ws1'
40
+ })
41
+
42
+ it('load stores the view and marks the surface available', async () => {
43
+ vi.stubGlobal('useApi', () => ({
44
+ getCapabilityCredentials: () => Promise.resolve(view({ declared: [declared('SEARCH_KEY')] })),
45
+ }))
46
+
47
+ const store = useCapabilityCredentialsStore()
48
+ await store.load()
49
+
50
+ expect(store.available).toBe(true)
51
+ expect(store.hasSurface).toBe(true)
52
+ expect(store.loading).toBe(false)
53
+ })
54
+
55
+ it.each([503, 403])(
56
+ 'a definitive %i latches the surface unavailable without throwing',
57
+ async (statusCode) => {
58
+ // 503: the deployment has no encryption key. 403: this caller may not manage secrets, and
59
+ // the READ is gated too, because the view names the credential keys the deployment wants.
60
+ // Both hide the tab rather than disabling it.
61
+ vi.stubGlobal('useApi', () => ({
62
+ getCapabilityCredentials: () => Promise.reject({ statusCode }),
63
+ }))
64
+
65
+ const store = useCapabilityCredentialsStore()
66
+ await expect(store.load()).resolves.toBeUndefined()
67
+
68
+ expect(store.available).toBe(false)
69
+ expect(store.view).toBeNull()
70
+ expect(store.hasSurface).toBe(false)
71
+ },
72
+ )
73
+
74
+ it('a transient failure propagates and leaves `available` null so the probe stays retryable', async () => {
75
+ vi.stubGlobal('useApi', () => ({
76
+ getCapabilityCredentials: () => Promise.reject({ statusCode: 500 }),
77
+ }))
78
+
79
+ const store = useCapabilityCredentialsStore()
80
+ await expect(store.load()).rejects.toMatchObject({ statusCode: 500 })
81
+
82
+ expect(store.available).toBeNull()
83
+ expect(store.loading).toBe(false)
84
+ })
85
+
86
+ it('offers no surface when nothing is declared and nothing is stored', async () => {
87
+ vi.stubGlobal('useApi', () => ({ getCapabilityCredentials: () => Promise.resolve(view()) }))
88
+
89
+ const store = useCapabilityCredentialsStore()
90
+ await store.load()
91
+
92
+ // The panel is a checklist projected from the deployment's registered capabilities. With
93
+ // none, there is no credential to type and the tab would be a dead end.
94
+ expect(store.available).toBe(true)
95
+ expect(store.hasSurface).toBe(false)
96
+ })
97
+
98
+ it('keeps the surface when the declaration read failed, even with both lists empty', async () => {
99
+ vi.stubGlobal('useApi', () => ({
100
+ getCapabilityCredentials: () => Promise.resolve(view({ declarationsIncomplete: true })),
101
+ }))
102
+
103
+ const store = useCapabilityCredentialsStore()
104
+ await store.load()
105
+
106
+ // An unreadable list and an empty one are the same list and opposite facts. Hiding the tab
107
+ // here would render someone else's outage as "this deployment needs no credentials".
108
+ expect(store.hasSurface).toBe(true)
109
+ })
110
+
111
+ it('keeps the surface for an orphan nothing declares any more', async () => {
112
+ vi.stubGlobal('useApi', () => ({
113
+ getCapabilityCredentials: () =>
114
+ Promise.resolve(view({ orphaned: [{ key: 'OLD_KEY', updatedAt: 1000 }] })),
115
+ }))
116
+
117
+ const store = useCapabilityCredentialsStore()
118
+ await store.load()
119
+
120
+ // A live secret nobody will ever ask for. The tab is the only place it can be removed.
121
+ expect(store.hasSurface).toBe(true)
122
+ })
123
+
124
+ it('saves ONE key and adopts the returned view', async () => {
125
+ const calls: { key: string; value: string }[] = []
126
+ vi.stubGlobal('useApi', () => ({
127
+ getCapabilityCredentials: () => Promise.resolve(view({ declared: [declared('SEARCH_KEY')] })),
128
+ setCapabilityCredential: (_ws: string, key: string, value: string) => {
129
+ calls.push({ key, value })
130
+ return Promise.resolve(view({ declared: [declared('SEARCH_KEY', true)] }))
131
+ },
132
+ }))
133
+
134
+ const store = useCapabilityCredentialsStore()
135
+ await store.load()
136
+ await store.save('SEARCH_KEY', 'sk-live')
137
+
138
+ // Per KEY, never a set-replacing write: this client never received the other values, so a
139
+ // whole-set save would delete every credential the operator did not retype.
140
+ expect(calls).toEqual([{ key: 'SEARCH_KEY', value: 'sk-live' }])
141
+ expect(store.view?.declared[0]?.stored).toBe(true)
142
+ })
143
+
144
+ it('re-reads after a delete rather than patching the row out locally', async () => {
145
+ let stored = true
146
+ vi.stubGlobal('useApi', () => ({
147
+ getCapabilityCredentials: () =>
148
+ Promise.resolve(view({ declared: [declared('SEARCH_KEY', stored)] })),
149
+ deleteCapabilityCredential: () => {
150
+ stored = false
151
+ return Promise.resolve(undefined)
152
+ },
153
+ }))
154
+
155
+ const store = useCapabilityCredentialsStore()
156
+ await store.load()
157
+ await store.remove('SEARCH_KEY')
158
+
159
+ // The DELETE answers 204, and the declared half is deployment state this client does not
160
+ // own: a locally-patched row would drift from it the moment the deployment changed.
161
+ expect(store.view?.declared[0]?.stored).toBe(false)
162
+ })
163
+ })
@@ -0,0 +1,102 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { CapabilityCredentialsView } from '~/types/capabilityCredentials'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+ import { apiErrorStatus } from '~/composables/api/errors'
6
+
7
+ /**
8
+ * The workspace's capability credentials: the sealed, tenant-scoped values behind the secrets a
9
+ * registered tool server (MCP) or generative binary integration declares BY NAME. Values are
10
+ * write-only — the store only ever holds the view, which pairs the deployment's DECLARATIONS with
11
+ * which of them this workspace has stored. Loaded on demand (the Infrastructure window's
12
+ * "Capability credentials" tab, whose existence gates on the probe below), not from the snapshot.
13
+ *
14
+ * Writes are PER KEY. A whole-set write exists on the API for a caller declaring a set at once,
15
+ * and this store cannot use it: it never receives the values, so replacing the set would delete
16
+ * every credential the operator did not retype in this sitting.
17
+ */
18
+ export const useCapabilityCredentialsStore = defineStore('capabilityCredentials', () => {
19
+ const api = useApi()
20
+
21
+ const view = ref<CapabilityCredentialsView | null>(null)
22
+ const loading = ref(false)
23
+ // Mirrors the backend's two definitive refusals: the module 503s with no encryption key, and
24
+ // the whole surface (the READ included) is `secrets.manage`-gated, so a member without it gets
25
+ // a 403. `null` until first probed, then `true`/`false`. Both answers hide the tab rather than
26
+ // disabling it — a member who cannot manage secrets has no business learning which environment
27
+ // variables the deployment's capabilities want, which is the very content of this view.
28
+ const available = ref<boolean | null>(null)
29
+ let inFlight: Promise<void> | null = null
30
+
31
+ /**
32
+ * Whether there is anything to show. The panel is a CHECKLIST projected from the deployment's
33
+ * registered capabilities, so with nothing declared, nothing orphaned and a complete read there
34
+ * is no credential to type and no tab worth rendering.
35
+ *
36
+ * `declarationsIncomplete` keeps the surface even when both lists are empty, because then the
37
+ * emptiness is an OUTAGE (`BinaryGeneratorSource` throws rather than answering an empty set)
38
+ * rather than an answer, and hiding the tab would render the outage as "this deployment needs
39
+ * no credentials".
40
+ */
41
+ const hasSurface = computed(
42
+ () =>
43
+ view.value !== null &&
44
+ (view.value.declared.length > 0 ||
45
+ view.value.orphaned.length > 0 ||
46
+ view.value.declarationsIncomplete),
47
+ )
48
+
49
+ /** Force a refresh of the view (used after a save/remove). */
50
+ async function load() {
51
+ const ws = useWorkspaceStore()
52
+ loading.value = true
53
+ try {
54
+ view.value = await api.getCapabilityCredentials(ws.requireId())
55
+ available.value = true
56
+ } catch (err) {
57
+ const status = apiErrorStatus(err)
58
+ if (status === 503 || status === 403) {
59
+ // Definitive answers, not failures: the module is unconfigured, or this caller may not
60
+ // manage secrets. Hide the entry point and stop probing; resolve normally.
61
+ available.value = false
62
+ view.value = null
63
+ return
64
+ }
65
+ // Any other failure (transient 5xx / network) leaves the state untouched: it must not hide
66
+ // an already-available panel nor cache a false "unavailable", and `available` stays `null`
67
+ // when never probed so `ensureLoaded` remains retryable. The error PROPAGATES, because the
68
+ // panel is the one surface that can tell a reader it is looking at a list we could not
69
+ // fetch — the PROBE caller swallows instead (a failed probe means no tab, not a broken
70
+ // window). Same split as the package-registries store.
71
+ throw err
72
+ } finally {
73
+ loading.value = false
74
+ }
75
+ }
76
+
77
+ /** Load once and share the result (coalescing concurrent callers); `load()` refreshes. */
78
+ async function ensureLoaded() {
79
+ if (available.value !== null) return
80
+ if (!inFlight) inFlight = load().finally(() => (inFlight = null))
81
+ return inFlight
82
+ }
83
+
84
+ /** Store ONE credential's value, leaving every other stored key as it is. */
85
+ async function save(key: string, value: string) {
86
+ const ws = useWorkspaceStore()
87
+ view.value = await api.setCapabilityCredential(ws.requireId(), key, value)
88
+ available.value = true
89
+ }
90
+
91
+ /** Remove ONE stored credential (a rotated key, or an orphan nothing declares any more). */
92
+ async function remove(key: string) {
93
+ const ws = useWorkspaceStore()
94
+ await api.deleteCapabilityCredential(ws.requireId(), key)
95
+ // The DELETE answers 204, so the view is re-read rather than patched locally: the declared
96
+ // half is deployment state this client does not own, and a locally-patched row would drift
97
+ // from it the moment the deployment changed.
98
+ await load()
99
+ }
100
+
101
+ return { view, loading, available, hasSurface, load, ensureLoaded, save, remove }
102
+ })
@@ -2,6 +2,7 @@ import { ref } from 'vue'
2
2
  import { useExecutionStore } from '~/stores/execution'
3
3
  import { agentKindMeta } from '~/utils/catalog'
4
4
  import { dedicatedParkView } from '~/utils/pipelineRender'
5
+ import { createRunStepOpeners } from './runStepOpeners'
5
6
 
6
7
  /**
7
8
  * The step-inspection / result-view slice of the UI store: the dedicated result-view overlay
@@ -126,84 +127,16 @@ export function createUiResultViews() {
126
127
  function openInitiativePlanning(blockId: string) {
127
128
  resultView.value = { view: 'initiative-planning', blockId, instanceId: null, stepIndex: null }
128
129
  }
129
- // Open the Follow-up companion window for a run's Coder step (the blinking chip + the
130
- // `followup_pending` notification). Resolves the Coder step index from the run when not
131
- // given, so callers that only know the run can still open it.
132
- function openFollowUps(instanceId: string, stepIndex: number | null = null) {
133
- const execution = useExecutionStore()
134
- const instance = execution.getInstance(instanceId)
135
- if (!instance) return
136
- // A pipeline may carry more than one follow-up-enabled Coder step, so don't blindly pick
137
- // the first when no index is given: prefer the step that still has undecided items (the
138
- // one the run is parked on), else the current step, else the first enabled one.
139
- const resolveIdx = () => {
140
- const pending = instance.steps.findIndex(
141
- (s) => s.followUps?.enabled && s.followUps.items.some((i) => i.status === 'pending'),
142
- )
143
- if (pending >= 0) return pending
144
- const current = instance.steps[instance.currentStep]
145
- if (current?.followUps?.enabled) return instance.currentStep
146
- return instance.steps.findIndex((s) => s.followUps?.enabled)
147
- }
148
- const idx = stepIndex ?? resolveIdx()
149
- if (idx < 0) return
150
- resultView.value = {
151
- view: 'follow-ups',
152
- blockId: instance.blockId,
153
- instanceId,
154
- stepIndex: idx,
155
- }
156
- }
157
- // Open the implementation-fork decision window for a run's coder step (from the inspector /
158
- // pipeline chip / `fork_decision_pending` notification). Resolves the coder step index from
159
- // the run when not given, preferring the step parked awaiting a choice.
160
- function openForkDecision(instanceId: string, stepIndex: number | null = null) {
161
- const execution = useExecutionStore()
162
- const instance = execution.getInstance(instanceId)
163
- if (!instance) return
164
- const resolveIdx = () => {
165
- const awaiting = instance.steps.findIndex(
166
- (s) => s.agentKind === 'coder' && s.forkDecision?.status === 'awaiting_choice',
167
- )
168
- if (awaiting >= 0) return awaiting
169
- const current = instance.steps[instance.currentStep]
170
- if (current?.agentKind === 'coder' && current.forkDecision) return instance.currentStep
171
- return instance.steps.findIndex((s) => s.agentKind === 'coder' && s.forkDecision)
172
- }
173
- const idx = stepIndex ?? resolveIdx()
174
- if (idx < 0) return
175
- resultView.value = {
176
- view: 'fork-decision',
177
- blockId: instance.blockId,
178
- instanceId,
179
- stepIndex: idx,
180
- }
181
- }
182
- // Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
183
- // notification / the step). Resolves the step index from the run when not given, preferring
184
- // the step parked awaiting a finding selection.
185
- function openPrReview(instanceId: string, stepIndex: number | null = null) {
186
- const execution = useExecutionStore()
187
- const instance = execution.getInstance(instanceId)
188
- if (!instance) return
189
- const resolveIdx = () => {
190
- const awaiting = instance.steps.findIndex(
191
- (s) => s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection',
192
- )
193
- if (awaiting >= 0) return awaiting
194
- const current = instance.steps[instance.currentStep]
195
- if (current?.agentKind === 'pr-reviewer' && current.prReview) return instance.currentStep
196
- return instance.steps.findIndex((s) => s.agentKind === 'pr-reviewer' && s.prReview)
197
- }
198
- const idx = stepIndex ?? resolveIdx()
199
- if (idx < 0) return
200
- resultView.value = {
201
- view: 'pr-review',
202
- blockId: instance.blockId,
203
- instanceId,
204
- stepIndex: idx,
205
- }
206
- }
130
+ // The run-scoped openers (a caller that knows only the RUN, so the step index has to be
131
+ // resolved) live in a sibling module: they share one shape and one hazard, and lifting them out
132
+ // keeps this factory inside its per-function line budget. Their two seams are bound here.
133
+ const { openFollowUps, openForkDecision, openPrReview, openTestEvidence } = createRunStepOpeners({
134
+ dispatchStepView: (instanceId, stepIndex) => dispatchStepView(instanceId, stepIndex),
135
+ setResultView: (view, instance, stepIndex) => {
136
+ resultView.value = { view, blockId: instance.blockId, instanceId: instance.id, stepIndex }
137
+ },
138
+ })
139
+
207
140
  function closeResultView() {
208
141
  resultView.value = null
209
142
  }
@@ -243,6 +176,7 @@ export function createUiResultViews() {
243
176
  openFollowUps,
244
177
  openForkDecision,
245
178
  openPrReview,
179
+ openTestEvidence,
246
180
  closeResultView,
247
181
  closeRequirementReview,
248
182
  openStepDetail,
@@ -0,0 +1,143 @@
1
+ import { useExecutionStore } from '~/stores/execution'
2
+ import { isTesterKind } from '~/utils/catalog'
3
+ import type { ExecutionInstance, PipelineStep } from '~/types/domain'
4
+
5
+ /**
6
+ * The RUN-SCOPED window openers: entry points that know only which run to open, and have to
7
+ * resolve WHICH step of it the caller means before dispatching.
8
+ *
9
+ * They share one shape (find the step, bail if the run has none, open its window) and one hazard:
10
+ * a pipeline may carry the same agent kind more than once, so "the first matching step" is the
11
+ * wrong answer for every one of them. Each resolver states which of its candidates is the one a
12
+ * human was pointed at (the step parked awaiting a decision, the one that actually reported), and
13
+ * the callers that DO know the index still pass it and skip the resolution entirely.
14
+ *
15
+ * Extracted from `resultViews.ts` when the `test-evidence` opener pushed that factory over its
16
+ * per-function line budget: the budget is a split trigger, and this is the cohesive seam it named.
17
+ * Purely a move plus the new opener; the store's public surface is unchanged.
18
+ */
19
+ export interface RunStepOpenerDeps {
20
+ /** Open a step's bespoke window through the universal routing seam. */
21
+ dispatchStepView: (instanceId: string, stepIndex: number) => void
22
+ /** Set the result-view overlay directly, for a window the routing seam does not select. */
23
+ setResultView: (view: string, instance: ExecutionInstance, stepIndex: number) => void
24
+ }
25
+
26
+ /** Index of the first step matching `predicate`, or -1. */
27
+ function indexOf(instance: ExecutionInstance, predicate: (step: PipelineStep) => boolean): number {
28
+ return instance.steps.findIndex(predicate)
29
+ }
30
+
31
+ export function createRunStepOpeners(deps: RunStepOpenerDeps) {
32
+ /**
33
+ * Resolve the run, then the step index (the caller's when given, else `resolve`'s), then hand
34
+ * both to `open`. A run the store has not hydrated, or a pipeline with no candidate step, is a
35
+ * silent no-op: these are notification and deep-link entry points, and failing loudly at one
36
+ * would strand a user on a blank overlay.
37
+ */
38
+ function withStep(
39
+ instanceId: string,
40
+ stepIndex: number | null,
41
+ resolve: (instance: ExecutionInstance) => number,
42
+ open: (instance: ExecutionInstance, idx: number) => void,
43
+ ): void {
44
+ const instance = useExecutionStore().getInstance(instanceId)
45
+ if (!instance) return
46
+ const idx = stepIndex ?? resolve(instance)
47
+ if (idx < 0) return
48
+ open(instance, idx)
49
+ }
50
+
51
+ // Open the Follow-up companion window for a run's Coder step (the blinking chip + the
52
+ // `followup_pending` notification). Resolves the Coder step index from the run when not
53
+ // given, so callers that only know the run can still open it.
54
+ function openFollowUps(instanceId: string, stepIndex: number | null = null) {
55
+ withStep(
56
+ instanceId,
57
+ stepIndex,
58
+ // A pipeline may carry more than one follow-up-enabled Coder step, so don't blindly pick
59
+ // the first when no index is given: prefer the step that still has undecided items (the
60
+ // one the run is parked on), else the current step, else the first enabled one.
61
+ (instance) => {
62
+ const pending = indexOf(
63
+ instance,
64
+ (s) => !!s.followUps?.enabled && s.followUps.items.some((i) => i.status === 'pending'),
65
+ )
66
+ if (pending >= 0) return pending
67
+ const current = instance.steps[instance.currentStep]
68
+ if (current?.followUps?.enabled) return instance.currentStep
69
+ return indexOf(instance, (s) => !!s.followUps?.enabled)
70
+ },
71
+ (instance, idx) => deps.setResultView('follow-ups', instance, idx),
72
+ )
73
+ }
74
+
75
+ // Open the implementation-fork decision window for a run's coder step (from the inspector /
76
+ // pipeline chip / `fork_decision_pending` notification). Resolves the coder step index from
77
+ // the run when not given, preferring the step parked awaiting a choice.
78
+ function openForkDecision(instanceId: string, stepIndex: number | null = null) {
79
+ withStep(
80
+ instanceId,
81
+ stepIndex,
82
+ (instance) => {
83
+ const awaiting = indexOf(
84
+ instance,
85
+ (s) => s.agentKind === 'coder' && s.forkDecision?.status === 'awaiting_choice',
86
+ )
87
+ if (awaiting >= 0) return awaiting
88
+ const current = instance.steps[instance.currentStep]
89
+ if (current?.agentKind === 'coder' && current.forkDecision) return instance.currentStep
90
+ return indexOf(instance, (s) => s.agentKind === 'coder' && !!s.forkDecision)
91
+ },
92
+ (instance, idx) => deps.setResultView('fork-decision', instance, idx),
93
+ )
94
+ }
95
+
96
+ // Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
97
+ // notification / the step). Resolves the step index from the run when not given, preferring
98
+ // the step parked awaiting a finding selection.
99
+ function openPrReview(instanceId: string, stepIndex: number | null = null) {
100
+ withStep(
101
+ instanceId,
102
+ stepIndex,
103
+ (instance) => {
104
+ const awaiting = indexOf(
105
+ instance,
106
+ (s) => s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection',
107
+ )
108
+ if (awaiting >= 0) return awaiting
109
+ const current = instance.steps[instance.currentStep]
110
+ if (current?.agentKind === 'pr-reviewer' && current.prReview) return instance.currentStep
111
+ return indexOf(instance, (s) => s.agentKind === 'pr-reviewer' && !!s.prReview)
112
+ },
113
+ (instance, idx) => deps.setResultView('pr-review', instance, idx),
114
+ )
115
+ }
116
+
117
+ // Open the Tester's result window for a run, where the screenshots and per-area outcomes it
118
+ // captured are rendered. The entry point is the `test-evidence` deep link the engine puts in
119
+ // every PR verification report's environment-lifecycle section, so the caller only ever knows
120
+ // the run.
121
+ function openTestEvidence(instanceId: string) {
122
+ withStep(
123
+ instanceId,
124
+ null,
125
+ // Prefer the tester step that actually REPORTED: a pipeline may carry more than one, and
126
+ // the later one describes the PR as it stands. Falling back to the first tester step opens
127
+ // the window on its "not run yet" state, which beats the link going nowhere.
128
+ (instance) => {
129
+ const candidates = instance.steps
130
+ .map((s, i) => ({ s, i }))
131
+ .filter(({ s }) => isTesterKind(s.agentKind))
132
+ const reported = candidates.filter(({ s }) => s.test?.lastReport)
133
+ return (reported.at(-1) ?? candidates[0])?.i ?? -1
134
+ },
135
+ // Routed through the universal dispatch rather than a fixed view id: a tester step running
136
+ // as a consensus panel opens the Consensus Session window instead, and this entry point
137
+ // must not override that.
138
+ (_instance, idx) => deps.dispatchStepView(instanceId, idx),
139
+ )
140
+ }
141
+
142
+ return { openFollowUps, openForkDecision, openPrReview, openTestEvidence }
143
+ }
@@ -96,4 +96,42 @@ describe('dispatchStepView routing', () => {
96
96
  expect(ui.resultView).toBeNull()
97
97
  expect(ui.stepDetail).toEqual({ instanceId: 'e1', stepIndex: 0 })
98
98
  })
99
+
100
+ // The `test-evidence` deep link the engine puts in every PR verification report knows only the
101
+ // RUN, so the opener has to resolve which tester step the reviewer was pointed at.
102
+ describe('openTestEvidence', () => {
103
+ it('opens the tester step that actually reported, not the first one', () => {
104
+ execution.hydrate(
105
+ [
106
+ instance('e1', 'b1', [
107
+ { agentKind: 'coder' },
108
+ { agentKind: 'tester-api' },
109
+ { agentKind: 'tester-ui', test: { lastReport: { greenlight: true } } },
110
+ ]),
111
+ ],
112
+ 'ws1',
113
+ )
114
+
115
+ ui.openTestEvidence('e1')
116
+
117
+ expect(ui.resultView).toMatchObject({ view: 'tester', instanceId: 'e1', stepIndex: 2 })
118
+ })
119
+
120
+ it('still opens a tester step that has not reported, rather than going nowhere', () => {
121
+ execution.hydrate([instance('e1', 'b1', [{ agentKind: 'tester-api' }])], 'ws1')
122
+
123
+ ui.openTestEvidence('e1')
124
+
125
+ expect(ui.resultView).toMatchObject({ view: 'tester', stepIndex: 0 })
126
+ })
127
+
128
+ it('does nothing when the run carries no tester step at all', () => {
129
+ execution.hydrate([instance('e1', 'b1', [{ agentKind: 'coder' }])], 'ws1')
130
+
131
+ ui.openTestEvidence('e1')
132
+
133
+ expect(ui.resultView).toBeNull()
134
+ expect(ui.stepDetail).toBeNull()
135
+ })
136
+ })
99
137
  })
@@ -0,0 +1,12 @@
1
+ // Per-workspace capability-credential shapes: the tenant-scoped home for the secrets a
2
+ // registered tool server (MCP) or generative binary integration declares BY NAME. Values are
3
+ // write-only — the view carries the keys the deployment DECLARES, which of them this workspace
4
+ // has stored, and which stored keys nothing declares any more.
5
+ //
6
+ // All wire shapes are sourced from @cat-factory/contracts (single source of truth).
7
+
8
+ export type {
9
+ CapabilityCredentialRef,
10
+ CapabilityCredentialStatus,
11
+ CapabilityCredentialsView,
12
+ } from '@cat-factory/contracts'
@@ -27,7 +27,11 @@ export type ProviderConnectionKind = 'environment' | 'runner-pool'
27
27
  * pointer could open the window but never land the user on the tab it meant. Every tab
28
28
  * `InfrastructureWindow.vue` can render must have a name here.
29
29
  */
30
- export type InfrastructureTab = ProviderConnectionKind | 'shared-stacks' | 'package-registries'
30
+ export type InfrastructureTab =
31
+ | ProviderConnectionKind
32
+ | 'shared-stacks'
33
+ | 'package-registries'
34
+ | 'capability-credentials'
31
35
 
32
36
  /** A workspace's provider binding, as exposed to clients (never secret values). */
33
37
  export interface ProviderConnection {
@@ -568,6 +568,41 @@
568
568
  "removeFailed": "Der Registry-Eintrag konnte nicht entfernt werden"
569
569
  }
570
570
  },
571
+ "capabilityCredentials": {
572
+ "tab": "Zugangsdaten für Fähigkeiten",
573
+ "intro": "Die Secrets, die die Tool-Server und generativen Integrationen dieser Installation namentlich anfordern. Werte gelten nur für dieses Board, werden verschlüsselt gespeichert und direkt an den Prozess des Agenten übergeben: Sie erscheinen weder in einem Prompt noch in einem Log. Werte lassen sich nur schreiben, nie auslesen, ein gespeicherter Wert wird also durch Eingabe eines neuen ersetzt.",
574
+ "credentialNoun": "Zugangsdaten {key}",
575
+ "required": "Erforderlich",
576
+ "optional": "Optional",
577
+ "stored": "Gespeichert",
578
+ "storedAt": "Zuletzt gesetzt am {date}",
579
+ "notStoredWithFallback": "Für dieses Board ist nichts gespeichert. Diese Installation liest den Schlüssel auch aus ihrer eigenen Umgebung, die Fähigkeit funktioniert also möglicherweise trotzdem.",
580
+ "notStored": "Für dieses Board ist nichts gespeichert, und diese Installation hat keinen Rückfallwert aus der Umgebung. Die Fähigkeit kann sich daher nicht authentifizieren.",
581
+ "notStoredUnknownFallback": "Für dieses Board ist nichts gespeichert. Die Kette der Anmeldedaten dieser Installation lässt sich hier nicht beschreiben, daher ist nicht bekannt, ob der Schlüssel anderswo beantwortet wird.",
582
+ "setValue": "Wert",
583
+ "replaceValue": "Gespeicherten Wert ersetzen",
584
+ "save": "Speichern",
585
+ "remove": "Zugangsdaten entfernen",
586
+ "noneDeclared": "Keine der registrierten Fähigkeiten dieser Installation fordert Zugangsdaten an.",
587
+ "subject": {
588
+ "toolServer": "Tool-Server",
589
+ "binaryGenerator": "Generative Integration"
590
+ },
591
+ "incomplete": {
592
+ "title": "Diese Liste ist möglicherweise unvollständig",
593
+ "body": "Die generativen Integrationen konnten nicht gelesen werden, daher fehlen unten womöglich Zugangsdaten, die eine von ihnen anfordert. Gespeicherte Schlüssel, die niemand anfordert, bleiben ausgeblendet, bis die Liste wieder gelesen werden kann."
594
+ },
595
+ "orphaned": {
596
+ "heading": "Gespeichert, aber nicht angefordert",
597
+ "body": "Nichts, was diese Installation registriert hat, fordert diese Schlüssel an. Genau das hinterlässt eine abgeschaltete Integration oder eine umbenannte Variable. Sie bleiben verschlüsselt gespeichert, bis du sie entfernst."
598
+ },
599
+ "toast": {
600
+ "loadFailed": "Zugangsdaten für Fähigkeiten konnten nicht geladen werden",
601
+ "saved": "{key} gespeichert",
602
+ "saveFailed": "Die Zugangsdaten konnten nicht gespeichert werden",
603
+ "removeFailed": "Die Zugangsdaten konnten nicht entfernt werden"
604
+ }
605
+ },
571
606
  "apiTokens": {
572
607
  "title": "API-Zugriffstokens",
573
608
  "intro": "Erstelle Tokens, die externe Systeme der cat-factory-API vorlegen. Jedes Token authentifiziert sich als dieser Arbeitsbereich an den /api/v1-Endpunkten. Das Geheimnis wird nur einmal bei der Erstellung angezeigt und kann nicht wiederhergestellt werden. Speichere es daher sofort.",