@cat-factory/app 0.266.0 → 0.267.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/app/components/board/AddTaskModal.vue +3 -0
- package/app/components/board/nodes/TaskCard.vue +7 -1
- package/app/components/judge/JudgeResultView.vue +11 -8
- package/app/components/panels/StepFragmentAdherence.vue +5 -5
- package/app/components/panels/StepMetadataCard.vue +27 -12
- package/app/components/panels/StepTestReport.vue +7 -4
- package/app/components/pipeline/PipelineBuilder.vue +62 -39
- package/app/components/prReview/PrReviewWindow.vue +23 -14
- package/app/components/requirements/RequirementsReviewWindow.logic.spec.ts +52 -10
- package/app/components/requirements/RequirementsReviewWindow.logic.ts +44 -6
- package/app/components/requirements/RequirementsReviewWindow.vue +114 -17
- package/app/components/settings/RiskPolicyPanel.vue +27 -0
- package/app/components/testing/TestReportWindow.vue +24 -16
- package/app/composables/usePipelineLibraryActions.ts +82 -0
- package/app/stores/pipelines/persistence.ts +32 -2
- package/app/stores/pipelines.ts +25 -1
- package/i18n/locales/de.json +26 -1
- package/i18n/locales/en.json +26 -1
- package/i18n/locales/es.json +26 -1
- package/i18n/locales/fr.json +26 -1
- package/i18n/locales/he.json +26 -1
- package/i18n/locales/it.json +26 -1
- package/i18n/locales/ja.json +26 -1
- package/i18n/locales/pl.json +26 -1
- package/i18n/locales/tr.json +26 -1
- package/i18n/locales/uk.json +26 -1
- package/package.json +2 -2
|
@@ -14,8 +14,11 @@ import {
|
|
|
14
14
|
orderFindings,
|
|
15
15
|
reconcileFindingOrder,
|
|
16
16
|
type FindingAttention,
|
|
17
|
+
type FindingClass,
|
|
17
18
|
type OrderedFinding,
|
|
18
19
|
} from './RequirementsReviewWindow.logic'
|
|
20
|
+
import { recommendationConfidenceBand } from '@cat-factory/contracts'
|
|
21
|
+
import type { RecommendationConfidenceBand } from '@cat-factory/contracts'
|
|
19
22
|
import type {
|
|
20
23
|
RecommendationSource,
|
|
21
24
|
RequirementRecommendation,
|
|
@@ -191,6 +194,49 @@ const STATUS_LABELS = computed<Record<ReviewItemStatus, string>>(() => ({
|
|
|
191
194
|
recommend_requested: t('requirements.itemStatus.recommend_requested'),
|
|
192
195
|
}))
|
|
193
196
|
|
|
197
|
+
// The two GROUPS the reviewer sorts its findings into, which is the window's top-level structure:
|
|
198
|
+
// what only this person can decide, and what practice can answer. Each section says what its group
|
|
199
|
+
// IS rather than only naming it, because the distinction is what tells the reader which half of the
|
|
200
|
+
// list is theirs — and, on an unwatched run, which half the platform may answer without them.
|
|
201
|
+
const CLASS_LABELS = computed<Record<FindingClass, string>>(() => ({
|
|
202
|
+
judgement: t('requirements.findingClass.judgement'),
|
|
203
|
+
practice: t('requirements.findingClass.practice'),
|
|
204
|
+
}))
|
|
205
|
+
const CLASS_HINTS = computed<Record<FindingClass, string>>(() => ({
|
|
206
|
+
judgement: t('requirements.findingClass.judgementHint'),
|
|
207
|
+
practice: t('requirements.findingClass.practiceHint'),
|
|
208
|
+
}))
|
|
209
|
+
const CLASS_LABEL_COLOR = {
|
|
210
|
+
judgement: 'text-amber-300',
|
|
211
|
+
practice: 'text-sky-300',
|
|
212
|
+
} as const satisfies Record<FindingClass, string>
|
|
213
|
+
|
|
214
|
+
// How sure the Writer says it is. Shown on every suggestion, because the confidence is what an
|
|
215
|
+
// unattended run compares against its policy floor: a reader deciding whether to keep a
|
|
216
|
+
// pre-filled answer is looking at the same number the platform used to decide not to ask them.
|
|
217
|
+
// A suggestion the Writer did not grade renders NO badge rather than a "low" one — unreported and
|
|
218
|
+
// unsure are different facts (see `recommendationConfidenceBand`).
|
|
219
|
+
const CONFIDENCE_COLOR = {
|
|
220
|
+
high: 'success',
|
|
221
|
+
medium: 'warning',
|
|
222
|
+
low: 'error',
|
|
223
|
+
} as const satisfies Record<RecommendationConfidenceBand, string>
|
|
224
|
+
const CONFIDENCE_LABELS = computed<Record<RecommendationConfidenceBand, string>>(() => ({
|
|
225
|
+
high: t('requirements.confidence.high'),
|
|
226
|
+
medium: t('requirements.confidence.medium'),
|
|
227
|
+
low: t('requirements.confidence.low'),
|
|
228
|
+
}))
|
|
229
|
+
/** The band a recommendation's grade falls in, or null when it reported none. */
|
|
230
|
+
function confidenceBandOf(
|
|
231
|
+
rec: RequirementRecommendation | undefined,
|
|
232
|
+
): RecommendationConfidenceBand | null {
|
|
233
|
+
return rec ? recommendationConfidenceBand(rec.confidence) : null
|
|
234
|
+
}
|
|
235
|
+
/** The grade as a percentage for the badge's tooltip, or null when ungraded. */
|
|
236
|
+
function confidencePercent(rec: RequirementRecommendation | undefined): string | null {
|
|
237
|
+
return rec?.confidence == null ? null : `${Math.round(rec.confidence * 100)}%`
|
|
238
|
+
}
|
|
239
|
+
|
|
194
240
|
// Answers auto-save: there is no explicit "save" button. The textarea is pre-seeded with
|
|
195
241
|
// the recorded reply (see the watch below); editing and blurring persists it. Persist only
|
|
196
242
|
// when the trimmed draft actually differs from what's already recorded, so blurring an
|
|
@@ -365,24 +411,37 @@ watch(
|
|
|
365
411
|
},
|
|
366
412
|
{ immediate: true },
|
|
367
413
|
)
|
|
368
|
-
const orderedFindings = computed<
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
)
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
414
|
+
const orderedFindings = computed<
|
|
415
|
+
{ item: RequirementReviewItem; attention: FindingAttention; group: FindingClass }[]
|
|
416
|
+
>(() => {
|
|
417
|
+
const byId = new Map((review.value?.items ?? []).map((item) => [item.id, item]))
|
|
418
|
+
return reconcileFindingOrder(desiredOrder.value, pinnedOrder.value).flatMap((entry) => {
|
|
419
|
+
const item = byId.get(entry.id)
|
|
420
|
+
return item ? [{ item, attention: entry.attention, group: entry.group }] : []
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
// The GROUP heading is shown whenever the list spans both groups, which is the point of the split:
|
|
424
|
+
// a reader has to be able to see where "yours to decide" ends. A review entirely in one group needs
|
|
425
|
+
// no heading, because then the whole list is that group.
|
|
426
|
+
function startsFindingGroup(index: number): boolean {
|
|
427
|
+
const entries = orderedFindings.value
|
|
428
|
+
if (new Set(entries.map((entry) => entry.group)).size < 2) return false
|
|
429
|
+
return index === 0 || entries[index - 1]?.group !== entries[index]?.group
|
|
430
|
+
}
|
|
431
|
+
// The attention sub-heading is shown only inside a group that spans more than one bucket, so the
|
|
432
|
+
// two levels of heading cannot both appear on a review where they would say the same thing (a fresh
|
|
433
|
+
// review is all outstanding; a pre-answered practice group is all settled).
|
|
382
434
|
function startsAttentionGroup(index: number): boolean {
|
|
383
|
-
if (!attentionGroupsShown.value) return false
|
|
384
435
|
const entries = orderedFindings.value
|
|
385
|
-
|
|
436
|
+
const here = entries[index]
|
|
437
|
+
if (!here) return false
|
|
438
|
+
if (entries.filter((entry) => entry.group === here.group).length < 2) return false
|
|
439
|
+
const spansBuckets =
|
|
440
|
+
new Set(entries.filter((entry) => entry.group === here.group).map((entry) => entry.attention))
|
|
441
|
+
.size > 1
|
|
442
|
+
if (!spansBuckets) return false
|
|
443
|
+
const previous = entries[index - 1]
|
|
444
|
+
return !previous || previous.group !== here.group || previous.attention !== here.attention
|
|
386
445
|
}
|
|
387
446
|
const ATTENTION_LABELS = computed<Record<FindingAttention, string>>(() => ({
|
|
388
447
|
action: t('requirements.group.action'),
|
|
@@ -740,7 +799,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
740
799
|
@focusin="onFindingFocusIn"
|
|
741
800
|
@focusout="onFindingFocusOut"
|
|
742
801
|
>
|
|
743
|
-
<template v-for="({ item, attention }, index) in orderedFindings" :key="item.id">
|
|
802
|
+
<template v-for="({ item, attention, group }, index) in orderedFindings" :key="item.id">
|
|
803
|
+
<div v-if="startsFindingGroup(index)" class="pt-2" data-testid="requirements-group">
|
|
804
|
+
<div class="flex items-center gap-2">
|
|
805
|
+
<span
|
|
806
|
+
class="text-xs font-semibold uppercase tracking-wide"
|
|
807
|
+
:class="CLASS_LABEL_COLOR[group]"
|
|
808
|
+
:data-finding-group="group"
|
|
809
|
+
>
|
|
810
|
+
{{ CLASS_LABELS[group] }}
|
|
811
|
+
</span>
|
|
812
|
+
<span class="h-px flex-1 bg-slate-700" />
|
|
813
|
+
</div>
|
|
814
|
+
<p class="mt-0.5 text-[11px] text-slate-500">{{ CLASS_HINTS[group] }}</p>
|
|
815
|
+
</div>
|
|
744
816
|
<div v-if="startsAttentionGroup(index)" class="flex items-center gap-2 pt-1">
|
|
745
817
|
<span
|
|
746
818
|
class="text-[11px] font-semibold uppercase tracking-wide"
|
|
@@ -869,6 +941,20 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
869
941
|
>
|
|
870
942
|
{{ GROUNDING_LABELS[autoDefaults.get(item.id)!.groundedIn!] }}
|
|
871
943
|
</UBadge>
|
|
944
|
+
<!-- The Writer's own grade, which is a different question from where the
|
|
945
|
+
answer came from: it is what an unwatched run compares against its
|
|
946
|
+
policy floor, so it is also what tells a reader how hard this
|
|
947
|
+
pre-filled answer was to be sure of. -->
|
|
948
|
+
<UBadge
|
|
949
|
+
v-if="confidenceBandOf(autoDefaults.get(item.id))"
|
|
950
|
+
size="xs"
|
|
951
|
+
variant="outline"
|
|
952
|
+
:color="CONFIDENCE_COLOR[confidenceBandOf(autoDefaults.get(item.id))!]"
|
|
953
|
+
:title="confidencePercent(autoDefaults.get(item.id)) ?? undefined"
|
|
954
|
+
data-testid="requirements-confidence"
|
|
955
|
+
>
|
|
956
|
+
{{ CONFIDENCE_LABELS[confidenceBandOf(autoDefaults.get(item.id))!] }}
|
|
957
|
+
</UBadge>
|
|
872
958
|
</div>
|
|
873
959
|
<UTextarea
|
|
874
960
|
v-model="drafts[item.id]"
|
|
@@ -927,6 +1013,17 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
927
1013
|
>
|
|
928
1014
|
{{ GROUNDING_LABELS[rec.groundedIn] }}
|
|
929
1015
|
</UBadge>
|
|
1016
|
+
<UBadge
|
|
1017
|
+
v-if="confidenceBandOf(rec)"
|
|
1018
|
+
size="xs"
|
|
1019
|
+
variant="outline"
|
|
1020
|
+
class="ms-1.5"
|
|
1021
|
+
:color="CONFIDENCE_COLOR[confidenceBandOf(rec)!]"
|
|
1022
|
+
:title="confidencePercent(rec) ?? undefined"
|
|
1023
|
+
data-testid="requirements-confidence"
|
|
1024
|
+
>
|
|
1025
|
+
{{ CONFIDENCE_LABELS[confidenceBandOf(rec)!] }}
|
|
1026
|
+
</UBadge>
|
|
930
1027
|
<!-- The Writer's suggested answer — agent prose, so it takes the
|
|
931
1028
|
measure like the finding's own question above it. -->
|
|
932
1029
|
<p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-300">
|
|
@@ -88,6 +88,10 @@ interface Draft {
|
|
|
88
88
|
// up, rather than stopping for a person. Edited as a switch because the vocabulary is two-valued
|
|
89
89
|
// and the OFF state is the historical behaviour.
|
|
90
90
|
unattended: boolean
|
|
91
|
+
// The confidence floor an unattended run's auto-answered requirements finding must clear, as a
|
|
92
|
+
// PERCENT (the numbers above are edited the same way). Only read while `unattended` is on, which
|
|
93
|
+
// is why the field is rendered inside that block rather than beside the other budgets.
|
|
94
|
+
minAutoAnswerConfidence: number
|
|
91
95
|
// Per-change-class auto-merge rules. An OMITTED class means "use the score ceilings above",
|
|
92
96
|
// so `{}` is the identity — the editor stores `thresholds` as an omission for that reason.
|
|
93
97
|
classRules: MergeClassRules
|
|
@@ -136,6 +140,7 @@ function toDraft(p: RiskPolicy): Draft {
|
|
|
136
140
|
maxRequirementConcernAllowed: p.maxRequirementConcernAllowed,
|
|
137
141
|
autoMergeEnabled: p.autoMergeEnabled,
|
|
138
142
|
unattended: p.autonomy === 'unattended',
|
|
143
|
+
minAutoAnswerConfidence: Math.round(p.minAutoAnswerConfidence * 100),
|
|
139
144
|
classRules: { ...p.classRules },
|
|
140
145
|
classRulesByRole: { ...p.classRulesByRole },
|
|
141
146
|
dryRunRoles: [...p.dryRunRoles],
|
|
@@ -196,6 +201,7 @@ async function save(p: RiskPolicy) {
|
|
|
196
201
|
maxRequirementConcernAllowed: d.maxRequirementConcernAllowed,
|
|
197
202
|
autoMergeEnabled: d.autoMergeEnabled,
|
|
198
203
|
autonomy: d.unattended ? 'unattended' : 'attended',
|
|
204
|
+
minAutoAnswerConfidence: d.minAutoAnswerConfidence / 100,
|
|
199
205
|
classRules: d.classRules,
|
|
200
206
|
classRulesByRole: d.classRulesByRole,
|
|
201
207
|
dryRunRoles: d.dryRunRoles,
|
|
@@ -277,6 +283,7 @@ const draft = reactive<Draft>({
|
|
|
277
283
|
// A new policy parks on its own caps, matching every built-in but the unattended default: a
|
|
278
284
|
// licence to answer them is a posture somebody grants, never one a blank form assumes.
|
|
279
285
|
unattended: false,
|
|
286
|
+
minAutoAnswerConfidence: 80,
|
|
280
287
|
// The create row authors the numbers only. Class and role rules start at their identity and
|
|
281
288
|
// are edited on the saved preset, where each rule can be shown beside the base rule (and the
|
|
282
289
|
// track record) it narrows — neither reads as anything on a policy that does not exist yet.
|
|
@@ -305,6 +312,7 @@ async function create() {
|
|
|
305
312
|
maxRequirementConcernAllowed: draft.maxRequirementConcernAllowed,
|
|
306
313
|
autoMergeEnabled: draft.autoMergeEnabled,
|
|
307
314
|
autonomy: draft.unattended ? 'unattended' : 'attended',
|
|
315
|
+
minAutoAnswerConfidence: draft.minAutoAnswerConfidence / 100,
|
|
308
316
|
classRules: draft.classRules,
|
|
309
317
|
forkDecision: forkGating(draft),
|
|
310
318
|
})
|
|
@@ -520,6 +528,25 @@ async function create() {
|
|
|
520
528
|
: t('settings.riskPolicy.autonomy.attendedHint')
|
|
521
529
|
"
|
|
522
530
|
/>
|
|
531
|
+
<!-- Shown only while the posture is on, because that is the only state that reads it: a
|
|
532
|
+
floor on an attended policy would be a control over a decision this policy never makes.
|
|
533
|
+
It is not hidden as an "advanced override" — it is inert, which is a different thing. -->
|
|
534
|
+
<label v-if="drafts[p.id]!.unattended" class="mt-3 block">
|
|
535
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
536
|
+
{{ t('settings.riskPolicy.autoAnswer.label') }}
|
|
537
|
+
</span>
|
|
538
|
+
<UInput
|
|
539
|
+
v-model.number="drafts[p.id]!.minAutoAnswerConfidence"
|
|
540
|
+
type="number"
|
|
541
|
+
min="0"
|
|
542
|
+
max="100"
|
|
543
|
+
size="sm"
|
|
544
|
+
data-testid="risk-policy-auto-answer-floor"
|
|
545
|
+
/>
|
|
546
|
+
<span class="mt-1 block text-[11px] text-slate-500">
|
|
547
|
+
{{ t('settings.riskPolicy.autoAnswer.hint') }}
|
|
548
|
+
</span>
|
|
549
|
+
</label>
|
|
523
550
|
</div>
|
|
524
551
|
|
|
525
552
|
<div class="mt-3 flex items-center justify-between gap-3">
|
|
@@ -20,6 +20,7 @@ import StepContainerStatus from '~/components/panels/StepContainerStatus.vue'
|
|
|
20
20
|
import AttemptEntryHeader from '~/components/panels/AttemptEntryHeader.vue'
|
|
21
21
|
import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
|
|
22
22
|
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
23
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
23
24
|
|
|
24
25
|
const board = useBoardStore()
|
|
25
26
|
const execution = useExecutionStore()
|
|
@@ -489,9 +490,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
489
490
|
:icon="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
|
|
490
491
|
:icon-class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
|
|
491
492
|
/>
|
|
492
|
-
<
|
|
493
|
-
|
|
494
|
-
|
|
493
|
+
<MarkdownProse
|
|
494
|
+
v-if="a.summary"
|
|
495
|
+
:text="a.summary"
|
|
496
|
+
class="mt-1 max-w-3xl text-[12px] leading-snug text-slate-400"
|
|
497
|
+
/>
|
|
495
498
|
<div v-if="a.concerns && a.concerns.length" class="mt-1.5">
|
|
496
499
|
<p class="text-[11px] text-slate-500">
|
|
497
500
|
{{ t('testing.fixerTimeline.addressed') }}
|
|
@@ -575,9 +578,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
575
578
|
d(new Date(vd.at), 'short')
|
|
576
579
|
}}</span>
|
|
577
580
|
</div>
|
|
578
|
-
<
|
|
579
|
-
|
|
580
|
-
|
|
581
|
+
<MarkdownProse
|
|
582
|
+
v-if="vd.feedback"
|
|
583
|
+
:text="vd.feedback"
|
|
584
|
+
class="mt-1 text-[12px] leading-snug text-slate-400"
|
|
585
|
+
/>
|
|
581
586
|
<div v-if="vd.gaps.length" class="mt-1.5">
|
|
582
587
|
<p class="text-[11px] text-slate-500">{{ t('testing.quality.gaps') }}</p>
|
|
583
588
|
<ul class="mt-1 space-y-0.5">
|
|
@@ -610,12 +615,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
610
615
|
<!-- Summary — the tester's own prose, so it takes the reading measure the shell's `full`
|
|
611
616
|
width obliges (see the `width` prop). The scenario rows and log tails below keep the
|
|
612
617
|
full span. -->
|
|
613
|
-
<
|
|
618
|
+
<MarkdownProse
|
|
614
619
|
v-if="report.summary"
|
|
620
|
+
:text="report.summary"
|
|
615
621
|
class="mb-4 max-w-3xl text-[13px] leading-relaxed text-slate-300"
|
|
616
|
-
|
|
617
|
-
{{ report.summary }}
|
|
618
|
-
</p>
|
|
622
|
+
/>
|
|
619
623
|
|
|
620
624
|
<h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
621
625
|
{{ t('testing.scenariosOutcomes') }}
|
|
@@ -680,9 +684,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
680
684
|
/>
|
|
681
685
|
<div class="min-w-0">
|
|
682
686
|
<span class="text-[13px] text-slate-200">{{ o.name }}</span>
|
|
683
|
-
<
|
|
684
|
-
|
|
685
|
-
|
|
687
|
+
<MarkdownProse
|
|
688
|
+
v-if="o.detail"
|
|
689
|
+
:text="o.detail"
|
|
690
|
+
class="max-w-3xl text-[12px] leading-snug text-slate-400"
|
|
691
|
+
/>
|
|
686
692
|
</div>
|
|
687
693
|
</div>
|
|
688
694
|
<p v-if="!g.outcomes.length" class="py-0.5 text-[12px] italic text-slate-500">
|
|
@@ -710,9 +716,11 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
710
716
|
{{ SEVERITY_LABELS[c.severity] }}
|
|
711
717
|
</span>
|
|
712
718
|
</div>
|
|
713
|
-
<
|
|
714
|
-
|
|
715
|
-
|
|
719
|
+
<MarkdownProse
|
|
720
|
+
v-if="c.detail"
|
|
721
|
+
:text="c.detail"
|
|
722
|
+
class="max-w-3xl text-[12px] leading-snug text-slate-400"
|
|
723
|
+
/>
|
|
716
724
|
</div>
|
|
717
725
|
</div>
|
|
718
726
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { RunDefaultScope } from '@cat-factory/contracts'
|
|
2
|
+
import type { Pipeline } from '~/types/domain'
|
|
3
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
4
|
+
import { usePipelineErrorToast } from '~/composables/usePipelineErrorToast'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The actions a row of the saved-pipeline LIBRARY offers: archive, promote to a scope default,
|
|
8
|
+
* edit, clone, delete.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from `PipelineBuilder.vue` so that component stays inside its (shrink-only) size
|
|
11
|
+
* budget. A cohesive seam rather than an arbitrary cut: every one of these takes a library row and
|
|
12
|
+
* nothing else, none of them touches the DRAFT chain the rest of the builder is about, and each
|
|
13
|
+
* reports its own failure — which is what makes them the same kind of thing.
|
|
14
|
+
*/
|
|
15
|
+
export function usePipelineLibraryActions() {
|
|
16
|
+
const pipelines = usePipelinesStore()
|
|
17
|
+
const toast = useToast()
|
|
18
|
+
const { t } = useI18n()
|
|
19
|
+
const { present } = usePipelineErrorToast()
|
|
20
|
+
const { confirm } = useConfirm()
|
|
21
|
+
|
|
22
|
+
/** Archive / unarchive: organize the library without deleting. Works on built-ins too. */
|
|
23
|
+
async function toggleArchive(p: Pipeline) {
|
|
24
|
+
try {
|
|
25
|
+
if (p.archived) await pipelines.unarchive(p.id)
|
|
26
|
+
else await pipelines.archive(p.id)
|
|
27
|
+
} catch {
|
|
28
|
+
toast.add({ title: t('pipeline.builder.toast.updateFailed'), color: 'error' })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Claim (or release) a pipeline as the workspace's default for one resolution scope.
|
|
34
|
+
*
|
|
35
|
+
* Both scopes are ADVANCED-tier controls in the builder, and the reason is the interface-mode rule
|
|
36
|
+
* rather than the feeling of the setting: a workspace that names neither runs exactly what it runs
|
|
37
|
+
* today (the interface-mode rung in the app, the seeded unattended rung headlessly), so hiding the
|
|
38
|
+
* control leaves the same default a basic-tier user would have had. What is NOT hidden is the
|
|
39
|
+
* resulting badge — a default somebody set has to be visible in the library at both tiers, or the
|
|
40
|
+
* hidden control becomes a hidden decision.
|
|
41
|
+
*/
|
|
42
|
+
async function toggleDefault(p: Pipeline, scope: RunDefaultScope) {
|
|
43
|
+
const held = scope === 'unattended' ? p.isUnattendedDefault : p.isDefault
|
|
44
|
+
try {
|
|
45
|
+
await pipelines.setDefault(p.id, scope, !held)
|
|
46
|
+
} catch (error) {
|
|
47
|
+
present(error, 'pipeline.builder.toast.updateFailed')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Load a custom pipeline into the draft for in-place editing. */
|
|
52
|
+
function edit(p: Pipeline) {
|
|
53
|
+
pipelines.loadForEdit(p)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function removePipeline(p: Pipeline) {
|
|
57
|
+
const ok = await confirm({
|
|
58
|
+
title: t('pipeline.builder.confirmDeletePipeline.title'),
|
|
59
|
+
description: t('pipeline.builder.confirmDeletePipeline.body', { name: p.name }),
|
|
60
|
+
variant: 'destructive',
|
|
61
|
+
confirmLabel: t('common.delete'),
|
|
62
|
+
icon: 'i-lucide-trash-2',
|
|
63
|
+
})
|
|
64
|
+
if (ok) void pipelines.removePipeline(p.id)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Clone any pipeline (incl. a read-only built-in) into an editable copy. */
|
|
68
|
+
async function clone(p: Pipeline) {
|
|
69
|
+
try {
|
|
70
|
+
const copy = await pipelines.clonePipeline(p.id)
|
|
71
|
+
toast.add({
|
|
72
|
+
title: t('pipeline.builder.toast.cloned', { name: p.name, copy: copy.name }),
|
|
73
|
+
color: 'success',
|
|
74
|
+
icon: 'i-lucide-copy',
|
|
75
|
+
})
|
|
76
|
+
} catch {
|
|
77
|
+
toast.add({ title: t('pipeline.builder.toast.cloneFailed'), color: 'error' })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { toggleArchive, toggleDefault, edit, removePipeline, clone }
|
|
82
|
+
}
|
|
@@ -112,16 +112,45 @@ export function createPipelinePersistence(
|
|
|
112
112
|
return updated
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Set a pipeline's organizational metadata (labels / archive / the two default claims). Works on
|
|
117
|
+
* built-ins too, which is the whole reason the default claims live on this call: the rungs a
|
|
118
|
+
* workspace most wants as its defaults are built-in, and a built-in refuses a structural edit.
|
|
119
|
+
*
|
|
120
|
+
* Promoting one row DEMOTES another, and the response names only the winner. So the incumbent is
|
|
121
|
+
* released LOCALLY before the winner is upserted: a targeted edit of the two rows that changed,
|
|
122
|
+
* rather than a full re-read (which this store has no door for — it hydrates from the workspace
|
|
123
|
+
* snapshot) and rather than upserting the winner alone, which would leave two rows claiming the
|
|
124
|
+
* same default on screen until the next snapshot.
|
|
125
|
+
*/
|
|
126
|
+
async function organize(
|
|
127
|
+
id: string,
|
|
128
|
+
body: {
|
|
129
|
+
labels?: string[]
|
|
130
|
+
archived?: boolean
|
|
131
|
+
isDefault?: boolean
|
|
132
|
+
isUnattendedDefault?: boolean
|
|
133
|
+
},
|
|
134
|
+
) {
|
|
117
135
|
const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
|
|
136
|
+
if (body.isDefault !== undefined) releaseOtherClaims(id, 'isDefault')
|
|
137
|
+
if (body.isUnattendedDefault !== undefined) releaseOtherClaims(id, 'isUnattendedDefault')
|
|
118
138
|
upsertPipeline(updated)
|
|
119
139
|
return updated
|
|
120
140
|
}
|
|
121
141
|
|
|
142
|
+
/** Drop `field` from every row but `id`, mirroring what the store just did server-side. */
|
|
143
|
+
function releaseOtherClaims(id: string, field: 'isDefault' | 'isUnattendedDefault') {
|
|
144
|
+
ctx.pipelines.value = ctx.pipelines.value.map((pipeline) =>
|
|
145
|
+
pipeline.id === id || pipeline[field] !== true ? pipeline : { ...pipeline, [field]: false },
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
122
149
|
const archive = (id: string) => organize(id, { archived: true })
|
|
123
150
|
const unarchive = (id: string) => organize(id, { archived: false })
|
|
124
151
|
const setLabels = (id: string, labels: string[]) => organize(id, { labels })
|
|
152
|
+
const setDefault = (id: string, scope: 'interactive' | 'unattended', claimed: boolean) =>
|
|
153
|
+
organize(id, scope === 'unattended' ? { isUnattendedDefault: claimed } : { isDefault: claimed })
|
|
125
154
|
|
|
126
155
|
return {
|
|
127
156
|
saveDraft,
|
|
@@ -132,5 +161,6 @@ export function createPipelinePersistence(
|
|
|
132
161
|
archive,
|
|
133
162
|
unarchive,
|
|
134
163
|
setLabels,
|
|
164
|
+
setDefault,
|
|
135
165
|
}
|
|
136
166
|
}
|
package/app/stores/pipelines.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import type { Pipeline } from '~/types/domain'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
GateConfigForm,
|
|
6
|
+
PipelinePurpose,
|
|
7
|
+
RetiredPipelineWire,
|
|
8
|
+
RunDefaultScope,
|
|
9
|
+
} from '@cat-factory/contracts'
|
|
10
|
+
import { declaredDefaultPipelineId } from '@cat-factory/contracts'
|
|
5
11
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
6
12
|
import { createDraftStepState, type PipelinesContext } from '~/stores/pipelines/context'
|
|
7
13
|
import { createPipelineDraftActions } from '~/stores/pipelines/draftActions'
|
|
@@ -124,6 +130,23 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
124
130
|
return pipelines.value.find((p) => p.id === id)
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The pipeline id this workspace has DECLARED as its default for a resolution scope, or undefined
|
|
135
|
+
* when no row claims it.
|
|
136
|
+
*
|
|
137
|
+
* The rule itself is `declaredDefaultPipelineId` in `@cat-factory/contracts`, shared with the
|
|
138
|
+
* engine: the SPA pre-selects on its start controls what the backend falls back to when a headless
|
|
139
|
+
* caller names none, and two readings of "the default" is how a Start button comes to run
|
|
140
|
+
* something other than what the board said it would.
|
|
141
|
+
*
|
|
142
|
+
* Undefined is a real answer, not a lookup failure, and each caller composes its own fallback with
|
|
143
|
+
* it: the start controls `defaultBuildPipelineId` (the interface-mode rung), the backend catalog
|
|
144
|
+
* order.
|
|
145
|
+
*/
|
|
146
|
+
function declaredDefaultId(scope: RunDefaultScope): string | undefined {
|
|
147
|
+
return declaredDefaultPipelineId(pipelines.value, scope)
|
|
148
|
+
}
|
|
149
|
+
|
|
127
150
|
// The draft manipulation + persistence operations, split into cohesive factories sharing the
|
|
128
151
|
// state above (a size-only extraction — behaviour is identical to the former in-closure
|
|
129
152
|
// functions). Persistence drives the draft-lifecycle helpers (`clearDraft`/`loadForEdit`).
|
|
@@ -173,6 +196,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
|
|
|
173
196
|
hydrate,
|
|
174
197
|
hydrateGateConfigForms,
|
|
175
198
|
getPipeline,
|
|
199
|
+
declaredDefaultId,
|
|
176
200
|
...draftActions,
|
|
177
201
|
...persistence,
|
|
178
202
|
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -611,6 +611,10 @@
|
|
|
611
611
|
"label": "Unbeaufsichtigte Läufe ohne Wartezeit auf eine Person abschließen",
|
|
612
612
|
"unattendedHint": "Wenn eine automatische Schleife aufgibt (ein Companion an seinem Überarbeitungslimit, eine Prüfung an ihrem Durchlauflimit, unbearbeitete Folgepunkte), läuft der Durchlauf nachvollziehbar weiter statt anzuhalten. Von der Pipeline angeforderte Gates wie manuelles Testen, Review und Freigabe halten den Durchlauf weiterhin an.",
|
|
613
613
|
"attendedHint": "Wenn eine automatische Schleife aufgibt, hält der Durchlauf an und wartet auf eine Entscheidung. Richtig für ein Board, das jemand beobachtet; ein über die API gestarteter Durchlauf wartet unbegrenzt."
|
|
614
|
+
},
|
|
615
|
+
"autoAnswer": {
|
|
616
|
+
"label": "Mindestzuversicht für Auto-Antworten (%)",
|
|
617
|
+
"hint": "Wie sicher der Requirement Writer sein muss, damit ein unbeobachteter Lauf seinen Vorschlag übernimmt statt auf eine Person zu warten. Infrage kommen nur Befunde, die der Reviewer ohne Product Owner für beantwortbar hielt."
|
|
614
618
|
}
|
|
615
619
|
},
|
|
616
620
|
"observabilityConnection": {
|
|
@@ -4509,7 +4513,17 @@
|
|
|
4509
4513
|
"binaryComparison": "Kandidaten vor der Lieferung vergleichen",
|
|
4510
4514
|
"binaryPerGenerator": "Kandidaten pro Integration",
|
|
4511
4515
|
"binaryMultiSelect": "Mehrere behalten erlauben",
|
|
4512
|
-
"binaryComparisonUnreachable": "Dieser Schritt kann nur einen Kandidaten pro Motiv erzeugen, es gäbe also nichts zu vergleichen und der einzige würde ungefragt behalten. Wähle eine zweite Integration oder erhöhe die Anzahl der Kandidaten pro Integration."
|
|
4516
|
+
"binaryComparisonUnreachable": "Dieser Schritt kann nur einen Kandidaten pro Motiv erzeugen, es gäbe also nichts zu vergleichen und der einzige würde ungefragt behalten. Wähle eine zweite Integration oder erhöhe die Anzahl der Kandidaten pro Integration.",
|
|
4517
|
+
"scopeDefault": {
|
|
4518
|
+
"interactive": "Standard in der App",
|
|
4519
|
+
"interactiveHint": "Was eine vom Board gestartete Aufgabe ausführt, wenn sie keine eigene Pipeline festlegt.",
|
|
4520
|
+
"unattended": "Standard ohne Aufsicht",
|
|
4521
|
+
"unattendedHint": "Was ein Lauf ohne Beobachter ausführt (API, Ticket, Zeitplan), wenn die Aufgabe keine Pipeline festlegt.",
|
|
4522
|
+
"claimInteractive": "Als Standard in der App festlegen",
|
|
4523
|
+
"releaseInteractive": "Nicht mehr Standard in der App",
|
|
4524
|
+
"claimUnattended": "Als Standard ohne Aufsicht festlegen",
|
|
4525
|
+
"releaseUnattended": "Nicht mehr Standard ohne Aufsicht"
|
|
4526
|
+
}
|
|
4513
4527
|
},
|
|
4514
4528
|
"progress": {
|
|
4515
4529
|
"status": {
|
|
@@ -4794,6 +4808,17 @@
|
|
|
4794
4808
|
"reReview": "Die Anforderungen konnten nicht erneut geprüft werden",
|
|
4795
4809
|
"proceed": "Es konnte nicht fortgefahren werden",
|
|
4796
4810
|
"resolveReview": "Die Prüfung konnte nicht abgeschlossen werden"
|
|
4811
|
+
},
|
|
4812
|
+
"findingClass": {
|
|
4813
|
+
"judgement": "Ihre Entscheidung nötig",
|
|
4814
|
+
"judgementHint": "Eine Geschäfts-, Produkt- oder Domänenentscheidung oder etwas, das dem Reviewer nicht mitgeteilt wurde. Nur Sie können das klären.",
|
|
4815
|
+
"practice": "Aus der Praxis beantwortbar",
|
|
4816
|
+
"practiceHint": "Durch etablierte Praxis, den bereits genutzten Stack oder den vorliegenden Kontext geklärt. Vorbefüllt zum Übernehmen oder Ändern."
|
|
4817
|
+
},
|
|
4818
|
+
"confidence": {
|
|
4819
|
+
"high": "Hohe Zuversicht",
|
|
4820
|
+
"medium": "Mittlere Zuversicht",
|
|
4821
|
+
"low": "Geringe Zuversicht"
|
|
4797
4822
|
}
|
|
4798
4823
|
},
|
|
4799
4824
|
"bootstrap": {
|
package/i18n/locales/en.json
CHANGED
|
@@ -3347,6 +3347,10 @@
|
|
|
3347
3347
|
"label": "Finish unattended runs without waiting for a person",
|
|
3348
3348
|
"unattendedHint": "When an automatic loop gives up (a companion at its rework cap, a review at its pass cap, untriaged follow-ups), the run proceeds on the record instead of parking. Gates the pipeline asks for, such as human testing, review and approval, still stop the run.",
|
|
3349
3349
|
"attendedHint": "When an automatic loop gives up, the run parks and waits for someone to choose. Right for a board somebody is watching; a run started over the API waits indefinitely."
|
|
3350
|
+
},
|
|
3351
|
+
"autoAnswer": {
|
|
3352
|
+
"label": "Auto-answer confidence floor (%)",
|
|
3353
|
+
"hint": "How sure the Requirement Writer must be for an unwatched run to keep its suggested answer instead of stopping for a person. Only findings the reviewer judged answerable without a product owner are eligible."
|
|
3350
3354
|
}
|
|
3351
3355
|
},
|
|
3352
3356
|
"observabilityConnection": {
|
|
@@ -5117,7 +5121,17 @@
|
|
|
5117
5121
|
"binaryComparison": "Compare candidates before delivering",
|
|
5118
5122
|
"binaryPerGenerator": "Candidates per integration",
|
|
5119
5123
|
"binaryMultiSelect": "Allow keeping more than one",
|
|
5120
|
-
"binaryComparisonUnreachable": "This step can only produce one candidate per subject, so nothing would be compared and the only one would be kept without asking. Select a second integration, or raise the candidates-per-integration count."
|
|
5124
|
+
"binaryComparisonUnreachable": "This step can only produce one candidate per subject, so nothing would be compared and the only one would be kept without asking. Select a second integration, or raise the candidates-per-integration count.",
|
|
5125
|
+
"scopeDefault": {
|
|
5126
|
+
"interactive": "In-app default",
|
|
5127
|
+
"interactiveHint": "What a task started from the board runs when it pins no pipeline of its own.",
|
|
5128
|
+
"unattended": "Unattended default",
|
|
5129
|
+
"unattendedHint": "What a run nobody is watching runs (the API, a ticket, a schedule) when the task pins no pipeline.",
|
|
5130
|
+
"claimInteractive": "Make the in-app default",
|
|
5131
|
+
"releaseInteractive": "Stop being the in-app default",
|
|
5132
|
+
"claimUnattended": "Make the unattended default",
|
|
5133
|
+
"releaseUnattended": "Stop being the unattended default"
|
|
5134
|
+
}
|
|
5121
5135
|
},
|
|
5122
5136
|
"progress": {
|
|
5123
5137
|
"status": {
|
|
@@ -5654,6 +5668,17 @@
|
|
|
5654
5668
|
"reReview": "Could not re-review the requirements",
|
|
5655
5669
|
"proceed": "Could not proceed",
|
|
5656
5670
|
"resolveReview": "Could not resolve the review"
|
|
5671
|
+
},
|
|
5672
|
+
"findingClass": {
|
|
5673
|
+
"judgement": "Needs your decision",
|
|
5674
|
+
"judgementHint": "A business, product or domain call, or something the reviewer was not told. Only you can settle these.",
|
|
5675
|
+
"practice": "Answerable from practice",
|
|
5676
|
+
"practiceHint": "Settled by established practice, the stack already in use, or the context provided. Pre-filled for you to keep or change."
|
|
5677
|
+
},
|
|
5678
|
+
"confidence": {
|
|
5679
|
+
"high": "High confidence",
|
|
5680
|
+
"medium": "Medium confidence",
|
|
5681
|
+
"low": "Low confidence"
|
|
5657
5682
|
}
|
|
5658
5683
|
},
|
|
5659
5684
|
"clarity": {
|