@cat-factory/app 0.213.1 → 0.215.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/README.md +107 -13
- package/app/components/observability/StepMetricsBar.vue +11 -0
- package/app/components/panels/AgentStepDetail.vue +14 -0
- package/app/components/panels/MergerResultView.vue +20 -2
- package/app/components/panels/ObservabilityPanel.vue +57 -0
- package/app/components/panels/ResultWindowShell.vue +77 -0
- package/app/components/panels/StepReproductionReport.vue +167 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +25 -0
- package/app/components/tutorial/TutorialCatalogue.vue +14 -1
- package/app/components/tutorial/TutorialNudge.vue +107 -0
- package/app/components/tutorial/TutorialOverlay.vue +92 -11
- package/app/composables/api/execution.ts +5 -2
- package/app/composables/api/tutorial.ts +25 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useTutorialNudge.ts +77 -0
- package/app/composables/useTutorialSync.ts +141 -0
- package/app/modular/external-tools.spec.ts +1 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +11 -0
- package/app/modular/nav-gates.ts +10 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +55 -4
- package/app/modular/tutorial-tours.ts +231 -9
- package/app/pages/index.vue +20 -1
- package/app/stores/tutorial.prompt.ts +59 -0
- package/app/stores/tutorial.record.ts +191 -0
- package/app/stores/tutorial.spec.ts +207 -0
- package/app/stores/tutorial.ts +78 -91
- package/app/stores/workspace/hydrate.ts +5 -0
- package/app/types/domain.ts +3 -0
- package/app/types/reproduction.ts +11 -0
- package/app/utils/binaryOutput.spec.ts +56 -2
- package/app/utils/binaryOutput.ts +55 -32
- package/app/utils/observability.spec.ts +44 -1
- package/app/utils/observability.ts +50 -0
- package/app/utils/reproduction.ts +51 -0
- package/app/utils/tutorial.spec.ts +255 -0
- package/app/utils/tutorial.ts +173 -0
- package/i18n/locales/de.json +148 -6
- package/i18n/locales/en.json +153 -6
- package/i18n/locales/es.json +148 -6
- package/i18n/locales/fr.json +148 -6
- package/i18n/locales/he.json +148 -6
- package/i18n/locales/it.json +148 -6
- package/i18n/locales/ja.json +148 -6
- package/i18n/locales/pl.json +148 -6
- package/i18n/locales/tr.json +148 -6
- package/i18n/locales/uk.json +148 -6
- package/package.json +2 -2
|
@@ -25,6 +25,7 @@ import { useSharedStacksStore } from '~/stores/sharedStacks'
|
|
|
25
25
|
import { useSkillsStore } from '~/stores/skills'
|
|
26
26
|
import { useTaskTypesStore } from '~/stores/taskTypes'
|
|
27
27
|
import { useTrackerStore } from '~/stores/tracker'
|
|
28
|
+
import { useTutorialStore } from '~/stores/tutorial'
|
|
28
29
|
import { useWorkspaceSettingsStore } from '~/stores/workspaceSettings'
|
|
29
30
|
import { buildWorkspaceCapabilitiesManifest } from '~/modular/capabilities'
|
|
30
31
|
|
|
@@ -60,6 +61,10 @@ export function resetPerBoardCaches() {
|
|
|
60
61
|
*/
|
|
61
62
|
export function applySnapshotToStores(snapshot: WorkspaceSnapshot, boardSince?: number) {
|
|
62
63
|
useUserSettingsStore().hydrate(snapshot.userSettings ?? null)
|
|
64
|
+
// The signed-in user's tutorial progress MERGES rather than replaces (see the store): both id
|
|
65
|
+
// lists are grow-only sets, so a snapshot must never un-say a walkthrough this browser finished
|
|
66
|
+
// while the mirror write was failing. Absent ⇒ no server copy, and the local one stands.
|
|
67
|
+
useTutorialStore().mergeServerProgress(snapshot.tutorialProgress ?? null)
|
|
63
68
|
useBoardStore().hydrate(snapshot.blocks, boardSince)
|
|
64
69
|
useBoardStore().hydrateArchived(snapshot.archivedServices ?? [])
|
|
65
70
|
usePipelinesStore().hydrate(
|
package/app/types/domain.ts
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Bugfix REPRODUCTION-PROOF shapes: the declared reproducing check as the executor-harness ran it
|
|
2
|
+
// against the pre-fix tree and the final tree, and the verdict it computed from the two exit codes.
|
|
3
|
+
//
|
|
4
|
+
// All wire shapes are sourced from @cat-factory/contracts (single source of truth).
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
ReproductionProofMode,
|
|
8
|
+
ReproductionPhaseOutcome,
|
|
9
|
+
ReproductionReport,
|
|
10
|
+
ReproductionStatus,
|
|
11
|
+
} from '@cat-factory/contracts'
|
|
@@ -428,8 +428,9 @@ describe('binaryOutputPickIssues, generative half', () => {
|
|
|
428
428
|
expect(pick.issues).toEqual([])
|
|
429
429
|
})
|
|
430
430
|
|
|
431
|
-
// The FORMAT half
|
|
432
|
-
//
|
|
431
|
+
// The FORMAT half. The rule itself is contracts' `binaryFormatCoverage`, tested there; what
|
|
432
|
+
// these pin is the picker's own job, which is mapping its three outcomes onto two issues that
|
|
433
|
+
// read differently: a refusal and an advisory.
|
|
433
434
|
const meshy = {
|
|
434
435
|
id: 'meshy',
|
|
435
436
|
modalities: ['3d-model' as const],
|
|
@@ -490,6 +491,59 @@ describe('binaryOutputPickIssues, generative half', () => {
|
|
|
490
491
|
expect(pick.uncoveredModalities).toEqual([])
|
|
491
492
|
})
|
|
492
493
|
|
|
494
|
+
it('flags an OVERLAP as ADVISORY, where the content type stops deciding', () => {
|
|
495
|
+
// The step saves and starts: two producers of one content type is the reason the selection is
|
|
496
|
+
// a list. What it costs is a decision nobody wrote down, and this is the surface where the
|
|
497
|
+
// person who knows the answer has the step's prompt already open.
|
|
498
|
+
const pick = binaryOutputPickIssues(
|
|
499
|
+
{ storageServiceId: 'files', generatorIds: ['retro', 'flux'] },
|
|
500
|
+
catalog,
|
|
501
|
+
true,
|
|
502
|
+
[...generators, { id: 'flux', modalities: ['image' as const] }],
|
|
503
|
+
)
|
|
504
|
+
expect(pick.issues).toEqual(['generator_overlap'])
|
|
505
|
+
expect(pick.generatorOverlaps).toEqual([{ modality: 'image', generatorIds: ['retro', 'flux'] }])
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('reads a repeated id as ONE integration, exactly as the backend resolves it', () => {
|
|
509
|
+
// A step naming one integration twice holds one producer, so there is no choice to advise
|
|
510
|
+
// about, and the unknown-id list must not name the same missing id twice either.
|
|
511
|
+
const pick = binaryOutputPickIssues(
|
|
512
|
+
{ storageServiceId: 'files', generatorIds: ['retro', 'retro', 'ghost', 'ghost'] },
|
|
513
|
+
catalog,
|
|
514
|
+
true,
|
|
515
|
+
generators,
|
|
516
|
+
)
|
|
517
|
+
expect(pick.issues).toEqual(['unknown_generator'])
|
|
518
|
+
expect(pick.unknownGeneratorIds).toEqual(['ghost'])
|
|
519
|
+
expect(pick.generatorOverlaps).toEqual([])
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('says nothing about an overlap while one integration produces each content type', () => {
|
|
523
|
+
const pick = binaryOutputPickIssues(
|
|
524
|
+
{ storageServiceId: 'files', generatorIds: ['retro', 'studio'] },
|
|
525
|
+
catalog,
|
|
526
|
+
true,
|
|
527
|
+
generators,
|
|
528
|
+
)
|
|
529
|
+
expect(pick.issues).not.toContain('generator_overlap')
|
|
530
|
+
expect(pick.generatorOverlaps).toEqual([])
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('claims no overlap about a set nobody could read', () => {
|
|
534
|
+
// Same rule every other generative judgement here follows: an unreachable mothership answers
|
|
535
|
+
// the same empty list a deployment registering nothing does, and only one of them is a fact.
|
|
536
|
+
const pick = binaryOutputPickIssues(
|
|
537
|
+
{ storageServiceId: 'files', generatorIds: ['retro', 'flux'] },
|
|
538
|
+
catalog,
|
|
539
|
+
true,
|
|
540
|
+
[],
|
|
541
|
+
true,
|
|
542
|
+
)
|
|
543
|
+
expect(pick.issues).toEqual(['generators_unavailable'])
|
|
544
|
+
expect(pick.generatorOverlaps).toEqual([])
|
|
545
|
+
})
|
|
546
|
+
|
|
493
547
|
it('still judges an EMPTY set, which is a real answer about the deployment', () => {
|
|
494
548
|
// The distinction the flag exists for: same empty list, opposite fact, opposite message.
|
|
495
549
|
const pick = binaryOutputPickIssues(
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
ASSET_STORAGE_CAPABILITY,
|
|
3
|
+
binaryFormatCoverage,
|
|
4
|
+
binaryModalityOverlaps,
|
|
5
|
+
normalizeMediaType,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import type {
|
|
8
|
+
BinaryModality,
|
|
9
|
+
BinaryModalityOverlap,
|
|
10
|
+
RegisteredBinaryGenerator,
|
|
11
|
+
} from '@cat-factory/contracts'
|
|
3
12
|
import type {
|
|
4
13
|
BinaryOutputArtifact,
|
|
5
14
|
BinaryOutputConfig,
|
|
@@ -412,6 +421,22 @@ export type BinaryOutputPickIssue =
|
|
|
412
421
|
* comes to look exactly like "this is fine".
|
|
413
422
|
*/
|
|
414
423
|
| 'media_type_unverifiable'
|
|
424
|
+
/**
|
|
425
|
+
* Two or more selected integrations produce the SAME content type, so the content type no
|
|
426
|
+
* longer decides which one the agent calls.
|
|
427
|
+
*
|
|
428
|
+
* ADVISORY, like `media_type_unverifiable` and unlike everything above it: the step saves, it
|
|
429
|
+
* starts, and selecting two producers of one kind is the whole reason the selection is a list.
|
|
430
|
+
* What it costs is a decision nobody has written down, and the agent resolves an unstated
|
|
431
|
+
* choice by picking one and picking it consistently, which is invisible in the artifacts,
|
|
432
|
+
* since every one of them has the right modality, the right format and a clean storage verdict.
|
|
433
|
+
*
|
|
434
|
+
* It is raised HERE as well as in the agent's brief because this is the surface where it can be
|
|
435
|
+
* acted on: the person selecting two integrations is the one who knows why, and the step's
|
|
436
|
+
* prompt is a field they already have open. The brief catches the step whose author did not
|
|
437
|
+
* think to write it; this catches the author.
|
|
438
|
+
*/
|
|
439
|
+
| 'generator_overlap'
|
|
415
440
|
|
|
416
441
|
/** What the builder found wrong with one step's selection, and which ids to name. */
|
|
417
442
|
export interface BinaryOutputPickState {
|
|
@@ -426,6 +451,12 @@ export interface BinaryOutputPickState {
|
|
|
426
451
|
uncoveredMediaTypes: readonly string[]
|
|
427
452
|
/** The declared formats that could not be judged, kept apart from the refusal above. */
|
|
428
453
|
unverifiableMediaTypes: readonly string[]
|
|
454
|
+
/**
|
|
455
|
+
* The content types more than one selected integration produces, with the ids that share each.
|
|
456
|
+
* Computed through the SAME `binaryModalityOverlaps` the agent's brief renders from, so the
|
|
457
|
+
* picker and the brief cannot describe one selection two ways.
|
|
458
|
+
*/
|
|
459
|
+
generatorOverlaps: readonly BinaryModalityOverlap[]
|
|
429
460
|
}
|
|
430
461
|
|
|
431
462
|
/**
|
|
@@ -433,6 +464,12 @@ export interface BinaryOutputPickState {
|
|
|
433
464
|
* `binaryGeneratorSelectionIssues` so the builder surfaces the `binary_output_generator_invalid`
|
|
434
465
|
* refusal before the round trip rather than inventing a second opinion.
|
|
435
466
|
*
|
|
467
|
+
* What is mirrored is the DISPOSITION: which conditions refuse, which advise, and what each is
|
|
468
|
+
* called on this surface. The two rules underneath are IMPORTED (`binaryFormatCoverage`,
|
|
469
|
+
* `binaryModalityOverlaps`), because a rule restated on both sides of a wire is one that can come
|
|
470
|
+
* to two answers about the same selection, and the reader here is the person who would then be
|
|
471
|
+
* told the builder's version and the agent the other.
|
|
472
|
+
*
|
|
436
473
|
* `unavailable` is the one state that is NOT derivable from the list, which is why the snapshot
|
|
437
474
|
* carries it as its own flag. An empty list normally IS a real empty — this deployment registers
|
|
438
475
|
* none — and that is exactly why a selected id in that state is `unknown_generator` rather than
|
|
@@ -453,16 +490,21 @@ function generatorPickIssues(
|
|
|
453
490
|
uncovered: BinaryModality[]
|
|
454
491
|
uncoveredMediaTypes: string[]
|
|
455
492
|
unverifiableMediaTypes: string[]
|
|
493
|
+
overlaps: BinaryModalityOverlap[]
|
|
456
494
|
} {
|
|
457
495
|
const none = {
|
|
458
496
|
unknownGeneratorIds: [],
|
|
459
497
|
uncovered: [],
|
|
460
498
|
uncoveredMediaTypes: [],
|
|
461
499
|
unverifiableMediaTypes: [],
|
|
500
|
+
overlaps: [],
|
|
462
501
|
}
|
|
463
502
|
if (unavailable) return { issues: ['generators_unavailable'], ...none }
|
|
464
503
|
const byId = new Map(generators.map((g) => [g.id, g]))
|
|
465
|
-
|
|
504
|
+
// Deduplicated on the way in, exactly as `resolveBinaryGeneratorSelection` does it on the
|
|
505
|
+
// backend: a step that names one integration twice holds ONE, and every line below states a
|
|
506
|
+
// count or a list a repeat would double.
|
|
507
|
+
const selectedIds = [...new Set(config?.generatorIds ?? [])]
|
|
466
508
|
const unknownGeneratorIds = selectedIds.filter((id) => !byId.has(id))
|
|
467
509
|
// Coverage is judged against what RESOLVED, exactly as admission judges it: an unknown id
|
|
468
510
|
// contributes no content types, so a step whose only audio generator is unregistered is told
|
|
@@ -470,49 +512,28 @@ function generatorPickIssues(
|
|
|
470
512
|
const selected = selectedIds.flatMap((id) => byId.get(id) ?? [])
|
|
471
513
|
const covered = new Set(selected.flatMap((g) => g.modalities))
|
|
472
514
|
const uncovered = (config?.modalities ?? []).filter((m) => !covered.has(m))
|
|
473
|
-
const format =
|
|
515
|
+
const format = binaryFormatCoverage(config?.mediaTypes ?? [], selected)
|
|
516
|
+
// Judged against what RESOLVED, like every rule above it, and against the SELECTION rather than
|
|
517
|
+
// the step's declared content types: the case that most often puts two producers of one kind on
|
|
518
|
+
// one step is the one where neither is the deliverable (an image generated to feed a mesh API),
|
|
519
|
+
// and gating on `modalities` would go silent on exactly that step.
|
|
520
|
+
const overlaps = binaryModalityOverlaps(selected)
|
|
474
521
|
const issues: BinaryOutputPickIssue[] = []
|
|
475
522
|
if (unknownGeneratorIds.length) issues.push('unknown_generator')
|
|
476
523
|
if (uncovered.length) issues.push('modality_uncovered')
|
|
477
524
|
if (format.uncovered.length) issues.push('media_type_uncovered')
|
|
478
525
|
if (format.unverifiable.length) issues.push('media_type_unverifiable')
|
|
526
|
+
if (overlaps.length) issues.push('generator_overlap')
|
|
479
527
|
return {
|
|
480
528
|
issues,
|
|
481
529
|
unknownGeneratorIds,
|
|
482
530
|
uncovered,
|
|
483
531
|
uncoveredMediaTypes: format.uncovered,
|
|
484
532
|
unverifiableMediaTypes: format.unverifiable,
|
|
533
|
+
overlaps,
|
|
485
534
|
}
|
|
486
535
|
}
|
|
487
536
|
|
|
488
|
-
/**
|
|
489
|
-
* The SPA's copy of kernel's `binaryFormatCoverage`, restated for the reason the two `*_service`
|
|
490
|
-
* members above are: the builder cannot see kernel, and the wire vocabulary that does cross
|
|
491
|
-
* (`@cat-factory/contracts`) carries the schema, not the rule.
|
|
492
|
-
*
|
|
493
|
-
* The THIRD outcome is what must not be lost in the copying. A generator that declares no formats
|
|
494
|
-
* has said "only my modality is known" — a documented state, not an empty answer — so a
|
|
495
|
-
* requirement it cannot be judged against is unverifiable and the step still starts. Collapsing
|
|
496
|
-
* that into `uncovered` would flag steps the backend admits (and send someone editing a selection
|
|
497
|
-
* that is fine); collapsing it into silence would present an unchecked requirement as a checked
|
|
498
|
-
* one.
|
|
499
|
-
*/
|
|
500
|
-
function formatCoverage(
|
|
501
|
-
required: readonly string[],
|
|
502
|
-
selected: readonly Pick<RegisteredBinaryGenerator, 'mediaTypes'>[],
|
|
503
|
-
): { uncovered: string[]; unverifiable: string[] } {
|
|
504
|
-
const emitted = new Set(selected.flatMap((g) => g.mediaTypes ?? []))
|
|
505
|
-
const undeclared = selected.some((g) => (g.mediaTypes ?? []).length === 0)
|
|
506
|
-
const uncovered: string[] = []
|
|
507
|
-
const unverifiable: string[] = []
|
|
508
|
-
for (const mediaType of required) {
|
|
509
|
-
if (emitted.has(mediaType)) continue
|
|
510
|
-
if (undeclared) unverifiable.push(mediaType)
|
|
511
|
-
else uncovered.push(mediaType)
|
|
512
|
-
}
|
|
513
|
-
return { uncovered, unverifiable }
|
|
514
|
-
}
|
|
515
|
-
|
|
516
537
|
/**
|
|
517
538
|
* Validate a step's selection against the workspace's RESOLVED catalog — the same catalog run
|
|
518
539
|
* admission re-validates against, which is the whole reason the picker offers only resolved
|
|
@@ -572,6 +593,7 @@ export function binaryOutputPickIssues(
|
|
|
572
593
|
uncoveredModalities: generative.uncovered,
|
|
573
594
|
uncoveredMediaTypes: generative.uncoveredMediaTypes,
|
|
574
595
|
unverifiableMediaTypes: generative.unverifiableMediaTypes,
|
|
596
|
+
generatorOverlaps: generative.overlaps,
|
|
575
597
|
}
|
|
576
598
|
}
|
|
577
599
|
|
|
@@ -595,5 +617,6 @@ export function binaryOutputPickIssues(
|
|
|
595
617
|
uncoveredModalities: generative.uncovered,
|
|
596
618
|
uncoveredMediaTypes: generative.uncoveredMediaTypes,
|
|
597
619
|
unverifiableMediaTypes: generative.unverifiableMediaTypes,
|
|
620
|
+
generatorOverlaps: generative.overlaps,
|
|
598
621
|
}
|
|
599
622
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import type { PipelineStep, StepPhaseMetrics } from '~/types/execution'
|
|
3
|
-
import { foldRunPhaseMetrics, totalInputTokens } from './observability'
|
|
3
|
+
import { foldRunPhaseMetrics, formatCost, sumCosts, totalInputTokens } from './observability'
|
|
4
4
|
|
|
5
5
|
describe('totalInputTokens', () => {
|
|
6
6
|
it('sums all three input classes, so the headline matches Claude Code’s context gauge', () => {
|
|
@@ -85,3 +85,46 @@ describe('foldRunPhaseMetrics', () => {
|
|
|
85
85
|
expect(row.calls).toBe(3)
|
|
86
86
|
})
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
describe('formatCost', () => {
|
|
90
|
+
it('omits the figure entirely when nothing priced it', () => {
|
|
91
|
+
// Null, never "0.00": a deployment that cannot price a model and a step that cost nothing
|
|
92
|
+
// are opposite facts, and rendering both as zero states the wrong one confidently.
|
|
93
|
+
expect(formatCost(null, 'EUR')).toBeNull()
|
|
94
|
+
expect(formatCost(undefined, 'EUR')).toBeNull()
|
|
95
|
+
// A genuine zero still renders — it is a real, priced answer.
|
|
96
|
+
expect(formatCost(0, 'EUR')).toBe('0.00 EUR')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps more decimals under a unit, where most steps land', () => {
|
|
100
|
+
expect(formatCost(0.0037, 'EUR')).toBe('0.0037 EUR')
|
|
101
|
+
expect(formatCost(12.5, 'EUR')).toBe('12.50 EUR')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('shows a threshold rather than rounding a real cost down to zero', () => {
|
|
105
|
+
// `0.0000` makes a priced-but-tiny step read as free — the same claim the null case is
|
|
106
|
+
// careful not to make. A cheap step is not a free one.
|
|
107
|
+
expect(formatCost(0.00001, 'EUR')).toBe('<0.0001 EUR')
|
|
108
|
+
expect(formatCost(0.0001, 'EUR')).toBe('0.0001 EUR')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('labels the amount with the currency it was priced in rather than assuming one', () => {
|
|
112
|
+
// The price table's currency is operator-configured; the built-in one is EUR, not USD.
|
|
113
|
+
expect(formatCost(1, 'USD')).toBe('1.00 USD')
|
|
114
|
+
expect(formatCost(1)).toBe('1.00')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('sumCosts', () => {
|
|
119
|
+
it('adds the parts it can price', () => {
|
|
120
|
+
expect(sumCosts([1, 2, 0.5])).toBe(3.5)
|
|
121
|
+
expect(sumCosts([])).toBe(0)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('declines to answer when any part is unpriced, rather than under-reporting', () => {
|
|
125
|
+
// A total that silently dropped its unpriceable term is a smaller number that still reads
|
|
126
|
+
// as complete — strictly worse than no number.
|
|
127
|
+
expect(sumCosts([1, null, 2])).toBeNull()
|
|
128
|
+
expect(sumCosts([undefined])).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -36,6 +36,52 @@ export function totalInputTokens(m: {
|
|
|
36
36
|
return m.promptTokens + (m.cacheReadTokens ?? 0) + (m.cacheWriteTokens ?? 0)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Smallest amount {@link formatCost} will print as a figure. Below it, four decimals round to
|
|
41
|
+
* `0.0000`, which is the same "free" claim a null renders as `0.00` — so such an amount is
|
|
42
|
+
* shown as a threshold instead.
|
|
43
|
+
*/
|
|
44
|
+
const MIN_RENDERED_COST = 0.0001
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format an estimated cost for display, or null when there is nothing honest to show.
|
|
48
|
+
*
|
|
49
|
+
* Null in ⇒ null out, and the caller renders the tokens WITHOUT a money figure: a cost the
|
|
50
|
+
* deployment could not price and a cost of zero are different facts, and `0.00` claims the
|
|
51
|
+
* second one. Small amounts keep more decimals because most steps land well under a unit and
|
|
52
|
+
* rounding them all to `0.00` would make the whole column useless; an amount too small even
|
|
53
|
+
* for those decimals is rendered as `<0.0001` rather than rounded down to the zero this
|
|
54
|
+
* function exists to avoid printing.
|
|
55
|
+
*/
|
|
56
|
+
export function formatCost(amount: number | null | undefined, currency?: string): string | null {
|
|
57
|
+
if (amount == null) return null
|
|
58
|
+
const value = formatCostAmount(amount)
|
|
59
|
+
// The currency is a bare ISO code beside the number rather than a locale symbol: the amounts
|
|
60
|
+
// come from a deployment-configured table whose code is whatever an operator set, and a
|
|
61
|
+
// symbol we guessed for an unrecognised code would be a wrong label on a right number.
|
|
62
|
+
return currency ? `${value} ${currency}` : value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatCostAmount(amount: number): string {
|
|
66
|
+
if (amount > 0 && amount < MIN_RENDERED_COST) return `<${MIN_RENDERED_COST}`
|
|
67
|
+
// Four decimals under a unit, where most steps land; two above it, where they read as money.
|
|
68
|
+
return amount.toFixed(amount > 0 && amount < 1 ? 4 : 2)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sum costs across rows the way the backend folds do: NULL contaminates rather than being
|
|
73
|
+
* skipped as zero, so a total that could not price one of its parts declines to answer instead
|
|
74
|
+
* of reporting a smaller number that reads as complete.
|
|
75
|
+
*/
|
|
76
|
+
export function sumCosts(values: readonly (number | null | undefined)[]): number | null {
|
|
77
|
+
let total = 0
|
|
78
|
+
for (const value of values) {
|
|
79
|
+
if (value == null) return null
|
|
80
|
+
total += value
|
|
81
|
+
}
|
|
82
|
+
return total
|
|
83
|
+
}
|
|
84
|
+
|
|
39
85
|
/** Compact duration: 850 → "850ms", 1500 → "1.5s", 90_000 → "1m 30s". */
|
|
40
86
|
export function formatMs(ms: number): string {
|
|
41
87
|
if (ms < 1000) return `${Math.round(ms)}ms`
|
|
@@ -78,6 +124,7 @@ const EMPTY_PHASE: Omit<StepPhaseMetrics, 'phase'> = {
|
|
|
78
124
|
completionTokens: 0,
|
|
79
125
|
carryCostTokens: 0,
|
|
80
126
|
errors: 0,
|
|
127
|
+
costEstimate: 0,
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
/**
|
|
@@ -116,6 +163,9 @@ export function foldRunPhaseMetrics(steps: readonly PipelineStep[]): StepPhaseMe
|
|
|
116
163
|
completionTokens: prev.completionTokens + row.completionTokens,
|
|
117
164
|
carryCostTokens: prev.carryCostTokens + row.carryCostTokens,
|
|
118
165
|
errors: prev.errors + row.errors,
|
|
166
|
+
// Same contaminating sum the backend fold uses: one unpriced phase makes the run's
|
|
167
|
+
// figure unknown rather than quietly smaller.
|
|
168
|
+
costEstimate: sumCosts([prev.costEstimate, row.costEstimate]),
|
|
119
169
|
})
|
|
120
170
|
}
|
|
121
171
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ReproductionStatus } from '~/types/reproduction'
|
|
2
|
+
|
|
3
|
+
// Presentation for the BUGFIX REPRODUCTION PROOF verdict.
|
|
4
|
+
//
|
|
5
|
+
// The lookup is by a wire value, so it lives here as an EXHAUSTIVE `Record` keyed off the
|
|
6
|
+
// contracts union rather than as a t() call over a key assembled at the call site. The typed-key
|
|
7
|
+
// check cannot see a runtime-assembled key, so a fourth verdict added to the union would ship as a
|
|
8
|
+
// blank chip on exactly the surface whose job is to say what was and was not proven; keyed this
|
|
9
|
+
// way it fails to compile until the copy exists.
|
|
10
|
+
|
|
11
|
+
export interface ReproductionStatusPresentation {
|
|
12
|
+
/** Short chip copy: the verdict as one or two words. */
|
|
13
|
+
chip: string
|
|
14
|
+
/** One sentence a reviewer decides on. */
|
|
15
|
+
verdict: string
|
|
16
|
+
icon: string
|
|
17
|
+
/** Whether this verdict IS proof — the only one a collapsed row states completely. */
|
|
18
|
+
proven: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const REPRODUCTION_STATUS_KEYS: Record<ReproductionStatus, ReproductionStatusPresentation> =
|
|
22
|
+
{
|
|
23
|
+
reproduced: {
|
|
24
|
+
chip: 'panels.stepDetail.reproduction.status.reproduced',
|
|
25
|
+
verdict: 'panels.stepDetail.reproduction.verdict.reproduced',
|
|
26
|
+
icon: 'i-lucide-bug-off',
|
|
27
|
+
proven: true,
|
|
28
|
+
},
|
|
29
|
+
inconclusive: {
|
|
30
|
+
chip: 'panels.stepDetail.reproduction.status.inconclusive',
|
|
31
|
+
verdict: 'panels.stepDetail.reproduction.verdict.inconclusive',
|
|
32
|
+
icon: 'i-lucide-bug',
|
|
33
|
+
proven: false,
|
|
34
|
+
},
|
|
35
|
+
declared_infeasible: {
|
|
36
|
+
chip: 'panels.stepDetail.reproduction.status.declared_infeasible',
|
|
37
|
+
verdict: 'panels.stepDetail.reproduction.verdict.declared_infeasible',
|
|
38
|
+
icon: 'i-lucide-clipboard-list',
|
|
39
|
+
proven: false,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The two checkouts the same command was run against. A literal pair rather than a lookup: it is
|
|
45
|
+
* the feature's own vocabulary (the tree BEFORE the change and the tree after it), not a wire
|
|
46
|
+
* union that can grow.
|
|
47
|
+
*/
|
|
48
|
+
export const REPRODUCTION_TREE_KEYS = {
|
|
49
|
+
base: 'panels.stepDetail.reproduction.tree.base',
|
|
50
|
+
final: 'panels.stepDetail.reproduction.tree.final',
|
|
51
|
+
} as const
|