@cat-factory/app 0.227.0 → 0.228.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.
Files changed (38) hide show
  1. package/README.md +25 -0
  2. package/app/components/board/AddTaskModal.vue +11 -7
  3. package/app/components/board/RecurringPipelineModal.vue +11 -7
  4. package/app/components/board/nodes/TaskCard.vue +68 -8
  5. package/app/components/bootstrap/BootstrapModal.vue +11 -7
  6. package/app/components/documents/RepoContextDocPicker.vue +4 -1
  7. package/app/components/fragments/FragmentLibraryManager.vue +19 -18
  8. package/app/components/gates/GateResultView.vue +3 -3
  9. package/app/components/github/AddServiceFromRepoModal.vue +10 -9
  10. package/app/components/outcome/OutcomeSummaryWindow.vue +614 -0
  11. package/app/components/panels/AgentStepDetail.vue +9 -7
  12. package/app/components/panels/InspectorPanel.vue +4 -4
  13. package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
  14. package/app/components/panels/inspector/ServiceTestConfig.vue +12 -11
  15. package/app/components/panels/inspector/TaskExecution.vue +40 -7
  16. package/app/components/pipeline/PipelineProgress.vue +5 -4
  17. package/app/components/providers/ApiKeysSection.vue +3 -1
  18. package/app/components/ralph/RalphLoopResultView.vue +3 -3
  19. package/app/components/visualConfirm/VisualConfirmationWindow.vue +14 -14
  20. package/app/composables/useRunDeepLink.ts +12 -4
  21. package/app/modular/result-views.ts +4 -0
  22. package/app/pages/index.vue +21 -18
  23. package/app/stores/execution.ts +6 -6
  24. package/app/stores/ui/resultViews.ts +28 -0
  25. package/app/stores/ui.dispatch.spec.ts +55 -0
  26. package/app/utils/runOutcome.spec.ts +492 -0
  27. package/app/utils/runOutcome.ts +509 -0
  28. package/i18n/locales/de.json +107 -0
  29. package/i18n/locales/en.json +107 -0
  30. package/i18n/locales/es.json +107 -0
  31. package/i18n/locales/fr.json +107 -0
  32. package/i18n/locales/he.json +107 -0
  33. package/i18n/locales/it.json +107 -0
  34. package/i18n/locales/ja.json +107 -0
  35. package/i18n/locales/pl.json +107 -0
  36. package/i18n/locales/tr.json +107 -0
  37. package/i18n/locales/uk.json +107 -0
  38. package/package.json +2 -2
