@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.
- package/app/components/settings/CapabilityCredentialsPanel.vue +272 -0
- package/app/components/settings/InfrastructureWindow.logic.spec.ts +28 -3
- package/app/components/settings/InfrastructureWindow.logic.ts +15 -1
- package/app/components/settings/InfrastructureWindow.vue +23 -0
- package/app/composables/api/capabilityCredentials.ts +37 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useRunDeepLink.spec.ts +5 -0
- package/app/composables/useRunDeepLink.ts +11 -8
- package/app/stores/capabilityCredentials.spec.ts +163 -0
- package/app/stores/capabilityCredentials.ts +102 -0
- package/app/stores/ui/resultViews.ts +12 -78
- package/app/stores/ui/runStepOpeners.ts +143 -0
- package/app/stores/ui.dispatch.spec.ts +38 -0
- package/app/types/capabilityCredentials.ts +12 -0
- package/app/types/providerConnections.ts +5 -1
- package/i18n/locales/de.json +35 -0
- package/i18n/locales/en.json +35 -0
- package/i18n/locales/es.json +35 -0
- package/i18n/locales/fr.json +35 -0
- package/i18n/locales/he.json +35 -0
- package/i18n/locales/it.json +35 -0
- package/i18n/locales/ja.json +35 -0
- package/i18n/locales/pl.json +35 -0
- package/i18n/locales/tr.json +35 -0
- package/i18n/locales/uk.json +35 -0
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 =
|
|
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 {
|
package/i18n/locales/de.json
CHANGED
|
@@ -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.",
|