@cat-factory/app 0.256.3 → 0.258.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.
Files changed (59) hide show
  1. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
  2. package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
  3. package/app/components/board/AddTaskModal.vue +38 -12
  4. package/app/components/board/RecurringPipelineModal.vue +24 -12
  5. package/app/components/board/nodes/TaskCard.vue +34 -7
  6. package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
  7. package/app/components/panels/AgentStepDetail.vue +42 -20
  8. package/app/components/panels/InspectorPanel.vue +39 -0
  9. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  10. package/app/components/panels/inspector/TaskExecution.vue +34 -34
  11. package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
  12. package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
  13. package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
  14. package/app/components/pipeline/PipelineBuilder.vue +44 -0
  15. package/app/components/pipeline/PipelinePreview.vue +20 -1
  16. package/app/components/pipeline/PipelineProgress.vue +50 -0
  17. package/app/composables/api/binaryCandidates.ts +36 -0
  18. package/app/composables/api/execution.ts +19 -0
  19. package/app/composables/useApi.ts +2 -0
  20. package/app/composables/usePipelineHealth.spec.ts +42 -5
  21. package/app/composables/usePipelineHealth.ts +109 -48
  22. package/app/modular/agent-kinds.ts +5 -0
  23. package/app/modular/result-views.ts +4 -0
  24. package/app/stores/binaryCandidates.ts +89 -0
  25. package/app/stores/environmentWizard/context.ts +0 -2
  26. package/app/stores/environmentWizard/flow.ts +11 -6
  27. package/app/stores/environmentWizard.ts +12 -11
  28. package/app/stores/execution/commands.ts +26 -1
  29. package/app/stores/pipelines/draftActions.ts +2 -0
  30. package/app/stores/pipelines/draftStepConfig.ts +4 -161
  31. package/app/stores/pipelines/draftStepOptions.ts +204 -0
  32. package/app/stores/ui/resultViews.ts +8 -6
  33. package/app/stores/ui/runStepOpeners.ts +23 -1
  34. package/app/types/domain.ts +6 -0
  35. package/app/types/execution.ts +5 -0
  36. package/app/utils/agentPalette.spec.ts +26 -0
  37. package/app/utils/agentPalette.ts +9 -4
  38. package/app/utils/binaryCandidates.spec.ts +110 -0
  39. package/app/utils/binaryCandidates.ts +126 -0
  40. package/app/utils/binaryOutput.spec.ts +122 -1
  41. package/app/utils/binaryOutput.ts +149 -2
  42. package/app/utils/catalog.spec.ts +24 -0
  43. package/app/utils/catalog.ts +21 -4
  44. package/app/utils/pipeline.spec.ts +35 -3
  45. package/app/utils/pipeline.ts +54 -3
  46. package/app/utils/pipelineRender.spec.ts +122 -1
  47. package/app/utils/pipelineRender.ts +113 -2
  48. package/i18n/locales/de.json +107 -3
  49. package/i18n/locales/en.json +107 -3
  50. package/i18n/locales/es.json +107 -3
  51. package/i18n/locales/fr.json +107 -3
  52. package/i18n/locales/he.json +107 -3
  53. package/i18n/locales/it.json +107 -3
  54. package/i18n/locales/ja.json +107 -3
  55. package/i18n/locales/pl.json +107 -3
  56. package/i18n/locales/tr.json +107 -3
  57. package/i18n/locales/uk.json +107 -3
  58. package/i18n/plural-forms.spec.ts +15 -0
  59. package/package.json +2 -2
