@cat-factory/app 0.301.0 → 0.301.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.
- package/app/components/tasks/BugHuntModal.vue +4 -2
- package/app/composables/api/assistant.ts +7 -3
- package/app/composables/api/bugHunt.ts +10 -2
- package/app/stores/assistant.spec.ts +51 -0
- package/app/stores/assistant.ts +22 -3
- package/app/stores/bugHunt.spec.ts +29 -3
- package/app/stores/bugHunt.ts +29 -6
- package/package.json +2 -2
|
@@ -274,8 +274,10 @@ function loadBoardsFor(next: TaskSourceKind | undefined) {
|
|
|
274
274
|
async function runHunt() {
|
|
275
275
|
const input = request.value
|
|
276
276
|
if (!source.value || !input) return
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
// Only a real failure is reported. A cancelled credential prompt leaves nothing to say, and
|
|
278
|
+
// `repo_not_linked` is worded by the panel itself beside the scope it invalidates.
|
|
279
|
+
const attempt = await hunt.hunt(source.value, input)
|
|
280
|
+
if (attempt === 'failed' && !huntNeedsRepo.value) {
|
|
279
281
|
toast.add({
|
|
280
282
|
title: t('bugHunt.huntFailed'),
|
|
281
283
|
description: refusalText(hunt.huntErrorReason, hunt.huntError) ?? undefined,
|
|
@@ -3,7 +3,7 @@ import type { AssistantAnswer } from '~/types/domain'
|
|
|
3
3
|
import type { ApiContext } from './context'
|
|
4
4
|
|
|
5
5
|
/** In-app assistant: what it can do here, and one prompt-to-action turn. */
|
|
6
|
-
export function assistantApi({ send, ws }: ApiContext) {
|
|
6
|
+
export function assistantApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
7
7
|
return {
|
|
8
8
|
// Whether a model is wired and which actions this deployment offers. Read before the prompt
|
|
9
9
|
// box is shown, so an unconfigured deployment says so instead of failing on submit. `signal`
|
|
@@ -14,8 +14,12 @@ export function assistantApi({ send, ws }: ApiContext) {
|
|
|
14
14
|
|
|
15
15
|
// Run one turn. A live model call plus a board write, so it can take a couple of seconds:
|
|
16
16
|
// the modal shows progress and the outcome is rendered from the returned data.
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
//
|
|
18
|
+
// Carries the personal password header, like a run start does, because the turn resolves the
|
|
19
|
+
// workspace's own preset: a workspace pinned to an individual-usage subscription runs this
|
|
20
|
+
// surface on it, and without the header it could only ever fall back to another model.
|
|
21
|
+
runAssistantTurn: (workspaceId: string, prompt: string, password?: string) =>
|
|
22
|
+
sendWith(pwHeaders(password), runAssistantTurnContract, {
|
|
19
23
|
pathPrefix: ws(workspaceId),
|
|
20
24
|
body: { kind: 'prompt', prompt },
|
|
21
25
|
}),
|
|
@@ -16,8 +16,16 @@ export function bugHuntApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
|
16
16
|
|
|
17
17
|
// Scan the board for open, unassigned bugs and rank them by impact vs complexity. A live
|
|
18
18
|
// external call plus a model call, so it can take a while — the modal shows progress.
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Carries the personal password for the same reason `adoptBugHuntCandidate` below does: the
|
|
20
|
+
// RANKING is a model call resolved under the workspace's preset, so a subscription-pinned
|
|
21
|
+
// workspace needs the credential here and not only at adoption.
|
|
22
|
+
runBugHunt: (
|
|
23
|
+
workspaceId: string,
|
|
24
|
+
source: TaskSourceKind,
|
|
25
|
+
body: RunBugHuntInput,
|
|
26
|
+
password?: string,
|
|
27
|
+
) =>
|
|
28
|
+
sendWith(pwHeaders(password), runBugHuntContract, {
|
|
21
29
|
pathPrefix: ws(workspaceId),
|
|
22
30
|
pathParams: { source },
|
|
23
31
|
body,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
2
|
import { useAssistantStore } from '~/stores/assistant'
|
|
3
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
4
5
|
import { ApiError } from '~/composables/api/errors'
|
|
5
6
|
import type { AssistantCapability } from '~/types/domain'
|
|
6
7
|
|
|
@@ -181,3 +182,53 @@ describe('assistant store: the capability read deadline', () => {
|
|
|
181
182
|
expect(store.capabilityRead).toBe('error')
|
|
182
183
|
})
|
|
183
184
|
})
|
|
185
|
+
|
|
186
|
+
// The credential half of a turn: a workspace whose preset pins an individual-usage subscription
|
|
187
|
+
// runs this surface on it, which means the turn has to be able to ASK for the password and to
|
|
188
|
+
// survive being refused one.
|
|
189
|
+
describe('assistant store: the personal-credential flow', () => {
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
function stubTurn(
|
|
195
|
+
withCredential: (action: (password?: string) => Promise<void>) => Promise<boolean>,
|
|
196
|
+
) {
|
|
197
|
+
const calls: (string | undefined)[] = []
|
|
198
|
+
vi.stubGlobal('useApi', () => ({
|
|
199
|
+
getAssistantCapability: async () => ({ available: true, actions: ['add-service-from-repo'] }),
|
|
200
|
+
runAssistantTurn: async (_ws: string, _prompt: string, password?: string) => {
|
|
201
|
+
calls.push(password)
|
|
202
|
+
return { outcome: { status: 'declined', reason: 'no_matching_action' }, model: null }
|
|
203
|
+
},
|
|
204
|
+
}))
|
|
205
|
+
usePersonalSubscriptionsStore().withCredential = withCredential as unknown as ReturnType<
|
|
206
|
+
typeof usePersonalSubscriptionsStore
|
|
207
|
+
>['withCredential']
|
|
208
|
+
return { store: useAssistantStore(), calls }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
it('carries the password the credential flow supplies into the turn', async () => {
|
|
212
|
+
// Without it a workspace pinned to a personal subscription could only ever be answered by
|
|
213
|
+
// some other model, which is the failure this whole path exists to close.
|
|
214
|
+
const { store, calls } = stubTurn(async (action) => {
|
|
215
|
+
await action('correct horse')
|
|
216
|
+
return true
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const turn = await store.run('add the payments repo')
|
|
220
|
+
|
|
221
|
+
expect(calls).toEqual(['correct horse'])
|
|
222
|
+
expect(turn).not.toBeNull()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('answers null when the person cancels the password prompt', async () => {
|
|
226
|
+
// A cancel is not a failed turn and not a declined one: nothing ran, so there is no outcome
|
|
227
|
+
// to render. Reporting it as either would put a refusal on screen that nobody caused.
|
|
228
|
+
const { store, calls } = stubTurn(async () => false)
|
|
229
|
+
|
|
230
|
+
expect(await store.run('add the payments repo')).toBeNull()
|
|
231
|
+
expect(calls).toEqual([])
|
|
232
|
+
expect(store.turn).toBeNull()
|
|
233
|
+
})
|
|
234
|
+
})
|
package/app/stores/assistant.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
|
|
3
3
|
import type { AssistantAnswer, AssistantCapability, AssistantTurn } from '~/types/domain'
|
|
4
4
|
import type { LoadState } from '~/types/load-state'
|
|
5
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* How long the capability read waits before it counts as failed.
|
|
@@ -99,10 +100,28 @@ export const useAssistantStore = defineStore('assistant', () => {
|
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
102
|
* Run one turn. Throws on a refusal so the caller can hand it to the error funnel; the three
|
|
102
|
-
* outcomes (performed / needs_input / declined) come back as the resolved value
|
|
103
|
+
* outcomes (performed / needs_input / declined) come back as the resolved value, and `null`
|
|
104
|
+
* when the person cancelled the credential prompt.
|
|
103
105
|
*/
|
|
104
|
-
async function run(prompt: string): Promise<AssistantTurn> {
|
|
105
|
-
|
|
106
|
+
async function run(prompt: string): Promise<AssistantTurn | null> {
|
|
107
|
+
// Through the credential flow, exactly as a run start goes: the turn resolves the workspace's
|
|
108
|
+
// OWN preset, so on a workspace pinned to an individual-usage subscription the first turn 428s,
|
|
109
|
+
// the modal collects the password, and it rides transparently from the cache after that.
|
|
110
|
+
//
|
|
111
|
+
// `null` for a CANCELLED prompt, the shape every gated surface here uses. A cancel is not a
|
|
112
|
+
// failed turn and not a declined one either: nothing ran, so there is no outcome to render and
|
|
113
|
+
// nothing to report. `record` never runs, so the kept `turn` is whatever it already was, which
|
|
114
|
+
// for the modal is nothing: editing the prompt cleared it before submit.
|
|
115
|
+
//
|
|
116
|
+
// The completed turn is captured in a local of its OWN name rather than read back off the
|
|
117
|
+
// store's `turn` ref, which this would otherwise shadow: two bindings spelled the same in one
|
|
118
|
+
// function is how a later edit writes to the wrong one with nothing failing.
|
|
119
|
+
const personal = usePersonalSubscriptionsStore()
|
|
120
|
+
let completed: AssistantTurn | null = null
|
|
121
|
+
const ran = await personal.withCredential(async (password) => {
|
|
122
|
+
completed = await record(() => api.runAssistantTurn(workspace.requireId(), prompt, password))
|
|
123
|
+
})
|
|
124
|
+
return ran ? completed : null
|
|
106
125
|
}
|
|
107
126
|
|
|
108
127
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
2
|
import { useBugHuntStore } from '~/stores/bugHunt'
|
|
3
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
4
5
|
import { ApiError } from '~/composables/api/errors'
|
|
5
6
|
import type { BugHuntResult, TaskSourceKind, TrackerBoardsView } from '~/types/domain'
|
|
6
7
|
|
|
@@ -181,7 +182,7 @@ describe('bug hunt store — scan failures', () => {
|
|
|
181
182
|
const { store, serveHunt } = stubApi()
|
|
182
183
|
serveHunt(() => Promise.reject(apiError(422, 'validation', { reason: 'repo_not_linked' })))
|
|
183
184
|
|
|
184
|
-
expect(await store.hunt('github', SCAN)).toBe(
|
|
185
|
+
expect(await store.hunt('github', SCAN)).toBe('failed')
|
|
185
186
|
|
|
186
187
|
expect(store.huntErrorReason).toBe('repo_not_linked')
|
|
187
188
|
expect(store.huntError).toBeTruthy()
|
|
@@ -192,7 +193,7 @@ describe('bug hunt store — scan failures', () => {
|
|
|
192
193
|
const { store, serveHunt } = stubApi()
|
|
193
194
|
serveHunt(() => Promise.reject(apiError(502, 'upstream')))
|
|
194
195
|
|
|
195
|
-
expect(await store.hunt('jira', { containerId: 'blk_auth', board: 'PROJ' })).toBe(
|
|
196
|
+
expect(await store.hunt('jira', { containerId: 'blk_auth', board: 'PROJ' })).toBe('failed')
|
|
196
197
|
|
|
197
198
|
expect(store.huntErrorReason).toBeNull()
|
|
198
199
|
expect(store.huntError).toBeTruthy()
|
|
@@ -204,7 +205,7 @@ describe('bug hunt store — scan failures', () => {
|
|
|
204
205
|
await store.hunt('github', SCAN)
|
|
205
206
|
|
|
206
207
|
serveHunt(() => Promise.resolve(huntResult()))
|
|
207
|
-
expect(await store.hunt('github', SCAN)).toBe(
|
|
208
|
+
expect(await store.hunt('github', SCAN)).toBe('ran')
|
|
208
209
|
|
|
209
210
|
expect(store.huntError).toBeNull()
|
|
210
211
|
expect(store.huntErrorReason).toBeNull()
|
|
@@ -222,3 +223,28 @@ describe('bug hunt store — scan failures', () => {
|
|
|
222
223
|
expect(store.huntError).toBeNull()
|
|
223
224
|
})
|
|
224
225
|
})
|
|
226
|
+
|
|
227
|
+
describe('bug hunt store: a dismissed credential prompt', () => {
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('reports `cancelled` and drops the ranking the previous board left', async () => {
|
|
233
|
+
// The scan needs the personal password on a subscription-pinned workspace, so the prompt can
|
|
234
|
+
// be cancelled. Nothing ran: reporting that as a failure raises an error toast with no
|
|
235
|
+
// description for an action the person themselves called off, and leaving the earlier result
|
|
236
|
+
// mounted would show one board's candidates under another board's selection.
|
|
237
|
+
const { store } = stubApi()
|
|
238
|
+
expect(await store.hunt('github', SCAN)).toBe('ran')
|
|
239
|
+
expect(store.result).not.toBeNull()
|
|
240
|
+
|
|
241
|
+
usePersonalSubscriptionsStore().withCredential = (async () => false) as unknown as ReturnType<
|
|
242
|
+
typeof usePersonalSubscriptionsStore
|
|
243
|
+
>['withCredential']
|
|
244
|
+
|
|
245
|
+
expect(await store.hunt('github', SCAN)).toBe('cancelled')
|
|
246
|
+
expect(store.result).toBeNull()
|
|
247
|
+
expect(store.huntError).toBeNull()
|
|
248
|
+
expect(store.huntErrorReason).toBeNull()
|
|
249
|
+
})
|
|
250
|
+
})
|
package/app/stores/bugHunt.ts
CHANGED
|
@@ -5,6 +5,12 @@ import { apiErrorReason } from '~/composables/api/errors'
|
|
|
5
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
6
|
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* What one attempt did. `cancelled` is distinct from `failed` because the surface reports a
|
|
10
|
+
* failure: a dismissed credential prompt is the person's own decision, not an outcome to toast.
|
|
11
|
+
*/
|
|
12
|
+
export type BugHuntAttempt = 'ran' | 'cancelled' | 'failed'
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
15
|
* Bug-hunt state: the boards of the tracker being browsed, the last hunt's ranked candidates,
|
|
10
16
|
* and the actions behind the three steps (list boards → run the hunt → adopt one candidate).
|
|
@@ -95,19 +101,36 @@ export const useBugHuntStore = defineStore('bugHunt', () => {
|
|
|
95
101
|
boardsLoading.value = false
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Run a hunt and keep its ranked result.
|
|
106
|
+
*
|
|
107
|
+
* Three outcomes, not two, because the caller reports a failure and only a failure. `cancelled`
|
|
108
|
+
* is a hunt that never ran: the person dismissed the credential prompt (or the prompt's own
|
|
109
|
+
* retry failed and the credential modal already said so), so there is nothing to tell them that
|
|
110
|
+
* they did not just do. Collapsing it into `failed` is an error toast for an action they
|
|
111
|
+
* cancelled, with no description to put in it.
|
|
112
|
+
*/
|
|
113
|
+
async function hunt(source: TaskSourceKind, input: RunBugHuntInput): Promise<BugHuntAttempt> {
|
|
100
114
|
hunting.value = true
|
|
101
115
|
huntError.value = null
|
|
102
116
|
huntErrorReason.value = null
|
|
117
|
+
// A new scan supersedes whatever is on screen the moment it starts. Clearing HERE rather than
|
|
118
|
+
// on each way out is what keeps a cancelled prompt from leaving the previous board's ranking
|
|
119
|
+
// under the current selection, where adopting one would file a task against the wrong board.
|
|
120
|
+
result.value = null
|
|
103
121
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
122
|
+
// Gated like the adoption below, and for the reason the ranking is the model call: on a
|
|
123
|
+
// workspace pinned to an individual-usage subscription the scan needs the credential HERE,
|
|
124
|
+
// not only when a candidate is adopted.
|
|
125
|
+
const personal = usePersonalSubscriptionsStore()
|
|
126
|
+
const ran = await personal.withCredential(async (password) => {
|
|
127
|
+
result.value = await api.runBugHunt(workspace.requireId(), source, input, password)
|
|
128
|
+
})
|
|
129
|
+
return ran ? 'ran' : 'cancelled'
|
|
106
130
|
} catch (e) {
|
|
107
|
-
result.value = null
|
|
108
131
|
huntError.value = e instanceof Error ? e.message : String(e)
|
|
109
132
|
huntErrorReason.value = apiErrorReason(e)
|
|
110
|
-
return
|
|
133
|
+
return 'failed'
|
|
111
134
|
} finally {
|
|
112
135
|
hunting.value = false
|
|
113
136
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.301.
|
|
3
|
+
"version": "0.301.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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@cat-factory/contracts": "0.
|
|
21
|
+
"@cat-factory/contracts": "0.353.0",
|
|
22
22
|
"@modular-frontend/core": "0.6.0",
|
|
23
23
|
"@modular-vue/core": "^1.5.0",
|
|
24
24
|
"@modular-vue/journeys": "^1.4.0",
|