@cat-factory/app 0.161.0 → 0.162.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/panels/AgentStepDetail.vue +58 -6
- package/app/components/panels/ReportsPanel.logic.spec.ts +76 -0
- package/app/components/panels/ReportsPanel.logic.ts +71 -0
- package/app/components/panels/ReportsPanel.vue +471 -0
- package/app/components/panels/ReportsSpendBreakdown.vue +70 -0
- package/app/components/panels/inspector/TaskExecution.vue +25 -1
- package/app/components/pipeline/PipelineProgress.vue +13 -3
- package/app/composables/api/reports.ts +19 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/useStepApproval.ts +10 -9
- package/app/modular/nav-contributions.spec.ts +1 -0
- package/app/modular/nav-contributions.ts +11 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/execution/commands.ts +47 -16
- package/app/stores/reports.spec.ts +113 -0
- package/app/stores/reports.ts +91 -0
- package/app/stores/ui/modals.ts +14 -0
- package/app/stores/ui/resultViews.ts +5 -1
- package/app/types/execution.ts +8 -0
- package/app/utils/pipelineRender.spec.ts +72 -0
- package/app/utils/pipelineRender.ts +26 -0
- package/i18n/locales/de.json +74 -1
- package/i18n/locales/en.json +74 -1
- package/i18n/locales/es.json +74 -1
- package/i18n/locales/fr.json +74 -1
- package/i18n/locales/he.json +74 -1
- package/i18n/locales/it.json +74 -1
- package/i18n/locales/ja.json +74 -1
- package/i18n/locales/pl.json +74 -1
- package/i18n/locales/tr.json +74 -1
- package/i18n/locales/uk.json +74 -1
- package/package.json +2 -2
|
@@ -45,6 +45,7 @@ export function useNavContributions() {
|
|
|
45
45
|
localModels: () => ui.openLocalModels(),
|
|
46
46
|
accountSettings: () => ui.openAccountSettings(),
|
|
47
47
|
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
48
|
+
reports: () => ui.openReports(),
|
|
48
49
|
shortcuts: () => ui.openShortcutsHelp(),
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -100,14 +100,15 @@ export function useStepApproval(opts: {
|
|
|
100
100
|
() => !!feedback.value.trim() || reviewComments.value.length > 0,
|
|
101
101
|
)
|
|
102
102
|
|
|
103
|
-
// Plain approve: accept the agent's proposal verbatim and advance.
|
|
103
|
+
// Plain approve: accept the agent's proposal verbatim and advance. Every action below
|
|
104
|
+
// closes the overlay ONLY when the command actually ran — a server refusal (surfaced as
|
|
105
|
+
// a toast by the store) or a cancelled credential prompt keeps the review open.
|
|
104
106
|
async function approve() {
|
|
105
107
|
const id = opts.approvalId()
|
|
106
108
|
if (!opts.instanceId() || !id || submitting.value) return
|
|
107
109
|
submitting.value = true
|
|
108
110
|
try {
|
|
109
|
-
await execution.approveStep(opts.instanceId()!, id)
|
|
110
|
-
opts.close()
|
|
111
|
+
if (await execution.approveStep(opts.instanceId()!, id)) opts.close()
|
|
111
112
|
} finally {
|
|
112
113
|
submitting.value = false
|
|
113
114
|
}
|
|
@@ -130,8 +131,7 @@ export function useStepApproval(opts: {
|
|
|
130
131
|
if (!opts.instanceId() || !id || submitting.value) return
|
|
131
132
|
submitting.value = true
|
|
132
133
|
try {
|
|
133
|
-
await execution.approveStep(opts.instanceId()!, id, draftProposal.value)
|
|
134
|
-
opts.close()
|
|
134
|
+
if (await execution.approveStep(opts.instanceId()!, id, draftProposal.value)) opts.close()
|
|
135
135
|
} finally {
|
|
136
136
|
submitting.value = false
|
|
137
137
|
}
|
|
@@ -141,7 +141,7 @@ export function useStepApproval(opts: {
|
|
|
141
141
|
if (!opts.instanceId() || !id || submitting.value || !canRequestChanges.value) return
|
|
142
142
|
submitting.value = true
|
|
143
143
|
try {
|
|
144
|
-
await execution.requestStepChanges(opts.instanceId()!, id, {
|
|
144
|
+
const ok = await execution.requestStepChanges(opts.instanceId()!, id, {
|
|
145
145
|
feedback: feedback.value.trim() || undefined,
|
|
146
146
|
comments: reviewComments.value.length
|
|
147
147
|
? reviewComments.value.map((c) => ({
|
|
@@ -152,7 +152,7 @@ export function useStepApproval(opts: {
|
|
|
152
152
|
}))
|
|
153
153
|
: undefined,
|
|
154
154
|
})
|
|
155
|
-
opts.close()
|
|
155
|
+
if (ok) opts.close()
|
|
156
156
|
} finally {
|
|
157
157
|
submitting.value = false
|
|
158
158
|
}
|
|
@@ -168,8 +168,9 @@ export function useStepApproval(opts: {
|
|
|
168
168
|
if (!opts.instanceId() || !id || submitting.value) return
|
|
169
169
|
submitting.value = true
|
|
170
170
|
try {
|
|
171
|
-
await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)
|
|
172
|
-
|
|
171
|
+
if (await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)) {
|
|
172
|
+
opts.close()
|
|
173
|
+
}
|
|
173
174
|
} finally {
|
|
174
175
|
submitting.value = false
|
|
175
176
|
rejectArmed.value = false
|
|
@@ -96,6 +96,7 @@ export const NAV_ACTIONS = [
|
|
|
96
96
|
'localModels',
|
|
97
97
|
'accountSettings',
|
|
98
98
|
'operatorDashboard',
|
|
99
|
+
'reports',
|
|
99
100
|
'shortcuts',
|
|
100
101
|
] as const
|
|
101
102
|
|
|
@@ -354,6 +355,16 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
354
355
|
testId: 'nav-operator-dashboard',
|
|
355
356
|
sidebar: { group: 'configuration', order: 40 },
|
|
356
357
|
},
|
|
358
|
+
{
|
|
359
|
+
id: 'reports',
|
|
360
|
+
labelKey: 'nav.reports',
|
|
361
|
+
icon: 'i-lucide-chart-column',
|
|
362
|
+
surfaces: S('sidebar'),
|
|
363
|
+
gate: (g) => g.accountsEnabled && g.isAccountAdmin,
|
|
364
|
+
action: 'reports',
|
|
365
|
+
testId: 'nav-reports',
|
|
366
|
+
sidebar: { group: 'configuration', order: 45 },
|
|
367
|
+
},
|
|
357
368
|
{
|
|
358
369
|
id: 'keyboard-shortcuts',
|
|
359
370
|
labelKey: 'layout.commandBar.cmd.shortcuts',
|
package/app/pages/index.vue
CHANGED
|
@@ -35,6 +35,7 @@ const ObservabilityPanel = defineAsyncComponent(
|
|
|
35
35
|
const OperatorDashboardPanel = defineAsyncComponent(
|
|
36
36
|
() => import('~/components/panels/OperatorDashboardPanel.vue'),
|
|
37
37
|
)
|
|
38
|
+
const ReportsPanel = defineAsyncComponent(() => import('~/components/panels/ReportsPanel.vue'))
|
|
38
39
|
const KaizenPanel = defineAsyncComponent(() => import('~/components/kaizen/KaizenPanel.vue'))
|
|
39
40
|
// Occasional, externally store-gated surfaces — deferred to their own chunks like the
|
|
40
41
|
// sibling document modals above. Each mounts only while its ui open-flag is set, so it
|
|
@@ -400,6 +401,7 @@ watch(
|
|
|
400
401
|
<RecurringPipelineModal v-if="ui.addRecurringFrameId" />
|
|
401
402
|
<ObservabilityPanel v-if="ui.observabilityInstanceId" />
|
|
402
403
|
<OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
|
|
404
|
+
<ReportsPanel v-if="ui.reportsOpen" />
|
|
403
405
|
<KaizenPanel v-if="ui.kaizenScreenOpen" />
|
|
404
406
|
<DocumentSourceConnectModal v-if="ui.documentConnect" />
|
|
405
407
|
<DocumentImportModal v-if="ui.documentImport" />
|
|
@@ -69,35 +69,66 @@ export function createExecutionCommands(ctx: ExecutionCommandContext) {
|
|
|
69
69
|
})
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Approve a step's gated proposal (optionally edited); the run advances. Returns false —
|
|
74
|
+
* with the failure surfaced as an actionable toast — when the server refused (e.g. a 409
|
|
75
|
+
* for a park a dedicated window owns) or the user cancelled the credential prompt, so the
|
|
76
|
+
* approval rail can stay open instead of closing over a silently-failed approve.
|
|
77
|
+
*/
|
|
78
|
+
async function approveStep(
|
|
79
|
+
instanceId: string,
|
|
80
|
+
approvalId: string,
|
|
81
|
+
proposal?: string,
|
|
82
|
+
): Promise<boolean> {
|
|
74
83
|
const ws = useWorkspaceStore()
|
|
75
84
|
const personal = usePersonalSubscriptionsStore()
|
|
76
|
-
|
|
77
|
-
await
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
try {
|
|
86
|
+
return await personal.withCredential(async (password) => {
|
|
87
|
+
await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
|
|
88
|
+
await ws.refresh()
|
|
89
|
+
})
|
|
90
|
+
} catch (e) {
|
|
91
|
+
runErrors.present(e, 'errors.action.approveFailed')
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
/** Request changes on a gated proposal; the step re-runs with the review.
|
|
96
|
+
/** Request changes on a gated proposal; the step re-runs with the review. Returns false
|
|
97
|
+
* (with a toast) on refusal / a cancelled credential prompt, like {@link approveStep}. */
|
|
83
98
|
async function requestStepChanges(
|
|
84
99
|
instanceId: string,
|
|
85
100
|
approvalId: string,
|
|
86
101
|
review: RequestStepChangesInput,
|
|
87
|
-
) {
|
|
102
|
+
): Promise<boolean> {
|
|
88
103
|
const ws = useWorkspaceStore()
|
|
89
104
|
const personal = usePersonalSubscriptionsStore()
|
|
90
|
-
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
try {
|
|
106
|
+
return await personal.withCredential(async (password) => {
|
|
107
|
+
await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
|
|
108
|
+
await ws.refresh()
|
|
109
|
+
})
|
|
110
|
+
} catch (e) {
|
|
111
|
+
runErrors.present(e, 'errors.action.requestChangesFailed')
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
/** Reject a gated proposal; the run stops entirely (a retryable failure).
|
|
97
|
-
|
|
116
|
+
/** Reject a gated proposal; the run stops entirely (a retryable failure). Returns false
|
|
117
|
+
* (with a toast) when the server refused. */
|
|
118
|
+
async function rejectStep(
|
|
119
|
+
instanceId: string,
|
|
120
|
+
approvalId: string,
|
|
121
|
+
reason?: string,
|
|
122
|
+
): Promise<boolean> {
|
|
98
123
|
const ws = useWorkspaceStore()
|
|
99
|
-
|
|
100
|
-
|
|
124
|
+
try {
|
|
125
|
+
await api.rejectStep(ws.requireId(), instanceId, approvalId, { reason })
|
|
126
|
+
await ws.refresh()
|
|
127
|
+
return true
|
|
128
|
+
} catch (e) {
|
|
129
|
+
runErrors.present(e, 'errors.action.rejectFailed')
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
101
132
|
}
|
|
102
133
|
|
|
103
134
|
/**
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useAccountsStore } from '~/stores/accounts'
|
|
3
|
+
import { useReportsStore } from '~/stores/reports'
|
|
4
|
+
import type { ReportsView } from '~/types/execution'
|
|
5
|
+
|
|
6
|
+
// The reports view is loaded three ways that can each be triggered while another is still in
|
|
7
|
+
// flight — the window buttons, the board filter and the refresh button — so the store's
|
|
8
|
+
// monotonicity guard is the thing that keeps the panel from showing numbers for a window the
|
|
9
|
+
// user already left. These drive that race directly.
|
|
10
|
+
|
|
11
|
+
/** Minimal projection — only what the store stores and these assertions read. */
|
|
12
|
+
function view(over: Partial<ReportsView> = {}): ReportsView {
|
|
13
|
+
return {
|
|
14
|
+
window: '7d',
|
|
15
|
+
generatedAt: 1_000,
|
|
16
|
+
since: 0,
|
|
17
|
+
workspaceId: null,
|
|
18
|
+
currency: 'EUR',
|
|
19
|
+
totals: { inputTokens: 0, outputTokens: 0, calls: 0, meteredCost: 0, subscriptionCost: 0 },
|
|
20
|
+
spend: { byModel: [], byAgentKind: [], byWorkspace: [], byService: [], byTaskType: [] },
|
|
21
|
+
activity: { byWorkspace: [], byService: [], byTaskType: [] },
|
|
22
|
+
trend: { bucketMs: 1_000, points: [] },
|
|
23
|
+
...over,
|
|
24
|
+
} as ReportsView
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Seed an active account, since every load is scoped to one. */
|
|
28
|
+
function seedAccount() {
|
|
29
|
+
const accounts = useAccountsStore()
|
|
30
|
+
accounts.accounts = [
|
|
31
|
+
{
|
|
32
|
+
id: 'acc1',
|
|
33
|
+
type: 'org',
|
|
34
|
+
name: 'Acme',
|
|
35
|
+
githubAccountLogin: null,
|
|
36
|
+
createdAt: 0,
|
|
37
|
+
roles: null,
|
|
38
|
+
},
|
|
39
|
+
] as never
|
|
40
|
+
accounts.activeAccountId = 'acc1'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('reports store — concurrent loads', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
seedAccount()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('a stale load never overwrites a fresher one that already resolved', async () => {
|
|
49
|
+
// The user picks 24h, then immediately 90d. The 24h request is slower and lands LAST;
|
|
50
|
+
// committing it would leave the panel labelled 90d while showing 24h numbers.
|
|
51
|
+
let resolveSlow!: (v: ReportsView) => void
|
|
52
|
+
const slow = new Promise<ReportsView>((res) => {
|
|
53
|
+
resolveSlow = res
|
|
54
|
+
})
|
|
55
|
+
const responses: Record<string, Promise<ReportsView>> = {
|
|
56
|
+
'24h': slow,
|
|
57
|
+
'90d': Promise.resolve(view({ window: '90d', totals: { ...view().totals, calls: 42 } })),
|
|
58
|
+
}
|
|
59
|
+
vi.stubGlobal('useApi', () => ({
|
|
60
|
+
getReports: (_id: string, window: string) => responses[window]!,
|
|
61
|
+
}))
|
|
62
|
+
const store = useReportsStore()
|
|
63
|
+
|
|
64
|
+
const first = store.setWindow('24h')
|
|
65
|
+
const second = store.setWindow('90d')
|
|
66
|
+
await second
|
|
67
|
+
resolveSlow(view({ window: '24h', totals: { ...view().totals, calls: 7 } }))
|
|
68
|
+
await first
|
|
69
|
+
|
|
70
|
+
expect(store.view?.window).toBe('90d')
|
|
71
|
+
expect(store.view?.totals.calls).toBe(42)
|
|
72
|
+
// The superseded load must not resurrect the spinner it started either.
|
|
73
|
+
expect(store.loading).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('a superseded FAILURE never replaces the newer view with an error', async () => {
|
|
77
|
+
// The same race the other way round: an in-flight load rejects after a later
|
|
78
|
+
// one succeeded. Committing it would blank a perfectly good panel.
|
|
79
|
+
let rejectSlow!: (e: Error) => void
|
|
80
|
+
const slow = new Promise<ReportsView>((_res, rej) => {
|
|
81
|
+
rejectSlow = rej
|
|
82
|
+
})
|
|
83
|
+
const responses: Record<string, Promise<ReportsView>> = {
|
|
84
|
+
'24h': slow,
|
|
85
|
+
'90d': Promise.resolve(view({ window: '90d' })),
|
|
86
|
+
}
|
|
87
|
+
vi.stubGlobal('useApi', () => ({
|
|
88
|
+
getReports: (_id: string, window: string) => responses[window]!,
|
|
89
|
+
}))
|
|
90
|
+
const store = useReportsStore()
|
|
91
|
+
|
|
92
|
+
const first = store.setWindow('24h')
|
|
93
|
+
await store.setWindow('90d')
|
|
94
|
+
rejectSlow(new Error('gateway timeout'))
|
|
95
|
+
await first
|
|
96
|
+
|
|
97
|
+
expect(store.failed).toBe(false)
|
|
98
|
+
expect(store.error).toBeNull()
|
|
99
|
+
expect(store.view?.window).toBe('90d')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('records a failure from the newest load, with the raw message as detail', async () => {
|
|
103
|
+
vi.stubGlobal('useApi', () => ({
|
|
104
|
+
getReports: () => Promise.reject(new Error('reports are not available')),
|
|
105
|
+
}))
|
|
106
|
+
const store = useReportsStore()
|
|
107
|
+
await store.load()
|
|
108
|
+
expect(store.failed).toBe(true)
|
|
109
|
+
// Raw backend prose only — the localized heading is the panel's job.
|
|
110
|
+
expect(store.error).toBe('reports are not available')
|
|
111
|
+
expect(store.loading).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { ReportWindow, ReportsView } from '~/types/execution'
|
|
4
|
+
import { useAccountsStore } from '~/stores/accounts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reports: cross-cutting usage analytics for the active account — spend per model and
|
|
8
|
+
* agent kind, spend + run activity per workspace / service / task type, and a spend trend,
|
|
9
|
+
* over a selectable window and optionally narrowed to one board.
|
|
10
|
+
*
|
|
11
|
+
* The sibling of the `platformObservability` store: same account scope, same admin gate,
|
|
12
|
+
* same on-demand load. Nothing is pushed live (these are periodic rollups); changing the
|
|
13
|
+
* window or the board filter re-fetches, and a manual refresh re-fetches unconditionally.
|
|
14
|
+
*/
|
|
15
|
+
export const useReportsStore = defineStore('reports', () => {
|
|
16
|
+
const api = useApi()
|
|
17
|
+
const accounts = useAccountsStore()
|
|
18
|
+
|
|
19
|
+
const window = ref<ReportWindow>('7d')
|
|
20
|
+
/** The single board every breakdown is narrowed to, or null for the whole account. */
|
|
21
|
+
const workspaceFilter = ref<string | null>(null)
|
|
22
|
+
const view = ref<ReportsView | null>(null)
|
|
23
|
+
const loading = ref(false)
|
|
24
|
+
/** Whether the last load failed. The panel owns the localized wording. */
|
|
25
|
+
const failed = ref(false)
|
|
26
|
+
/**
|
|
27
|
+
* The backend's raw (untranslated) message for a failed load, shown beneath the localized
|
|
28
|
+
* heading as a last-resort detail — null when the failure carried no message at all.
|
|
29
|
+
*/
|
|
30
|
+
const error = ref<string | null>(null)
|
|
31
|
+
|
|
32
|
+
const accountId = computed(() => accounts.activeAccount?.id ?? null)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Monotonicity guard. The window buttons, the board filter and the refresh button can each
|
|
36
|
+
* start a load, so two are easily in flight at once — and without this a staler response
|
|
37
|
+
* resolving later would overwrite the newer view (and its `loading`/error state) with
|
|
38
|
+
* numbers for a window the user already moved off. Only the newest load may commit.
|
|
39
|
+
*/
|
|
40
|
+
let latest = 0
|
|
41
|
+
|
|
42
|
+
async function load() {
|
|
43
|
+
const id = accountId.value
|
|
44
|
+
if (!id) {
|
|
45
|
+
view.value = null
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const seq = ++latest
|
|
49
|
+
loading.value = true
|
|
50
|
+
failed.value = false
|
|
51
|
+
error.value = null
|
|
52
|
+
try {
|
|
53
|
+
const next = await api.getReports(id, window.value, workspaceFilter.value)
|
|
54
|
+
if (seq !== latest) return
|
|
55
|
+
view.value = next
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (seq !== latest) return
|
|
58
|
+
failed.value = true
|
|
59
|
+
error.value = err instanceof Error ? err.message : null
|
|
60
|
+
} finally {
|
|
61
|
+
if (seq === latest) loading.value = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Switch the window and reload (a no-op when it is already the loaded one). */
|
|
66
|
+
async function setWindow(next: ReportWindow) {
|
|
67
|
+
if (next === window.value && view.value) return
|
|
68
|
+
window.value = next
|
|
69
|
+
await load()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Narrow to one board (null = the whole account) and reload. */
|
|
73
|
+
async function setWorkspaceFilter(next: string | null) {
|
|
74
|
+
if (next === workspaceFilter.value && view.value) return
|
|
75
|
+
workspaceFilter.value = next
|
|
76
|
+
await load()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
window,
|
|
81
|
+
workspaceFilter,
|
|
82
|
+
view,
|
|
83
|
+
loading,
|
|
84
|
+
failed,
|
|
85
|
+
error,
|
|
86
|
+
accountId,
|
|
87
|
+
load,
|
|
88
|
+
setWindow,
|
|
89
|
+
setWorkspaceFilter,
|
|
90
|
+
}
|
|
91
|
+
})
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -440,6 +440,10 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
440
440
|
// from `observabilityConnectionOpen` (the Datadog connection) AND `observabilityInstanceId`
|
|
441
441
|
// (the per-run LLM call panel).
|
|
442
442
|
const operatorDashboardOpen = ref(false)
|
|
443
|
+
// Reports: the cross-cutting usage-analytics panel over the same account scope (spend per
|
|
444
|
+
// model / agent kind, spend + activity per workspace / service / task type). Admin-gated.
|
|
445
|
+
// Distinct from `operatorDashboardOpen`, which answers the deployment-HEALTH question.
|
|
446
|
+
const reportsOpen = ref(false)
|
|
443
447
|
// Private package registries: the workspace's npm/GitHub-Packages entries agent
|
|
444
448
|
// containers install with. Opened from the Integrations hub.
|
|
445
449
|
const packageRegistriesOpen = ref(false)
|
|
@@ -486,6 +490,13 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
486
490
|
function closeOperatorDashboard() {
|
|
487
491
|
operatorDashboardOpen.value = false
|
|
488
492
|
}
|
|
493
|
+
function openReports() {
|
|
494
|
+
resetHubReturn()
|
|
495
|
+
reportsOpen.value = true
|
|
496
|
+
}
|
|
497
|
+
function closeReports() {
|
|
498
|
+
reportsOpen.value = false
|
|
499
|
+
}
|
|
489
500
|
function openPackageRegistries() {
|
|
490
501
|
resetHubReturn()
|
|
491
502
|
packageRegistriesOpen.value = true
|
|
@@ -544,6 +555,7 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
544
555
|
slackOpen,
|
|
545
556
|
observabilityConnectionOpen,
|
|
546
557
|
operatorDashboardOpen,
|
|
558
|
+
reportsOpen,
|
|
547
559
|
packageRegistriesOpen,
|
|
548
560
|
apiTokensOpen,
|
|
549
561
|
modelConfigOpen,
|
|
@@ -559,6 +571,8 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
559
571
|
openObservabilityConnection,
|
|
560
572
|
closeObservabilityConnection,
|
|
561
573
|
openOperatorDashboard,
|
|
574
|
+
openReports,
|
|
575
|
+
closeReports,
|
|
562
576
|
closeOperatorDashboard,
|
|
563
577
|
openPackageRegistries,
|
|
564
578
|
closePackageRegistries,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import { useExecutionStore } from '~/stores/execution'
|
|
3
3
|
import { agentKindMeta } from '~/utils/catalog'
|
|
4
|
+
import { dedicatedParkView } from '~/utils/pipelineRender'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The step-inspection / result-view slice of the UI store: the dedicated result-view overlay
|
|
@@ -73,12 +74,15 @@ export function createUiResultViews() {
|
|
|
73
74
|
// A step carrying a PR deep-review parks with BOTH a pending approval and
|
|
74
75
|
// `prReview.status`, so the generic approval button funnels here; route it to the
|
|
75
76
|
// findings-selection window regardless of catalog/manifest state (mirrors consensus).
|
|
77
|
+
// Likewise a coder parked on the implementation-fork choice or on undecided follow-up
|
|
78
|
+
// items: those parks ride `step.approval` too, but the generic approve resolver refuses
|
|
79
|
+
// them server-side, so the step must open the window that CAN resolve it.
|
|
76
80
|
const view = step?.consensus?.enabled
|
|
77
81
|
? 'consensus-session'
|
|
78
82
|
: step?.prReview
|
|
79
83
|
? 'pr-review'
|
|
80
84
|
: step
|
|
81
|
-
? agentKindMeta(step.agentKind).resultView
|
|
85
|
+
? (dedicatedParkView(step) ?? agentKindMeta(step.agentKind).resultView)
|
|
82
86
|
: undefined
|
|
83
87
|
if (view && instance) {
|
|
84
88
|
// The brainstorm window is shared by both stages; carry which one from the step's kind.
|
package/app/types/execution.ts
CHANGED
|
@@ -30,6 +30,14 @@ export type {
|
|
|
30
30
|
PlatformOutcomeTotals,
|
|
31
31
|
PlatformTrendPoint,
|
|
32
32
|
PlatformFailureSlice,
|
|
33
|
+
ReportWindow,
|
|
34
|
+
ReportSpendDimension,
|
|
35
|
+
ReportActivityDimension,
|
|
36
|
+
ReportSpendRow,
|
|
37
|
+
ReportActivityRow,
|
|
38
|
+
ReportTrendPoint,
|
|
39
|
+
ReportTotals,
|
|
40
|
+
ReportsView,
|
|
33
41
|
AgentSearchQuery,
|
|
34
42
|
WebSearchAvailability,
|
|
35
43
|
WebSearchProvider,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
import { dedicatedParkView } from './pipelineRender'
|
|
4
|
+
|
|
5
|
+
/** A minimal coder step; the predicate only reads approval/followUps/forkDecision. */
|
|
6
|
+
const step = (over: Partial<PipelineStep>): PipelineStep =>
|
|
7
|
+
({
|
|
8
|
+
agentKind: 'coder',
|
|
9
|
+
state: 'waiting_decision',
|
|
10
|
+
approval: { id: 'ap_1', status: 'pending', proposal: '' },
|
|
11
|
+
...over,
|
|
12
|
+
}) as PipelineStep
|
|
13
|
+
|
|
14
|
+
const followUps = (statuses: string[]) => ({
|
|
15
|
+
enabled: true,
|
|
16
|
+
items: statuses.map((status, i) => ({
|
|
17
|
+
id: `fu_${i}`,
|
|
18
|
+
kind: 'follow_up',
|
|
19
|
+
title: 't',
|
|
20
|
+
detail: '',
|
|
21
|
+
status,
|
|
22
|
+
createdAt: 0,
|
|
23
|
+
updatedAt: 0,
|
|
24
|
+
})),
|
|
25
|
+
loops: 0,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('dedicatedParkView', () => {
|
|
29
|
+
// The regression this pins: a coder parked on the follow-up gate (or the fork choice)
|
|
30
|
+
// carries a pending `step.approval`, but the generic approve resolver 409s on it — the
|
|
31
|
+
// surfaces must route these parks to their dedicated window, never the "Approve &
|
|
32
|
+
// proceed" rail.
|
|
33
|
+
it('owns a follow-up park (pending approval + undecided items)', () => {
|
|
34
|
+
expect(
|
|
35
|
+
dedicatedParkView(step({ followUps: followUps(['pending', 'answered']) as never })),
|
|
36
|
+
).toBe('follow-ups')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not claim a step whose follow-up items are all decided', () => {
|
|
40
|
+
expect(
|
|
41
|
+
dedicatedParkView(step({ followUps: followUps(['answered', 'dismissed']) as never })),
|
|
42
|
+
).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not claim a WORKING coder that is still streaming items (no approval raised)', () => {
|
|
46
|
+
// Clicking a live step must keep opening the ordinary detail (progress + output).
|
|
47
|
+
expect(
|
|
48
|
+
dedicatedParkView(
|
|
49
|
+
step({ state: 'working', approval: null, followUps: followUps(['pending']) as never }),
|
|
50
|
+
),
|
|
51
|
+
).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('owns the fork park while awaiting a choice, and while a chat reply is in flight', () => {
|
|
55
|
+
expect(dedicatedParkView(step({ forkDecision: { status: 'awaiting_choice' } as never }))).toBe(
|
|
56
|
+
'fork-decision',
|
|
57
|
+
)
|
|
58
|
+
expect(dedicatedParkView(step({ forkDecision: { status: 'answering' } as never }))).toBe(
|
|
59
|
+
'fork-decision',
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('releases the step once the fork is resolved (chosen / single_path / skipped)', () => {
|
|
64
|
+
for (const status of ['chosen', 'single_path', 'skipped', 'proposing']) {
|
|
65
|
+
expect(dedicatedParkView(step({ forkDecision: { status } as never }))).toBeNull()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('leaves a plain approval park to the generic rail', () => {
|
|
70
|
+
expect(dedicatedParkView(step({}))).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -133,6 +133,32 @@ export function isCompanionKind(kind: string): boolean {
|
|
|
133
133
|
)
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The dedicated window that owns a step's approval park, when the park is NOT a generic
|
|
138
|
+
* prose approval: the implementation-fork window while a coder waits on (or chats about)
|
|
139
|
+
* an approach choice, or the follow-up triage window while surfaced items are undecided.
|
|
140
|
+
* The generic approve/request-changes/reject resolvers deliberately refuse these parks
|
|
141
|
+
* server-side (`assertNotIterativeGate`), so every surface that offers a step's pending
|
|
142
|
+
* approval must route these to their window instead of the generic "Approve & proceed"
|
|
143
|
+
* rail — which would blink a 409 and resolve nothing.
|
|
144
|
+
*/
|
|
145
|
+
export function dedicatedParkView(step: PipelineStep): 'follow-ups' | 'fork-decision' | null {
|
|
146
|
+
// The fork park sits BEFORE the coder's build dispatch; `answering` (a chat turn in
|
|
147
|
+
// flight) still belongs to the fork window, which renders the pending reply.
|
|
148
|
+
const fork = step.forkDecision?.status
|
|
149
|
+
if (fork === 'awaiting_choice' || fork === 'answering') return 'fork-decision'
|
|
150
|
+
// Follow-ups only own the park itself: while the coder is still WORKING (streaming
|
|
151
|
+
// items, no approval raised) a step click should keep opening the ordinary detail.
|
|
152
|
+
if (
|
|
153
|
+
step.approval?.status === 'pending' &&
|
|
154
|
+
step.followUps?.enabled &&
|
|
155
|
+
step.followUps.items.some((i) => i.status === 'pending')
|
|
156
|
+
) {
|
|
157
|
+
return 'follow-ups'
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
/**
|
|
137
163
|
* The friendly label for a container's live phase (clone → "Preparing workspace",
|
|
138
164
|
* agent → "Agent running", …), falling back to the raw phase string for an unknown/new
|