@@ -44,14 +44,19 @@ export interface NarrowedAgentPalette<T> {
44
44
  * nothing behaves, and none of them is a reason to drop it from the catalog.
45
45
  */
46
46
  export function narrowAgentPalette<
47
- T extends Pick<AgentArchetype, 'tier' | 'category' | 'purposes'>,
47
+ T extends Pick<AgentArchetype, 'tier' | 'category' | 'purposes' | 'internal'>,
48
48
  >(archetypes: readonly T[], purpose: PipelinePurpose, tier: AgentTier): NarrowedAgentPalette<T> {
49
+ // INTERNAL kinds are removed BEFORE either dial, and are counted by neither. They are not
50
+ // hidden, they are not placeable at all: the platform dispatches them for a flow of its own
51
+ // (the environment analyst hands its draft to the setup wizard), so counting one as
52
+ // "3 more at the widest tier" would send a reader to a dial that will never reveal it.
53
+ const placeable = archetypes.filter((a) => !a.internal)
49
54
  const relevant = (a: T) => purposeSuggestsAgentKind(purpose, a)
50
55
  const inTier = (a: T) => agentTierVisibleAt(a.tier, tier)
51
56
  return {
52
- offered: archetypes.filter((a) => relevant(a) && inTier(a)),
53
- hiddenByPurpose: archetypes.filter((a) => inTier(a) && !relevant(a)).length,
54
- hiddenByTier: archetypes.filter((a) => relevant(a) && !inTier(a)).length,
57
+ offered: placeable.filter((a) => relevant(a) && inTier(a)),
58
+ hiddenByPurpose: placeable.filter((a) => inTier(a) && !relevant(a)).length,
59
+ hiddenByTier: placeable.filter((a) => relevant(a) && !inTier(a)).length,
55
60
  }
56
61
  }
57
62
 
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { PipelineStep } from '~/types/execution'
3
+ import { binaryCandidateHasWarnings, binaryCandidateView } from './binaryCandidates'
4
+
5
+ function step(overrides: Record<string, unknown> = {}): PipelineStep {
6
+ // The candidate override MERGES into the base state rather than replacing it, so a case that
7
+ // only cares about one counter does not have to restate the whole candidate list.
8
+ const { binaryCandidates, ...rest } = overrides
9
+ return {
10
+ agentKind: 'imager',
11
+ state: 'waiting_decision',
12
+ binaryCandidates: {
13
+ status: 'awaiting_choice',
14
+ multiSelect: false,
15
+ invalidEntries: 0,
16
+ omitted: 0,
17
+ unusablePreviews: 0,
18
+ candidates: [
19
+ { id: 'c1', service: 's', location: 'a.png', subject: 'anvil', generator: 'flux' },
20
+ { id: 'c2', service: 's', location: 'b.png', subject: 'anvil', generator: 'retro' },
21
+ { id: 'c3', service: 's', location: 'c.png', subject: 'hammer' },
22
+ ],
23
+ ...(binaryCandidates as Record<string, unknown> | undefined),
24
+ },
25
+ ...rest,
26
+ } as unknown as PipelineStep
27
+ }
28
+
29
+ describe('binaryCandidateView', () => {
30
+ // A step that never compared renders nothing at all, exactly as the binary-output section does
31
+ // for a step that never generated: a row saying "no comparison here" would ride every step.
32
+ it('is absent for a step with no comparison story', () => {
33
+ expect(binaryCandidateView({ agentKind: 'coder' } as PipelineStep)).toBeNull()
34
+ expect(binaryCandidateView(null)).toBeNull()
35
+ })
36
+
37
+ // A person compares one subject at a time; forty subjects is forty comparisons, not one wall of
38
+ // eighty pictures.
39
+ it('groups candidates by subject in first-appearance order', () => {
40
+ const view = binaryCandidateView(step())!
41
+ expect(view.groups.map((g) => g.subject)).toEqual(['anvil', 'hammer'])
42
+ expect(view.groups[0]?.rows.map((r) => r.id)).toEqual(['c1', 'c2'])
43
+ })
44
+
45
+ // An unlabelled candidate is not "the same thing" as any labelled one, and filing it under the
46
+ // first subject would put a picture of something else into a comparison.
47
+ it('keeps unlabelled candidates in their own group rather than merging them', () => {
48
+ const view = binaryCandidateView(
49
+ step({
50
+ binaryCandidates: {
51
+ candidates: [
52
+ { id: 'c1', service: 's', location: 'a.png', subject: 'anvil' },
53
+ { id: 'c2', service: 's', location: 'b.png' },
54
+ ],
55
+ },
56
+ }),
57
+ )!
58
+ expect(view.groups.map((g) => g.subject)).toEqual(['anvil', null])
59
+ })
60
+
61
+ it('marks what was kept and the id it was kept under', () => {
62
+ const view = binaryCandidateView(
63
+ step({
64
+ binaryCandidates: {
65
+ status: 'chosen',
66
+ multiSelect: true,
67
+ choice: {
68
+ kept: [{ candidateId: 'c2', storeAs: 'anvil-pixel' }],
69
+ discarded: ['c1', 'c3'],
70
+ at: 1,
71
+ },
72
+ },
73
+ }),
74
+ )!
75
+ const rows = view.groups.flatMap((g) => g.rows)
76
+ expect(rows.find((r) => r.id === 'c2')).toMatchObject({ kept: true, storeAs: 'anvil-pixel' })
77
+ expect(rows.find((r) => r.id === 'c1')?.kept).toBe(false)
78
+ expect(view.awaiting).toBe(false)
79
+ })
80
+
81
+ // An automatic keep is NOT a review. A surface that renders it as a choice tells a reader a
82
+ // person looked at this and approved it, which is the claim the whole feature exists to make
83
+ // true.
84
+ it('reports an automatic keep as its own fact', () => {
85
+ const view = binaryCandidateView(
86
+ step({
87
+ binaryCandidates: {
88
+ status: 'chosen',
89
+ choice: { kept: [{ candidateId: 'c1' }], discarded: [], automatic: true, at: 1 },
90
+ },
91
+ }),
92
+ )!
93
+ expect(view.automatic).toBe(true)
94
+ })
95
+
96
+ it('counts candidates with no renderable preview', () => {
97
+ expect(binaryCandidateView(step())!.withoutPreview).toBe(3)
98
+ })
99
+ })
100
+
101
+ describe('binaryCandidateHasWarnings', () => {
102
+ // A comparison made over three of five candidates must not read as one made over all five.
103
+ it('is raised by any counted loss and by nothing else', () => {
104
+ expect(binaryCandidateHasWarnings(binaryCandidateView(step())!)).toBe(false)
105
+ for (const field of ['invalidEntries', 'omitted', 'unusablePreviews'] as const) {
106
+ const view = binaryCandidateView(step({ binaryCandidates: { [field]: 1 } }))!
107
+ expect(binaryCandidateHasWarnings(view)).toBe(true)
108
+ }
109
+ })
110
+ })
@@ -0,0 +1,126 @@
1
+ import type { BinaryCandidate, BinaryCandidateStepState, PipelineStep } from '~/types/execution'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The read model behind the candidate-comparison surface
5
+ // (docs/initiatives/binary-output-foundational-storage.md).
6
+ //
7
+ // A step whose selection declares a `comparison` generates a candidate from each of its selected
8
+ // integrations, stages them, and parks. This module turns that record into what the window
9
+ // renders: the candidates GROUPED BY SUBJECT (which is what a person actually compares), the
10
+ // preview each one does or does not have, and every loss the parse counted.
11
+ //
12
+ // Pure, and reads only the step's own record, which is the rule the sibling binary-output read
13
+ // model follows and for the same reason: the join a human wants is answerable from the step alone, so
14
+ // the surface needs no fetch and reads identically for a finished run.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** One candidate as the window renders it, with the two facts the raw record does not carry. */
18
+ export interface BinaryCandidateRow extends BinaryCandidate {
19
+ /**
20
+ * The human kept this one. Meaningful only once a choice exists, which is what lets the window
21
+ * double as the RECORD of a settled comparison rather than only its control surface.
22
+ */
23
+ kept: boolean
24
+ /** The id it is to be stored under, when the person who kept it assigned one. */
25
+ storeAs?: string
26
+ }
27
+
28
+ /** The candidates for one subject, which is the unit a person compares. */
29
+ export interface BinaryCandidateGroup {
30
+ /**
31
+ * What these candidates depict, or null when the agent declared no subject. Null is its own
32
+ * group rather than being merged into another: an unlabelled candidate is not "the same thing"
33
+ * as any labelled one, and quietly filing it under the first subject would put a picture of
34
+ * something else into a comparison.
35
+ */
36
+ subject: string | null
37
+ rows: BinaryCandidateRow[]
38
+ }
39
+
40
+ /** The whole surface's read model. */
41
+ export interface BinaryCandidateView {
42
+ state: BinaryCandidateStepState
43
+ /** Candidates grouped by subject, in first-appearance order. */
44
+ groups: BinaryCandidateGroup[]
45
+ /** Whether the run is parked on this decision right now (as opposed to showing the record). */
46
+ awaiting: boolean
47
+ /** Whether more than one candidate may be kept. */
48
+ multiSelect: boolean
49
+ /**
50
+ * True when the engine kept the only candidate without asking. Its own flag rather than an
51
+ * absent decider, because a surface that renders it as a choice claims a person looked at this.
52
+ */
53
+ automatic: boolean
54
+ /** How many candidates carry no renderable preview, so the window can say so once. */
55
+ withoutPreview: number
56
+ }
57
+
58
+ /**
59
+ * The step's candidate read model, or null when the step has no comparison story at all.
60
+ *
61
+ * A step that never compared renders nothing, exactly as the binary-output section does for a step
62
+ * that never generated: a row saying "no comparison was configured here" would ride every step of
63
+ * every run.
64
+ */
65
+ export function binaryCandidateView(
66
+ step: PipelineStep | null | undefined,
67
+ ): BinaryCandidateView | null {
68
+ const state = step?.binaryCandidates
69
+ if (!state) return null
70
+ const kept = new Map((state.choice?.kept ?? []).map((entry) => [entry.candidateId, entry]))
71
+ const groups: BinaryCandidateGroup[] = []
72
+ const bySubject = new Map<string | null, BinaryCandidateGroup>()
73
+ for (const candidate of state.candidates) {
74
+ const subject = candidate.subject ?? null
75
+ let group = bySubject.get(subject)
76
+ if (!group) {
77
+ group = { subject, rows: [] }
78
+ bySubject.set(subject, group)
79
+ groups.push(group)
80
+ }
81
+ const choice = kept.get(candidate.id)
82
+ group.rows.push({
83
+ ...candidate,
84
+ kept: choice !== undefined,
85
+ ...(choice?.storeAs ? { storeAs: choice.storeAs } : {}),
86
+ })
87
+ }
88
+ return {
89
+ state,
90
+ groups,
91
+ awaiting: state.status === 'awaiting_choice',
92
+ multiSelect: state.multiSelect === true,
93
+ automatic: state.choice?.automatic === true,
94
+ withoutPreview: state.candidates.filter((candidate) => !candidate.previewUrl).length,
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Whether anything about this comparison needs saying beside the candidates: entries the parse
100
+ * dropped, a truncated list, or preview links it refused.
101
+ *
102
+ * Drives the window's warning strip, so a comparison made over three of five candidates cannot
103
+ * read as one made over all of them. A missing PREVIEW is deliberately not one of these: it is
104
+ * ordinary (a private asset store issues no public link) and it is stated per candidate, where
105
+ * the reader can see exactly which one they are judging blind.
106
+ */
107
+ export function binaryCandidateHasWarnings(view: BinaryCandidateView): boolean {
108
+ const { invalidEntries = 0, omitted = 0, unusablePreviews = 0 } = view.state
109
+ return invalidEntries > 0 || omitted > 0 || unusablePreviews > 0
110
+ }
111
+
112
+ /**
113
+ * State → the i18n key for the line explaining why no choice was offered.
114
+ *
115
+ * An exhaustive `Record` over the reason vocabulary, so a fourth reason fails the typecheck here
116
+ * rather than rendering a missing key. Every member is a different fault with a different fix,
117
+ * which is exactly why the engine records the reason instead of leaving the step blank.
118
+ */
119
+ export const BINARY_CANDIDATE_NO_CHOICE_KEYS: Record<
120
+ NonNullable<BinaryCandidateStepState['noChoiceReason']>,
121
+ string
122
+ > = {
123
+ undeclared: 'binaryCandidates.noChoice.undeclared',
124
+ parse_failed: 'binaryCandidates.noChoice.parseFailed',
125
+ no_candidates: 'binaryCandidates.noChoice.noCandidates',
126
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import type { BinaryOutputReport, PipelineStep } from '~/types/execution'
2
+ import type { BinaryOutputArtifact, BinaryOutputReport, PipelineStep } from '~/types/execution'
3
3
  import {
4
4
  BINARY_OUTPUT_STATE_KEYS,
5
5
  binaryOutputHasWarnings,
@@ -371,6 +371,95 @@ describe('the delivered-format check', () => {
371
371
  })
372
372
  })
373
373
 
374
+ // The size axis's delivery-side half. Admission checked what the selected integrations can be
375
+ // ASKED for; only this checks what came back, which is the failure the requirement exists for:
376
+ // an asset stored at the wrong size passes every other check on this panel.
377
+ describe('binaryOutputView, the delivered size', () => {
378
+ const sized = (
379
+ outputSize: { width: number; height: number } | undefined,
380
+ stored: {
381
+ location: string
382
+ dimensions?: { width: number; height: number }
383
+ modality?: BinaryOutputArtifact['modality']
384
+ }[],
385
+ ) =>
386
+ binaryOutputView(
387
+ step({
388
+ stepOptions: {
389
+ binaryOutput: {
390
+ storageServiceId: 'files',
391
+ ...(outputSize ? { generation: { outputSize } } : {}),
392
+ },
393
+ },
394
+ binaryOutputs: report({
395
+ stored: stored.map((entry) => ({ service: 'files', ...entry })),
396
+ }),
397
+ }),
398
+ )
399
+
400
+ it('counts an artifact delivered at another size, and marks its row', () => {
401
+ const view = sized({ width: 96, height: 96 }, [
402
+ { location: 'a.png', dimensions: { width: 96, height: 96 } },
403
+ { location: 'b.png', dimensions: { width: 1024, height: 1024 } },
404
+ ])
405
+ expect(view?.requiredSize).toEqual({ width: 96, height: 96 })
406
+ expect(view?.missized).toBe(1)
407
+ expect(view?.rows.map((row) => row.missized)).toEqual([false, true])
408
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
409
+ })
410
+
411
+ it('counts an UNMEASURED artifact apart, never as a delivered one', () => {
412
+ // The rule this whole feature runs on: "we were not told" and "it came back wrong" are the
413
+ // same value and opposite facts. Folded together, a run that reported nothing would read as
414
+ // one that delivered everything wrong; dropped, it would read as a clean one.
415
+ const view = sized({ width: 96, height: 96 }, [{ location: 'a.png' }])
416
+ expect(view?.missized).toBe(0)
417
+ expect(view?.sizeUnreported).toBe(1)
418
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
419
+ })
420
+
421
+ it('judges nothing when the step asked for no size', () => {
422
+ const view = sized(undefined, [
423
+ { location: 'a.png', dimensions: { width: 1024, height: 1024 } },
424
+ { location: 'b.png' },
425
+ ])
426
+ expect(view?.requiredSize).toBeNull()
427
+ expect(view?.missized).toBe(0)
428
+ expect(view?.sizeUnreported).toBe(0)
429
+ expect(binaryOutputHasWarnings(view!)).toBe(false)
430
+ })
431
+
432
+ // A size is a statement about what is MEASURED in pixels. A step that generates the icon and
433
+ // its pickup sound delivered exactly what was asked, and counting the audio as unmeasured
434
+ // would warn about it forever, on a panel whose whole discipline is that a warning means
435
+ // something went wrong.
436
+ it('says nothing about an artifact a size cannot describe', () => {
437
+ const view = sized({ width: 96, height: 96 }, [
438
+ { location: 'a.png', modality: 'image', dimensions: { width: 96, height: 96 } },
439
+ { location: 'a.mp3', modality: 'audio' },
440
+ { location: 'a.glb', modality: '3d-model' },
441
+ ])
442
+ expect(view?.missized).toBe(0)
443
+ expect(view?.sizeUnreported).toBe(0)
444
+ expect(binaryOutputHasWarnings(view!)).toBe(false)
445
+ })
446
+
447
+ // The other direction, and the one that must not go quiet: an artifact the platform could not
448
+ // CLASSIFY may well be an image, so it stays covered. Excluding it would turn an unreadable
449
+ // content type into a silent pass on the one axis the requirement exists to check, and a
450
+ // RETIRED modality a stale run carries reads the same way.
451
+ it('keeps an unclassified artifact under the requirement', () => {
452
+ const view = sized({ width: 96, height: 96 }, [
453
+ { location: 'a.bin' },
454
+ // A modality the union no longer has, which a run saved before the 3D split goes on
455
+ // carrying. Cast because the TYPE cannot express it and the DATA can, which is the whole
456
+ // reason the read narrows through `isBinaryModality` before it looks anything up.
457
+ { location: 'b.bin', modality: '3d' as BinaryOutputArtifact['modality'] },
458
+ ])
459
+ expect(view?.sizeUnreported).toBe(2)
460
+ })
461
+ })
462
+
374
463
  describe('binaryOutputPickIssues, generative half', () => {
375
464
  const catalog = [{ id: 'files', capabilities: ['asset-storage'] }]
376
465
  const generators = [
@@ -400,6 +489,38 @@ describe('binaryOutputPickIssues, generative half', () => {
400
489
  expect(pick.uncoveredModalities).toEqual(['audio'])
401
490
  })
402
491
 
492
+ // The builder offers a ratio, an exact size and an upscale together, so the conflict it can
493
+ // save is one it must state: reported only by the failed round trip, the fix (delete one of two
494
+ // fields on this form) arrives as backend prose about a step the reader has already left.
495
+ it('states the save refusal for two statements of one delivered size', () => {
496
+ const pick = binaryOutputPickIssues(
497
+ {
498
+ storageServiceId: 'files',
499
+ generatorIds: ['retro'],
500
+ generation: { outputSize: { width: 96, height: 96 }, aspectRatio: '16:9' },
501
+ },
502
+ catalog,
503
+ true,
504
+ generators,
505
+ )
506
+ expect(pick.issues).toContain('output_size_ambiguous')
507
+ expect(pick.conflictingSizeOptions).toEqual(['aspectRatio'])
508
+ })
509
+
510
+ // A step with no storage pick still gets the whole list: the two halves resolve against
511
+ // different registries, and reporting them one round at a time is the retry cycle this
512
+ // function returns every issue together to avoid.
513
+ it('states it on a step whose storage is not picked either', () => {
514
+ const pick = binaryOutputPickIssues(
515
+ { storageServiceId: '', generation: { outputSize: { width: 96, height: 96 }, upscale: 2 } },
516
+ catalog,
517
+ true,
518
+ generators,
519
+ )
520
+ expect(pick.issues).toEqual(expect.arrayContaining(['not_selected', 'output_size_ambiguous']))
521
+ expect(pick.conflictingSizeOptions).toEqual(['upscale'])
522
+ })
523
+
403
524
  it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
404
525
  // One edit should clear the step. Naming only the missing id would leave the user to
405
526
  // discover the uncovered requirement on the next round trip.