@@ -0,0 +1,492 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Block } from '~/types/domain'
3
+ import type { ExecutionInstance, PipelineStep } from '~/types/execution'
4
+ import type { ServiceSpecView } from '~/types/spec'
5
+ import { composeRunOutcome, hasOutcomeToShow } from '~/utils/runOutcome'
6
+
7
+ // The composer's whole job is to keep facts that mean different things from rendering the same,
8
+ // so the cases worth pinning are the COLLAPSES: an absent producer vs a producer that found
9
+ // nothing, an aspirational failure vs a regression, a capture nobody reviewed vs a pair a human
10
+ // approved, and a tester that could not run vs one that ran and raised concerns.
11
+
12
+ function block(overrides: Partial<Block> = {}): Block {
13
+ return {
14
+ id: 'blk_1',
15
+ title: 'Password reset',
16
+ description: ' Let a signed-out user reset their password by email. ',
17
+ status: 'pr_ready',
18
+ level: 'task',
19
+ type: 'task',
20
+ parentId: 'frm_1',
21
+ position: { x: 0, y: 0 },
22
+ progress: 0,
23
+ dependsOn: [],
24
+ taskType: 'feature',
25
+ ...overrides,
26
+ } as Block
27
+ }
28
+
29
+ function step(overrides: Partial<PipelineStep> = {}): PipelineStep {
30
+ return {
31
+ agentKind: 'coder',
32
+ state: 'done',
33
+ progress: 1,
34
+ decision: null,
35
+ ...overrides,
36
+ } as PipelineStep
37
+ }
38
+
39
+ function run(steps: PipelineStep[], overrides: Partial<ExecutionInstance> = {}): ExecutionInstance {
40
+ return {
41
+ id: 'exe_1',
42
+ blockId: 'blk_1',
43
+ pipelineId: 'pl_1',
44
+ pipelineName: 'Build',
45
+ steps,
46
+ currentStep: 0,
47
+ status: 'running',
48
+ ...overrides,
49
+ } as ExecutionInstance
50
+ }
51
+
52
+ function testerStep(report: Record<string, unknown>, kind = 'tester-ui'): PipelineStep {
53
+ return step({
54
+ agentKind: kind,
55
+ test: {
56
+ phase: 'testing',
57
+ attempts: 0,
58
+ maxAttempts: 3,
59
+ lastReport: {
60
+ greenlight: true,
61
+ summary: 'Exercised the reset flow end to end.',
62
+ tested: ['Password reset'],
63
+ outcomes: [],
64
+ concerns: [],
65
+ ...report,
66
+ },
67
+ },
68
+ } as Partial<PipelineStep>)
69
+ }
70
+
71
+ const spec: ServiceSpecView = {
72
+ present: true,
73
+ spec: {
74
+ service: 'accounts',
75
+ summary: '',
76
+ modules: [
77
+ {
78
+ name: 'auth',
79
+ summary: '',
80
+ groups: [
81
+ {
82
+ name: 'login',
83
+ summary: '',
84
+ rules: [],
85
+ requirements: [
86
+ {
87
+ id: 'req-reset',
88
+ title: 'A user can reset their password',
89
+ statement: 'The system SHALL send a reset link.',
90
+ kind: 'functional',
91
+ priority: 'must',
92
+ state: 'aspirational',
93
+ acceptance: [],
94
+ sourceBlockIds: [],
95
+ },
96
+ {
97
+ id: 'req-login',
98
+ title: 'A user can sign in',
99
+ statement: 'The system SHALL authenticate a user.',
100
+ kind: 'functional',
101
+ priority: 'must',
102
+ state: 'established',
103
+ acceptance: [],
104
+ sourceBlockIds: [],
105
+ },
106
+ ],
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ },
112
+ features: [],
113
+ }
114
+
115
+ describe('composeRunOutcome', () => {
116
+ it('carries the ask and every pull request so the diff stays one click away', () => {
117
+ const outcome = composeRunOutcome({
118
+ block: block({
119
+ pullRequest: { url: 'https://host/pr/7', number: 7, branch: 'cat-factory/blk_1' },
120
+ peerPullRequests: [{ repo: 'acme/api', ref: { url: 'https://host/api/pr/3', number: 3 } }],
121
+ }),
122
+ instance: null,
123
+ })
124
+
125
+ expect(outcome.title).toBe('Password reset')
126
+ expect(outcome.ask).toBe('Let a signed-out user reset their password by email.')
127
+ expect(outcome.disposition).toBe('awaiting_merge')
128
+ expect(outcome.pullRequests).toEqual([
129
+ { url: 'https://host/pr/7', number: 7, branch: 'cat-factory/blk_1', repo: null },
130
+ { url: 'https://host/api/pr/3', number: 3, branch: null, repo: 'acme/api' },
131
+ ])
132
+ })
133
+
134
+ it('distinguishes no tester step from a tester that has not reported', () => {
135
+ const none = composeRunOutcome({ block: block(), instance: run([step()]) })
136
+ expect(none.tests).toEqual({ status: 'absent', gap: 'no_tester_step' })
137
+ expect(none.requirements).toEqual({ status: 'absent', gap: 'no_tester_step' })
138
+
139
+ const silent = composeRunOutcome({
140
+ block: block(),
141
+ instance: run([step({ agentKind: 'tester-api' })]),
142
+ })
143
+ expect(silent.tests).toEqual({ status: 'absent', gap: 'tester_not_reported' })
144
+ })
145
+
146
+ it('keeps a tester that could not run apart from one that raised concerns', () => {
147
+ const aborted = composeRunOutcome({
148
+ block: block(),
149
+ instance: run([
150
+ testerStep({
151
+ greenlight: false,
152
+ abort: { reason: 'The preview environment never came up.' },
153
+ }),
154
+ ]),
155
+ })
156
+ expect(aborted.tests).toMatchObject({
157
+ status: 'reported',
158
+ verdict: 'could_not_run',
159
+ abortReason: 'The preview environment never came up.',
160
+ concerns: [],
161
+ })
162
+
163
+ const buggy = composeRunOutcome({
164
+ block: block(),
165
+ instance: run([
166
+ testerStep({
167
+ greenlight: false,
168
+ concerns: [{ title: 'Reset link expires immediately', detail: '…', severity: 'high' }],
169
+ outcomes: [
170
+ { name: 'Reset', status: 'failed' },
171
+ { name: 'Login', status: 'passed' },
172
+ { name: 'Rate limit', status: 'skipped' },
173
+ ],
174
+ }),
175
+ ]),
176
+ })
177
+ expect(buggy.tests).toMatchObject({
178
+ verdict: 'concerns',
179
+ passed: 1,
180
+ failed: 1,
181
+ skipped: 1,
182
+ concerns: [{ title: 'Reset link expires immediately', severity: 'high' }],
183
+ })
184
+ })
185
+
186
+ it('reports a failing ESTABLISHED requirement as a regression and an aspirational one as not', () => {
187
+ const outcome = composeRunOutcome({
188
+ block: block(),
189
+ instance: run([
190
+ testerStep({
191
+ requirementVerdicts: [
192
+ { requirementId: 'req-reset', status: 'not_met', detail: 'Not built yet.' },
193
+ { requirementId: 'req-login', status: 'not_met', detail: 'Sign-in now 500s.' },
194
+ ],
195
+ }),
196
+ ]),
197
+ spec,
198
+ })
199
+
200
+ expect(outcome.requirements).toMatchObject({
201
+ status: 'reported',
202
+ spec: 'joined',
203
+ regressions: 1,
204
+ })
205
+ if (outcome.requirements.status !== 'reported') throw new Error('expected a reported section')
206
+ // The regression leads, and both are still counted as failures.
207
+ expect(outcome.requirements.entries.map((e) => [e.id, e.regression])).toEqual([
208
+ ['req-login', true],
209
+ ['req-reset', false],
210
+ ])
211
+ expect(outcome.requirements.notMet).toBe(2)
212
+ expect(outcome.requirements.entries[0]?.title).toBe('A user can sign in')
213
+ })
214
+
215
+ it('says the spec was never read rather than rendering ids as titles', () => {
216
+ const outcome = composeRunOutcome({
217
+ block: block(),
218
+ instance: run([
219
+ testerStep({ requirementVerdicts: [{ requirementId: 'req-reset', status: 'met' }] }),
220
+ ]),
221
+ })
222
+ expect(outcome.requirements).toMatchObject({ status: 'reported', spec: 'not_read' })
223
+ if (outcome.requirements.status !== 'reported') throw new Error('expected a reported section')
224
+ expect(outcome.requirements.entries[0]).toMatchObject({
225
+ id: 'req-reset',
226
+ title: null,
227
+ state: null,
228
+ regression: false,
229
+ })
230
+ })
231
+
232
+ // A spec that WAS read and names none of the reported ids leaves identical rows behind, and
233
+ // sends the reader to a different fix: the spec moved on, or the tester keyed its verdicts by
234
+ // something else. Reporting it as a failed read would send them to fix a read that worked.
235
+ it('keeps a spec that was never read apart from one that named none of these ids', () => {
236
+ const outcome = composeRunOutcome({
237
+ block: block(),
238
+ instance: run([
239
+ testerStep({ requirementVerdicts: [{ requirementId: 'req-gone', status: 'met' }] }),
240
+ ]),
241
+ spec,
242
+ })
243
+ expect(outcome.requirements).toMatchObject({ status: 'reported', spec: 'unmatched' })
244
+ })
245
+
246
+ // The partial join is the case a section-level note cannot state: some rows carry titles, and
247
+ // an id sitting unmarked between them reads as a requirement named after a slug.
248
+ it('marks the rows the spec did not name while the section as a whole joined', () => {
249
+ const outcome = composeRunOutcome({
250
+ block: block(),
251
+ instance: run([
252
+ testerStep({
253
+ requirementVerdicts: [
254
+ { requirementId: 'req-login', status: 'met' },
255
+ { requirementId: 'req-gone', status: 'met' },
256
+ ],
257
+ }),
258
+ ]),
259
+ spec,
260
+ })
261
+ expect(outcome.requirements).toMatchObject({ status: 'reported', spec: 'joined' })
262
+ if (outcome.requirements.status !== 'reported') throw new Error('expected a reported section')
263
+ const rows = outcome.requirements.entries
264
+ expect(rows.find((e) => e.id === 'req-login')?.title).toBe('A user can sign in')
265
+ expect(rows.find((e) => e.id === 'req-gone')?.title).toBeNull()
266
+ })
267
+
268
+ it('separates a tester report with no verdicts from a tester that never reported', () => {
269
+ const outcome = composeRunOutcome({
270
+ block: block(),
271
+ instance: run([testerStep({ requirementVerdicts: [] })]),
272
+ })
273
+ expect(outcome.requirements).toEqual({ status: 'absent', gap: 'no_verdicts' })
274
+ })
275
+
276
+ it('prefers the reviewed visual-confirmation pairs over the tester’s raw captures', () => {
277
+ const outcome = composeRunOutcome({
278
+ block: block(),
279
+ instance: run([
280
+ testerStep({ screenshots: [{ view: 'reset', artifactId: 'art_shot' }] }),
281
+ step({
282
+ agentKind: 'visual-confirmation',
283
+ visualConfirm: {
284
+ phase: 'approved',
285
+ attempts: 0,
286
+ maxAttempts: 2,
287
+ pairs: [{ view: 'reset', actualArtifactId: 'art_a', referenceArtifactId: 'art_ref' }],
288
+ },
289
+ } as Partial<PipelineStep>),
290
+ ]),
291
+ })
292
+ expect(outcome.visuals).toEqual({
293
+ status: 'reported',
294
+ source: 'visual_confirm',
295
+ phase: 'approved',
296
+ views: [{ view: 'reset', artifactId: 'art_a', referenceArtifactId: 'art_ref' }],
297
+ })
298
+ })
299
+
300
+ it('falls back to the tester’s captures, and says which gap it hit when there are none', () => {
301
+ const captured = composeRunOutcome({
302
+ block: block(),
303
+ instance: run([testerStep({ screenshots: [{ view: 'reset', artifactId: 'art_shot' }] })]),
304
+ })
305
+ expect(captured.visuals).toMatchObject({ status: 'reported', source: 'tester', phase: null })
306
+
307
+ // An API tester captures nothing by design: no producer, so nothing was ever meant to be seen.
308
+ const apiOnly = composeRunOutcome({
309
+ block: block(),
310
+ instance: run([testerStep({}, 'tester-api')]),
311
+ })
312
+ expect(apiOnly.visuals).toEqual({ status: 'absent', gap: 'no_visual_step', detail: null })
313
+
314
+ // A gate that ran and gathered nothing is a different fact, and it recorded why.
315
+ const degraded = composeRunOutcome({
316
+ block: block(),
317
+ instance: run([
318
+ step({
319
+ agentKind: 'visual-confirmation',
320
+ visualConfirm: {
321
+ phase: 'awaiting_human',
322
+ attempts: 0,
323
+ maxAttempts: 2,
324
+ pairs: [],
325
+ degradedReason: 'No artifact storage configured.',
326
+ },
327
+ } as Partial<PipelineStep>),
328
+ ]),
329
+ })
330
+ expect(degraded.visuals).toEqual({
331
+ status: 'absent',
332
+ gap: 'none_captured',
333
+ detail: 'No artifact storage configured.',
334
+ })
335
+ })
336
+
337
+ it('lists only the checks that actually recorded a verdict', () => {
338
+ const outcome = composeRunOutcome({
339
+ block: block(),
340
+ instance: run([
341
+ step({
342
+ agentKind: 'ci',
343
+ gate: { phase: 'checking', attempts: 0, maxAttempts: 3 },
344
+ } as Partial<PipelineStep>),
345
+ step({
346
+ validation: { passed: false, attempts: 2, maxAttempts: 3, outcomes: [] },
347
+ } as Partial<PipelineStep>),
348
+ step({
349
+ reproduction: {
350
+ status: 'inconclusive',
351
+ command: 'pnpm test',
352
+ testPaths: [],
353
+ attempts: 1,
354
+ maxAttempts: 2,
355
+ at: 1,
356
+ },
357
+ } as Partial<PipelineStep>),
358
+ ]),
359
+ })
360
+
361
+ // The CI gate has not probed yet, so it contributes NOTHING rather than a green row.
362
+ expect(outcome.checks).toEqual([
363
+ { kind: 'validation', state: 'fail', reproduction: null },
364
+ { kind: 'reproduction', state: 'inconclusive', reproduction: 'inconclusive' },
365
+ ])
366
+ })
367
+
368
+ it('reads the CI gate’s recorded verdict once it has probed', () => {
369
+ const outcome = composeRunOutcome({
370
+ block: block(),
371
+ instance: run([
372
+ step({
373
+ agentKind: 'ci',
374
+ gate: { phase: 'checking', attempts: 0, maxAttempts: 3, lastVerdict: 'pass' },
375
+ } as Partial<PipelineStep>),
376
+ ]),
377
+ })
378
+ expect(outcome.checks).toEqual([{ kind: 'ci', state: 'pass', reproduction: null }])
379
+ })
380
+
381
+ // A block that NAMES a run the caller could not resolve is the trap this whole module is
382
+ // about: composed from the empty step list it would report a pipeline that ran and produced
383
+ // nothing, which is the opposite of "nobody could read what it produced".
384
+ it('says the run could not be read rather than blaming the pipeline for the missing steps', () => {
385
+ const outcome = composeRunOutcome({
386
+ block: block({ status: 'done', executionId: 'exe_gone' }),
387
+ instance: null,
388
+ })
389
+
390
+ expect(outcome.requirements).toEqual({ status: 'absent', gap: 'run_unavailable' })
391
+ expect(outcome.tests).toEqual({ status: 'absent', gap: 'run_unavailable' })
392
+ expect(outcome.visuals).toEqual({ status: 'absent', gap: 'run_unavailable', detail: null })
393
+ expect(outcome.checks).toEqual([])
394
+ // The block still carries what the block knows.
395
+ expect(outcome.disposition).toBe('merged')
396
+ expect(outcome.title).toBe('Password reset')
397
+ })
398
+
399
+ it('keeps a task that never ran apart from one whose run could not be read', () => {
400
+ const never = composeRunOutcome({ block: block({ status: 'ready' }), instance: null })
401
+ const unread = composeRunOutcome({
402
+ block: block({ status: 'ready', executionId: 'exe_gone' }),
403
+ instance: null,
404
+ })
405
+
406
+ expect(never.disposition).toBe('not_run')
407
+ expect(never.tests).toEqual({ status: 'absent', gap: 'no_tester_step' })
408
+ expect(unread.disposition).toBe('unknown')
409
+ expect(unread.tests).toEqual({ status: 'absent', gap: 'run_unavailable' })
410
+ })
411
+
412
+ // The selected tester is the one that REPORTED, which can be the api half of a pipeline whose
413
+ // ui half has not. Reading the producer off it would tell a reader looking at a UI pipeline
414
+ // that nothing in it captures the interface.
415
+ it('finds the interface producer anywhere in the pipeline, not only on the reporting tester', () => {
416
+ const outcome = composeRunOutcome({
417
+ block: block(),
418
+ instance: run([
419
+ step({ agentKind: 'tester-ui' }),
420
+ testerStep({ screenshots: [] }, 'tester-api'),
421
+ ]),
422
+ })
423
+ expect(outcome.visuals).toEqual({ status: 'absent', gap: 'none_captured', detail: null })
424
+ })
425
+
426
+ it('derives the disposition from the block, and from the run only where the block cannot', () => {
427
+ const dispositions = [
428
+ composeRunOutcome({ block: block({ status: 'done' }), instance: null }).disposition,
429
+ composeRunOutcome({ block: block({ status: 'pr_ready' }), instance: null }).disposition,
430
+ composeRunOutcome({ block: block({ status: 'planned' }), instance: null }).disposition,
431
+ composeRunOutcome({ block: block({ status: 'in_progress' }), instance: run([step()]) })
432
+ .disposition,
433
+ composeRunOutcome({
434
+ block: block({ status: 'in_progress' }),
435
+ instance: run([step()], { status: 'failed' }),
436
+ }).disposition,
437
+ // `in_progress` is the block's own word for a live run, so it stands with no instance.
438
+ composeRunOutcome({
439
+ block: block({ status: 'in_progress', executionId: 'exe_gone' }),
440
+ instance: null,
441
+ }).disposition,
442
+ ]
443
+ expect(dispositions).toEqual([
444
+ 'merged',
445
+ 'awaiting_merge',
446
+ 'not_run',
447
+ 'in_flight',
448
+ 'needs_attention',
449
+ 'in_flight',
450
+ ])
451
+ })
452
+ })
453
+
454
+ describe('hasOutcomeToShow', () => {
455
+ it('is false for a run that has produced nothing to read yet', () => {
456
+ expect(
457
+ hasOutcomeToShow(composeRunOutcome({ block: block({ status: 'planned' }), instance: null })),
458
+ ).toBe(false)
459
+ })
460
+
461
+ // The affordance the board card and the inspector both gate on: a task marked done by hand,
462
+ // carrying no pull request and no readable run, has nothing an outcome card could show.
463
+ it('is false for a task whose run cannot be read and which carries no pull request', () => {
464
+ expect(
465
+ hasOutcomeToShow(
466
+ composeRunOutcome({
467
+ block: block({ status: 'done', executionId: 'exe_gone' }),
468
+ instance: null,
469
+ }),
470
+ ),
471
+ ).toBe(false)
472
+ })
473
+
474
+ it('is true as soon as there is a PR or any recorded evidence', () => {
475
+ expect(
476
+ hasOutcomeToShow(
477
+ composeRunOutcome({
478
+ block: block({ pullRequest: { url: 'https://host/pr/7', number: 7 } }),
479
+ instance: null,
480
+ }),
481
+ ),
482
+ ).toBe(true)
483
+ expect(
484
+ hasOutcomeToShow(
485
+ composeRunOutcome({
486
+ block: block({ status: 'in_progress' }),
487
+ instance: run([testerStep({})]),
488
+ }),
489
+ ),
490
+ ).toBe(true)
491
+ })
492
+ })