@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
|
@@ -15,6 +15,7 @@ import StepExecutionHistory from '~/components/board/StepExecutionHistory.vue'
|
|
|
15
15
|
import { useStepTimer } from '~/composables/useStepTimer'
|
|
16
16
|
import { useStepProse } from '~/composables/useStepProse'
|
|
17
17
|
import { useStepApproval } from '~/composables/useStepApproval'
|
|
18
|
+
import { dedicatedParkView } from '~/utils/pipelineRender'
|
|
18
19
|
|
|
19
20
|
// Detail overlay for a single pipeline step. Opened by clicking an agent in the
|
|
20
21
|
// inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
|
|
@@ -151,6 +152,25 @@ const approvalId = computed(() => step.value?.approval?.id ?? null)
|
|
|
151
152
|
// approve/request-changes/reject rail, it shows the shared iteration-cap prompt
|
|
152
153
|
// (one more round / proceed / stop & reset), resolved through its own endpoint.
|
|
153
154
|
const companionExceeded = computed(() => approvalPending.value && !!step.value?.companion?.exceeded)
|
|
155
|
+
// A park a DEDICATED window owns (fork choice / follow-up triage): the generic approve
|
|
156
|
+
// resolver refuses these server-side, so the rail is replaced by a redirect to that window.
|
|
157
|
+
// Computed live, since a coder step can park on one WHILE this overlay is already open
|
|
158
|
+
// (the routing in `dispatchStepView` only covers the open click).
|
|
159
|
+
const dedicatedPark = computed(() => (step.value ? dedicatedParkView(step.value) : null))
|
|
160
|
+
/** The generic approve/request-changes/reject rail applies (no dedicated surface owns the park). */
|
|
161
|
+
const genericApprovalPending = computed(
|
|
162
|
+
() => approvalPending.value && !companionExceeded.value && !dedicatedPark.value,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
/** Jump from this overlay to the window that can actually resolve the dedicated park. */
|
|
166
|
+
function openDedicatedWindow() {
|
|
167
|
+
const c = ctx.value
|
|
168
|
+
const park = dedicatedPark.value
|
|
169
|
+
if (!c || !park) return
|
|
170
|
+
close()
|
|
171
|
+
if (park === 'follow-ups') ui.openFollowUps(c.instanceId, c.stepIndex)
|
|
172
|
+
else ui.openForkDecision(c.instanceId, c.stepIndex)
|
|
173
|
+
}
|
|
154
174
|
|
|
155
175
|
function close() {
|
|
156
176
|
// Reset the approval-mode sub-states so reopening the same step is clean
|
|
@@ -159,13 +179,14 @@ function close() {
|
|
|
159
179
|
ui.closeStepDetail()
|
|
160
180
|
}
|
|
161
181
|
|
|
162
|
-
// The GitHub-style approval/review state machine for a pending gate step.
|
|
182
|
+
// The GitHub-style approval/review state machine for a pending gate step. A park a
|
|
183
|
+
// dedicated window owns is NOT reviewable here, so it doesn't count as pending.
|
|
163
184
|
const approval = useStepApproval({
|
|
164
185
|
step: () => step.value,
|
|
165
186
|
scrollEl: () => scrollEl.value,
|
|
166
187
|
instanceId: () => ctx.value?.instanceId,
|
|
167
188
|
approvalId: () => approvalId.value,
|
|
168
|
-
approvalPending: () => approvalPending.value,
|
|
189
|
+
approvalPending: () => approvalPending.value && !dedicatedPark.value,
|
|
169
190
|
companionExceeded: () => companionExceeded.value,
|
|
170
191
|
close,
|
|
171
192
|
})
|
|
@@ -292,7 +313,7 @@ async function copyOutput() {
|
|
|
292
313
|
</div>
|
|
293
314
|
<div class="ms-auto flex items-center gap-1.5">
|
|
294
315
|
<UBadge
|
|
295
|
-
v-if="
|
|
316
|
+
v-if="genericApprovalPending"
|
|
296
317
|
color="warning"
|
|
297
318
|
variant="subtle"
|
|
298
319
|
size="sm"
|
|
@@ -302,7 +323,7 @@ async function copyOutput() {
|
|
|
302
323
|
{{ t('panels.stepDetail.approvalRequired') }}
|
|
303
324
|
</UBadge>
|
|
304
325
|
<UBadge
|
|
305
|
-
v-else-if="companionExceeded"
|
|
326
|
+
v-else-if="companionExceeded || dedicatedPark"
|
|
306
327
|
color="warning"
|
|
307
328
|
variant="subtle"
|
|
308
329
|
size="sm"
|
|
@@ -396,6 +417,37 @@ async function copyOutput() {
|
|
|
396
417
|
@resolve="resolveCompanionCap"
|
|
397
418
|
/>
|
|
398
419
|
|
|
420
|
+
<!-- a park a dedicated window owns (fork choice / follow-up triage): the
|
|
421
|
+
generic approval rail can't resolve it (the server refuses), so point
|
|
422
|
+
the human at the window that can -->
|
|
423
|
+
<div
|
|
424
|
+
v-if="dedicatedPark"
|
|
425
|
+
class="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4"
|
|
426
|
+
data-testid="dedicated-park-redirect"
|
|
427
|
+
>
|
|
428
|
+
<p class="text-[13px] leading-relaxed text-amber-200/90">
|
|
429
|
+
{{
|
|
430
|
+
dedicatedPark === 'follow-ups'
|
|
431
|
+
? t('panels.stepDetail.followUpsParked')
|
|
432
|
+
: t('panels.stepDetail.forkParked')
|
|
433
|
+
}}
|
|
434
|
+
</p>
|
|
435
|
+
<UButton
|
|
436
|
+
class="mt-3"
|
|
437
|
+
color="primary"
|
|
438
|
+
size="sm"
|
|
439
|
+
:icon="dedicatedPark === 'follow-ups' ? 'i-lucide-compass' : 'i-lucide-git-fork'"
|
|
440
|
+
data-testid="dedicated-park-open"
|
|
441
|
+
@click="openDedicatedWindow"
|
|
442
|
+
>
|
|
443
|
+
{{
|
|
444
|
+
dedicatedPark === 'follow-ups'
|
|
445
|
+
? t('panels.stepDetail.openFollowUps')
|
|
446
|
+
: t('panels.stepDetail.chooseApproach')
|
|
447
|
+
}}
|
|
448
|
+
</UButton>
|
|
449
|
+
</div>
|
|
450
|
+
|
|
399
451
|
<!-- ephemeral environment lifecycle (spinning up / running / shut down /
|
|
400
452
|
errored + the exact error), when this step runs against one -->
|
|
401
453
|
<EnvironmentStatusPanel v-if="stepEnvironment" :environment="stepEnvironment" />
|
|
@@ -545,7 +597,7 @@ async function copyOutput() {
|
|
|
545
597
|
class="reader-prose mt-1 text-[13px] leading-relaxed text-slate-300"
|
|
546
598
|
:class="[
|
|
547
599
|
s.depth > 0 ? 'ps-6' : '',
|
|
548
|
-
|
|
600
|
+
genericApprovalPending && !editing ? 'review-mode' : '',
|
|
549
601
|
]"
|
|
550
602
|
@click="onProseClick"
|
|
551
603
|
v-html="s.bodyHtml"
|
|
@@ -567,7 +619,7 @@ async function copyOutput() {
|
|
|
567
619
|
Approve / Request changes / Reject. A end-side rail on wide screens; a
|
|
568
620
|
bottom sheet (still reachable) below lg, so the gate is always actionable. -->
|
|
569
621
|
<aside
|
|
570
|
-
v-if="
|
|
622
|
+
v-if="genericApprovalPending"
|
|
571
623
|
class="absolute inset-x-0 bottom-0 z-10 flex max-h-[70dvh] flex-col rounded-t-2xl border-t border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:static lg:inset-auto lg:z-auto lg:max-h-none lg:w-96 lg:shrink-0 lg:rounded-none lg:border-s lg:border-t-0 lg:border-slate-800 lg:bg-slate-900/60 lg:shadow-none lg:backdrop-blur-none"
|
|
572
624
|
>
|
|
573
625
|
<div class="border-b border-slate-800 px-4 py-3">
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { ReportActivityRow, ReportSpendRow } from '~/types/execution'
|
|
3
|
+
import {
|
|
4
|
+
activitySegments,
|
|
5
|
+
columnPct,
|
|
6
|
+
isUnattributed,
|
|
7
|
+
maxOf,
|
|
8
|
+
segmentPct,
|
|
9
|
+
spendMagnitude,
|
|
10
|
+
} from './ReportsPanel.logic'
|
|
11
|
+
|
|
12
|
+
const spend = (over: Partial<ReportSpendRow>): ReportSpendRow => ({
|
|
13
|
+
key: 'k',
|
|
14
|
+
label: null,
|
|
15
|
+
inputTokens: 0,
|
|
16
|
+
outputTokens: 0,
|
|
17
|
+
calls: 0,
|
|
18
|
+
meteredCost: 0,
|
|
19
|
+
subscriptionCost: 0,
|
|
20
|
+
...over,
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('ReportsPanel logic', () => {
|
|
24
|
+
it('ranks a spend slice by its combined footprint', () => {
|
|
25
|
+
// Ranking/scaling only: the sum mixes real money with the illustrative cost of
|
|
26
|
+
// flat-rate quota usage, so no caller may render it as an amount. `ReportsSpendBreakdown`
|
|
27
|
+
// shows `meteredCost` and `subscriptionCost` as separate figures for exactly that reason.
|
|
28
|
+
expect(spendMagnitude(spend({ meteredCost: 2, subscriptionCost: 3 }))).toBe(5)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('scales every segment against the widest row, not its own row', () => {
|
|
32
|
+
// A per-row denominator would draw both bars full-width and erase the ranking.
|
|
33
|
+
const rows = [spend({ meteredCost: 10 }), spend({ meteredCost: 2 })]
|
|
34
|
+
const max = maxOf(rows, spendMagnitude)
|
|
35
|
+
expect(segmentPct(rows[0]!.meteredCost, max)).toBe(100)
|
|
36
|
+
expect(segmentPct(rows[1]!.meteredCost, max)).toBe(20)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns a zero max for an empty list and draws nothing from it', () => {
|
|
40
|
+
expect(maxOf([], spendMagnitude)).toBe(0)
|
|
41
|
+
expect(segmentPct(5, 0)).toBe(0)
|
|
42
|
+
expect(columnPct(5, 0)).toBe(0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('floors a non-zero column so a small bucket stays visible', () => {
|
|
46
|
+
// Without the floor a single cheap call rounds to an empty column and reads as a
|
|
47
|
+
// quiet period, which is the opposite of what happened.
|
|
48
|
+
expect(columnPct(0.001, 1000)).toBe(2)
|
|
49
|
+
expect(columnPct(0, 1000)).toBe(0)
|
|
50
|
+
expect(columnPct(500, 1000)).toBe(50)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('treats the empty key as the unattributed bucket', () => {
|
|
54
|
+
expect(isUnattributed('')).toBe(true)
|
|
55
|
+
expect(isUnattributed('feature')).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('lists activity status splits in the legend order', () => {
|
|
59
|
+
const row: ReportActivityRow = {
|
|
60
|
+
key: 'ws',
|
|
61
|
+
label: 'Board',
|
|
62
|
+
runs: 6,
|
|
63
|
+
done: 3,
|
|
64
|
+
failed: 2,
|
|
65
|
+
running: 1,
|
|
66
|
+
other: 0,
|
|
67
|
+
avgDurationMs: 100,
|
|
68
|
+
}
|
|
69
|
+
expect(activitySegments(row)).toEqual([
|
|
70
|
+
{ status: 'done', count: 3 },
|
|
71
|
+
{ status: 'failed', count: 2 },
|
|
72
|
+
{ status: 'running', count: 1 },
|
|
73
|
+
{ status: 'other', count: 0 },
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReportActivityRow, ReportSpendRow, ReportTrendPoint } from '~/types/execution'
|
|
2
|
+
|
|
3
|
+
// Pure sizing/derivation behind `ReportsPanel.vue`: everything the template needs that is
|
|
4
|
+
// arithmetic rather than markup, so it is unit-tested directly instead of through the DOM.
|
|
5
|
+
|
|
6
|
+
// The two `*Magnitude` helpers add the metered and subscription costs together, which is a
|
|
7
|
+
// number NOBODY MAY RENDER AS MONEY: only `meteredCost` is real spend, and `subscriptionCost`
|
|
8
|
+
// is the illustrative equivalent-API cost of flat-rate quota usage, so their sum denominates
|
|
9
|
+
// nothing. They exist purely as the RANKING and BAR-SCALING measure — the one job the sum is
|
|
10
|
+
// valid for, because it orders slices by total footprint exactly as the SQL `ORDER BY` does.
|
|
11
|
+
// Anything shown to a reader with a currency symbol must come off one of the two fields.
|
|
12
|
+
|
|
13
|
+
/** A spend slice's combined footprint. Ranking/scaling only — never a displayed amount. */
|
|
14
|
+
export function spendMagnitude(row: ReportSpendRow): number {
|
|
15
|
+
return row.meteredCost + row.subscriptionCost
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A trend bucket's combined footprint. Ranking/scaling only — never a displayed amount. */
|
|
19
|
+
export function trendMagnitude(point: ReportTrendPoint): number {
|
|
20
|
+
return point.meteredCost + point.subscriptionCost
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The heaviest value in a list, or 0 when empty. Bars are drawn RELATIVE to this, so a
|
|
25
|
+
* full bar means "the biggest slice here", never "all of some absolute quota".
|
|
26
|
+
*/
|
|
27
|
+
export function maxOf<T>(rows: T[], measure: (row: T) => number): number {
|
|
28
|
+
return rows.reduce((max, row) => Math.max(max, measure(row)), 0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A segment's share of the widest row, as a percentage. Scaling every segment against the
|
|
33
|
+
* SAME denominator is what makes a stacked bar's total length comparable across rows; a
|
|
34
|
+
* per-row denominator would draw every bar full-width and destroy the ranking.
|
|
35
|
+
*/
|
|
36
|
+
export function segmentPct(value: number, max: number): number {
|
|
37
|
+
if (max <= 0 || value <= 0) return 0
|
|
38
|
+
return (value / max) * 100
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A trend column's height as a percentage of the tallest column. A non-zero value floors at
|
|
43
|
+
* 2% so a single small call is still a visible mark rather than an apparent gap — the one
|
|
44
|
+
* thing a reader would otherwise misread as "nothing happened here".
|
|
45
|
+
*/
|
|
46
|
+
export function columnPct(value: number, max: number): number {
|
|
47
|
+
if (max <= 0 || value <= 0) return 0
|
|
48
|
+
return Math.max(2, (value / max) * 100)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a breakdown key is the unattributed bucket. It is a REAL slice (a call whose run,
|
|
53
|
+
* service or task type could not be resolved), so it is rendered with an explicit label
|
|
54
|
+
* rather than silently dropped — omitting it would under-report the window while looking
|
|
55
|
+
* complete.
|
|
56
|
+
*/
|
|
57
|
+
export function isUnattributed(key: string): boolean {
|
|
58
|
+
return key === ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every status split of an activity row, in the fixed order the legend declares. */
|
|
62
|
+
export function activitySegments(
|
|
63
|
+
row: ReportActivityRow,
|
|
64
|
+
): Array<{ status: 'done' | 'failed' | 'running' | 'other'; count: number }> {
|
|
65
|
+
return [
|
|
66
|
+
{ status: 'done', count: row.done },
|
|
67
|
+
{ status: 'failed', count: row.failed },
|
|
68
|
+
{ status: 'running', count: row.running },
|
|
69
|
+
{ status: 'other', count: row.other },
|
|
70
|
+
]
|
|
71
|
+
}
|