@cat-factory/app 0.150.0 → 0.151.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 +43 -2
- package/app/components/board/ReviewDebtBadge.vue +26 -0
- package/app/components/board/ReviewFrictionDialog.vue +118 -0
- package/app/components/panels/inspector/ContainerSummary.vue +7 -3
- package/app/components/settings/KubernetesEnvironmentForm.vue +45 -29
- package/app/components/settings/WorkspaceSettingsPanel.vue +118 -1
- package/app/composables/api/board.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +218 -175
- package/app/composables/useReviewDebt.ts +23 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/board/mutations.ts +4 -0
- package/app/stores/ui/modals.ts +40 -0
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +33 -0
- package/i18n/locales/en.json +33 -0
- package/i18n/locales/es.json +33 -0
- package/i18n/locales/fr.json +33 -0
- package/i18n/locales/he.json +33 -0
- package/i18n/locales/it.json +33 -0
- package/i18n/locales/ja.json +33 -0
- package/i18n/locales/pl.json +33 -0
- package/i18n/locales/tr.json +33 -0
- package/i18n/locales/uk.json +33 -0
- package/package.json +2 -2
|
@@ -28,6 +28,7 @@ import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.
|
|
|
28
28
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
29
29
|
import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
|
|
30
30
|
import { riskPolicyOptionLabel, riskPolicySummary } from '~/utils/riskPolicy'
|
|
31
|
+
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
31
32
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
32
33
|
|
|
33
34
|
const ui = useUiStore()
|
|
@@ -678,8 +679,7 @@ const canAdd = computed(() => {
|
|
|
678
679
|
})
|
|
679
680
|
|
|
680
681
|
async function add() {
|
|
681
|
-
|
|
682
|
-
if (!containerId || !canAdd.value) return
|
|
682
|
+
if (!canAdd.value) return
|
|
683
683
|
// Recurring tasks are created via a schedule on the service frame — hand off to the
|
|
684
684
|
// existing recurring-pipeline modal (which carries the cadence + prompt).
|
|
685
685
|
if (isRecurring.value) {
|
|
@@ -689,6 +689,17 @@ async function add() {
|
|
|
689
689
|
ui.openAddRecurring(frameId)
|
|
690
690
|
return
|
|
691
691
|
}
|
|
692
|
+
await submitCreate(false)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Create the task, optionally acknowledging review-debt friction. A `review_debt_*` 409 opens the
|
|
697
|
+
* friction dialog instead of a bare error toast: the soft `warn` tier's dialog can retry via
|
|
698
|
+
* `submitCreate(true)` (the `onConfirm`), while a hard `blocked` tier only offers "Go review".
|
|
699
|
+
*/
|
|
700
|
+
async function submitCreate(acknowledgeReviewDebt: boolean) {
|
|
701
|
+
const containerId = ui.addTaskContainerId
|
|
702
|
+
if (!containerId) return
|
|
692
703
|
saving.value = true
|
|
693
704
|
try {
|
|
694
705
|
const typeFields = buildTypeFields()
|
|
@@ -718,6 +729,7 @@ async function add() {
|
|
|
718
729
|
// re-seeded from the service. The task owns its fragments from here.
|
|
719
730
|
fragmentIds: [...fragmentIds.value],
|
|
720
731
|
...(technical.value ? { technical: true } : {}),
|
|
732
|
+
...(acknowledgeReviewDebt ? { acknowledgeReviewDebt: true } : {}),
|
|
721
733
|
})
|
|
722
734
|
if (block) {
|
|
723
735
|
// Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
|
|
@@ -725,8 +737,14 @@ async function add() {
|
|
|
725
737
|
// one-click "Copy details" for a bug report.
|
|
726
738
|
presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
|
|
727
739
|
}
|
|
740
|
+
ui.closeReviewFriction()
|
|
728
741
|
ui.closeAddTask()
|
|
729
742
|
} catch (e) {
|
|
743
|
+
const conflict = parseConflict(e)
|
|
744
|
+
if (conflict?.reason === 'review_debt_warn' || conflict?.reason === 'review_debt_blocked') {
|
|
745
|
+
openReviewFrictionDialog(conflict)
|
|
746
|
+
return
|
|
747
|
+
}
|
|
730
748
|
toast.add({
|
|
731
749
|
title: t('board.addTask.addFailedTitle'),
|
|
732
750
|
description: e instanceof Error ? e.message : String(e),
|
|
@@ -737,6 +755,29 @@ async function add() {
|
|
|
737
755
|
saving.value = false
|
|
738
756
|
}
|
|
739
757
|
}
|
|
758
|
+
|
|
759
|
+
/** Turn a parsed review-debt friction 409 into the dialog context (see ReviewFrictionDialog.vue). */
|
|
760
|
+
function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseConflict>>) {
|
|
761
|
+
const details = conflict.details
|
|
762
|
+
const rawDebt = Array.isArray(details.debt) ? details.debt : []
|
|
763
|
+
const debt = rawDebt.map((d) => {
|
|
764
|
+
const row = (d ?? {}) as { blockId?: unknown; title?: unknown; waitingMinutes?: unknown }
|
|
765
|
+
return {
|
|
766
|
+
blockId: typeof row.blockId === 'string' ? row.blockId : '',
|
|
767
|
+
title: typeof row.title === 'string' ? row.title : null,
|
|
768
|
+
waitingMinutes: typeof row.waitingMinutes === 'number' ? row.waitingMinutes : 0,
|
|
769
|
+
}
|
|
770
|
+
})
|
|
771
|
+
const isWarn = conflict.reason === 'review_debt_warn'
|
|
772
|
+
ui.openReviewFriction({
|
|
773
|
+
kind: isWarn ? 'warn' : 'blocked',
|
|
774
|
+
reason:
|
|
775
|
+
details.friction === 'count' || details.friction === 'stuck' ? details.friction : undefined,
|
|
776
|
+
threshold: typeof details.threshold === 'number' ? details.threshold : null,
|
|
777
|
+
debt,
|
|
778
|
+
onConfirm: isWarn ? () => void submitCreate(true) : null,
|
|
779
|
+
})
|
|
780
|
+
}
|
|
740
781
|
</script>
|
|
741
782
|
|
|
742
783
|
<template>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// A small pre-warn badge shown next to an "Add task" affordance when the workspace's opt-in
|
|
3
|
+
// review-debt friction is at (or past) its threshold, so the friction dialog isn't a surprise.
|
|
4
|
+
// Hidden entirely when friction is off or the queue is under the warn threshold. The server
|
|
5
|
+
// remains the authority — this is a hint. See backend/docs/review-debt-friction.md.
|
|
6
|
+
import { computed } from 'vue'
|
|
7
|
+
import { useReviewDebt } from '~/composables/useReviewDebt'
|
|
8
|
+
|
|
9
|
+
const { active, debtCount, blocked } = useReviewDebt()
|
|
10
|
+
const { t } = useI18n()
|
|
11
|
+
|
|
12
|
+
const color = computed(() => (blocked.value ? 'error' : 'warning'))
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<UBadge
|
|
17
|
+
v-if="active"
|
|
18
|
+
:color="color"
|
|
19
|
+
variant="subtle"
|
|
20
|
+
size="sm"
|
|
21
|
+
icon="i-lucide-clock"
|
|
22
|
+
:title="t('errors.reviewFriction.badge', { count: debtCount })"
|
|
23
|
+
>
|
|
24
|
+
{{ debtCount }}
|
|
25
|
+
</UBadge>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Opt-in review-debt friction dialog. Shown when a task-create request is refused by the friction
|
|
3
|
+
// gate (a `review_debt_warn` / `review_debt_blocked` 409). It lists exactly which tasks are waiting
|
|
4
|
+
// on human review (worst first), each deep-linking to its block so "go review instead" is one
|
|
5
|
+
// click. The soft `warn` tier offers a secondary "Create anyway" (retries with the acknowledge
|
|
6
|
+
// flag); the hard `blocked` tier does not. Driven by `ui.reviewFrictionContext`, mounted in
|
|
7
|
+
// pages/index.vue. See backend/docs/review-debt-friction.md.
|
|
8
|
+
import { computed } from 'vue'
|
|
9
|
+
|
|
10
|
+
const { t } = useI18n()
|
|
11
|
+
const ui = useUiStore()
|
|
12
|
+
|
|
13
|
+
const ctx = computed(() => ui.reviewFrictionContext)
|
|
14
|
+
|
|
15
|
+
const open = computed({
|
|
16
|
+
get: () => ctx.value !== null,
|
|
17
|
+
set: (v: boolean) => {
|
|
18
|
+
if (!v) ui.closeReviewFriction()
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const title = computed(() =>
|
|
23
|
+
ctx.value?.kind === 'blocked'
|
|
24
|
+
? t('errors.reviewFriction.blockedTitle')
|
|
25
|
+
: t('errors.reviewFriction.warnTitle'),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const body = computed(() => {
|
|
29
|
+
const c = ctx.value
|
|
30
|
+
if (!c) return ''
|
|
31
|
+
if (c.kind === 'warn') return t('errors.reviewFriction.warnBody', { count: c.debt.length })
|
|
32
|
+
if (c.reason === 'stuck')
|
|
33
|
+
return t('errors.reviewFriction.blockedStuckBody', { minutes: c.threshold ?? 0 })
|
|
34
|
+
return t('errors.reviewFriction.blockedCountBody', {
|
|
35
|
+
count: c.debt.length,
|
|
36
|
+
threshold: c.threshold ?? 0,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** Deep-link to a waiting task's block and dismiss the whole friction flow. */
|
|
41
|
+
function goToBlock(blockId: string) {
|
|
42
|
+
if (blockId) ui.select(blockId)
|
|
43
|
+
ui.closeReviewFriction()
|
|
44
|
+
ui.closeAddTask()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function goReview() {
|
|
48
|
+
const first = ctx.value?.debt[0]
|
|
49
|
+
if (first) goToBlock(first.blockId)
|
|
50
|
+
else {
|
|
51
|
+
ui.closeReviewFriction()
|
|
52
|
+
ui.closeAddTask()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createAnyway() {
|
|
57
|
+
ctx.value?.onConfirm?.()
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<UModal v-model:open="open" :title="title" :ui="{ content: 'max-w-xl' }">
|
|
63
|
+
<template #body>
|
|
64
|
+
<div v-if="ctx" class="space-y-5">
|
|
65
|
+
<p class="text-sm text-slate-300">{{ body }}</p>
|
|
66
|
+
|
|
67
|
+
<div class="rounded-lg border border-slate-700 bg-slate-900/50 p-2">
|
|
68
|
+
<p class="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
69
|
+
{{ t('errors.reviewFriction.waitingHeading') }}
|
|
70
|
+
</p>
|
|
71
|
+
<ul class="space-y-1">
|
|
72
|
+
<li v-for="item in ctx.debt" :key="item.blockId">
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
class="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm hover:bg-slate-800/60"
|
|
76
|
+
@click="goToBlock(item.blockId)"
|
|
77
|
+
>
|
|
78
|
+
<span class="truncate text-slate-200">
|
|
79
|
+
{{ item.title || t('errors.reviewFriction.untitled') }}
|
|
80
|
+
</span>
|
|
81
|
+
<span class="shrink-0 text-[12px] text-slate-500">
|
|
82
|
+
{{ t('errors.reviewFriction.waiting', { minutes: item.waitingMinutes }) }}
|
|
83
|
+
</span>
|
|
84
|
+
</button>
|
|
85
|
+
</li>
|
|
86
|
+
</ul>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="flex flex-wrap justify-end gap-2">
|
|
90
|
+
<UButton
|
|
91
|
+
color="neutral"
|
|
92
|
+
variant="ghost"
|
|
93
|
+
size="sm"
|
|
94
|
+
@click="
|
|
95
|
+
() => {
|
|
96
|
+
open = false
|
|
97
|
+
}
|
|
98
|
+
"
|
|
99
|
+
>
|
|
100
|
+
{{ t('errors.reviewFriction.close') }}
|
|
101
|
+
</UButton>
|
|
102
|
+
<UButton
|
|
103
|
+
v-if="ctx.onConfirm"
|
|
104
|
+
color="neutral"
|
|
105
|
+
variant="subtle"
|
|
106
|
+
size="sm"
|
|
107
|
+
@click="createAnyway"
|
|
108
|
+
>
|
|
109
|
+
{{ t('errors.reviewFriction.createAnyway') }}
|
|
110
|
+
</UButton>
|
|
111
|
+
<UButton color="primary" size="sm" icon="i-lucide-list-checks" @click="goReview">
|
|
112
|
+
{{ t('errors.reviewFriction.goReview') }}
|
|
113
|
+
</UButton>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
</UModal>
|
|
118
|
+
</template>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Block } from '~/types/domain'
|
|
3
3
|
import { STATUS_META } from '~/utils/catalog'
|
|
4
4
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
5
|
+
import ReviewDebtBadge from '~/components/board/ReviewDebtBadge.vue'
|
|
5
6
|
|
|
6
7
|
const props = defineProps<{ block: Block }>()
|
|
7
8
|
|
|
@@ -61,9 +62,12 @@ function addTask() {
|
|
|
61
62
|
: t('inspector.container.tasks', { count: tasks.length })
|
|
62
63
|
}}
|
|
63
64
|
</span>
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
<div class="flex items-center gap-1.5">
|
|
66
|
+
<ReviewDebtBadge />
|
|
67
|
+
<UButton size="xs" variant="soft" color="primary" icon="i-lucide-plus" @click="addTask">
|
|
68
|
+
{{ t('inspector.container.addTask') }}
|
|
69
|
+
</UButton>
|
|
70
|
+
</div>
|
|
67
71
|
</div>
|
|
68
72
|
<ul v-if="tasks.length" class="space-y-1">
|
|
69
73
|
<li
|
|
@@ -77,6 +77,44 @@ const schemeItems = computed(() => [
|
|
|
77
77
|
{ label: 'http', value: 'http' },
|
|
78
78
|
])
|
|
79
79
|
|
|
80
|
+
// A stored non-secret value is `unknown`; coerce it to the string the form field holds,
|
|
81
|
+
// defaulting a missing/wrong-typed value to '' (the same `typeof … === 'string' ? … : ''`
|
|
82
|
+
// each field used inline before).
|
|
83
|
+
function readString(v: unknown): string {
|
|
84
|
+
return typeof v === 'string' ? v : ''
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Prefill the manifest-source fields from the stored discriminated config.
|
|
88
|
+
function applyManifestSource(k: Record<string, unknown>): void {
|
|
89
|
+
const src = k.manifestSource as Record<string, unknown> | undefined
|
|
90
|
+
if (src?.type === 'separate') {
|
|
91
|
+
form.manifestSourceType = 'separate'
|
|
92
|
+
form.manifestRepo = readString(src.repo)
|
|
93
|
+
form.manifestRef = readString(src.ref)
|
|
94
|
+
form.manifestPath = readString(src.path)
|
|
95
|
+
} else if (src?.type === 'colocated') {
|
|
96
|
+
form.manifestSourceType = 'colocated'
|
|
97
|
+
form.manifestPath = readString(src.path)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Prefill the URL-derivation fields (+ scheme) from the stored discriminated config.
|
|
102
|
+
function applyUrl(k: Record<string, unknown>): void {
|
|
103
|
+
const url = k.url as Record<string, unknown> | undefined
|
|
104
|
+
if (url?.source === 'ingressTemplate') {
|
|
105
|
+
form.urlSource = 'ingressTemplate'
|
|
106
|
+
form.hostTemplate = readString(url.hostTemplate)
|
|
107
|
+
} else if (url?.source === 'ingressStatus') {
|
|
108
|
+
form.urlSource = 'ingressStatus'
|
|
109
|
+
form.ingressName = readString(url.ingressName)
|
|
110
|
+
} else if (url?.source === 'serviceStatus') {
|
|
111
|
+
form.urlSource = 'serviceStatus'
|
|
112
|
+
form.serviceName = readString(url.serviceName)
|
|
113
|
+
form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
|
|
114
|
+
}
|
|
115
|
+
form.urlScheme = url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
|
|
116
|
+
}
|
|
117
|
+
|
|
80
118
|
// A registered k8s env connection exposes its non-secret config, so prefill every
|
|
81
119
|
// non-secret field from it (never the token — secrets are write-only and re-entered on
|
|
82
120
|
// update). This lets an edit change one field without re-typing the whole form.
|
|
@@ -89,36 +127,14 @@ watch(
|
|
|
89
127
|
? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
|
|
90
128
|
: undefined
|
|
91
129
|
if (!k) return
|
|
92
|
-
form.label =
|
|
93
|
-
form.apiServerUrl =
|
|
94
|
-
form.caCertPem =
|
|
130
|
+
form.label = readString(k.label)
|
|
131
|
+
form.apiServerUrl = readString(k.apiServerUrl)
|
|
132
|
+
form.caCertPem = readString(k.caCertPem)
|
|
95
133
|
form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
|
|
96
|
-
form.namespaceTemplate =
|
|
97
|
-
form.imageTemplate =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
form.manifestSourceType = 'separate'
|
|
101
|
-
form.manifestRepo = typeof src.repo === 'string' ? src.repo : ''
|
|
102
|
-
form.manifestRef = typeof src.ref === 'string' ? src.ref : ''
|
|
103
|
-
form.manifestPath = typeof src.path === 'string' ? src.path : ''
|
|
104
|
-
} else if (src?.type === 'colocated') {
|
|
105
|
-
form.manifestSourceType = 'colocated'
|
|
106
|
-
form.manifestPath = typeof src.path === 'string' ? src.path : ''
|
|
107
|
-
}
|
|
108
|
-
const url = k.url as Record<string, unknown> | undefined
|
|
109
|
-
if (url?.source === 'ingressTemplate') {
|
|
110
|
-
form.urlSource = 'ingressTemplate'
|
|
111
|
-
form.hostTemplate = typeof url.hostTemplate === 'string' ? url.hostTemplate : ''
|
|
112
|
-
} else if (url?.source === 'ingressStatus') {
|
|
113
|
-
form.urlSource = 'ingressStatus'
|
|
114
|
-
form.ingressName = typeof url.ingressName === 'string' ? url.ingressName : ''
|
|
115
|
-
} else if (url?.source === 'serviceStatus') {
|
|
116
|
-
form.urlSource = 'serviceStatus'
|
|
117
|
-
form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
|
|
118
|
-
form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
|
|
119
|
-
}
|
|
120
|
-
form.urlScheme =
|
|
121
|
-
url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
|
|
134
|
+
form.namespaceTemplate = readString(k.namespaceTemplate)
|
|
135
|
+
form.imageTemplate = readString(k.imageTemplate)
|
|
136
|
+
applyManifestSource(k)
|
|
137
|
+
applyUrl(k)
|
|
122
138
|
},
|
|
123
139
|
{ immediate: true },
|
|
124
140
|
)
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The latter three are body-only section components rendered in tabs here (no longer
|
|
9
9
|
// standalone modals).
|
|
10
10
|
import { reactive, ref, watch } from 'vue'
|
|
11
|
-
import type { TaskLimitMode } from '~/types/domain'
|
|
11
|
+
import type { ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
|
|
12
12
|
import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
|
|
13
13
|
import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
|
|
14
14
|
import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
|
|
@@ -129,6 +129,12 @@ const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
|
|
|
129
129
|
{ value: 'per_type', label: t('settings.workspaceSettings.taskLimit.modes.per_type') },
|
|
130
130
|
])
|
|
131
131
|
|
|
132
|
+
const REVIEW_FRICTION_MODES = computed<{ value: ReviewFrictionMode; label: string }[]>(() => [
|
|
133
|
+
{ value: 'off', label: t('settings.workspaceSettings.reviewFriction.modes.off') },
|
|
134
|
+
{ value: 'warn', label: t('settings.workspaceSettings.reviewFriction.modes.warn') },
|
|
135
|
+
{ value: 'enforce', label: t('settings.workspaceSettings.reviewFriction.modes.enforce') },
|
|
136
|
+
])
|
|
137
|
+
|
|
132
138
|
/** The localized "Max {type} tasks" label for a per-type running-task limit input. */
|
|
133
139
|
function maxTaskTypeLabel(type: LimitTaskType): string {
|
|
134
140
|
const key = TASK_TYPE_KEYS[type]
|
|
@@ -145,6 +151,12 @@ const draft = reactive({
|
|
|
145
151
|
storeAgentContext: true,
|
|
146
152
|
artifactRetentionDays: 14,
|
|
147
153
|
kaizenEnabled: true,
|
|
154
|
+
reviewFrictionMode: 'off' as ReviewFrictionMode,
|
|
155
|
+
reviewFrictionWarnCount: 3,
|
|
156
|
+
reviewFrictionBlockCountEnabled: false,
|
|
157
|
+
reviewFrictionBlockCount: 10 as number,
|
|
158
|
+
reviewFrictionBlockStuckEnabled: false,
|
|
159
|
+
reviewFrictionBlockStuckMinutes: 1440 as number,
|
|
148
160
|
})
|
|
149
161
|
|
|
150
162
|
function hydrate() {
|
|
@@ -157,6 +169,14 @@ function hydrate() {
|
|
|
157
169
|
draft.storeAgentContext = s.storeAgentContext
|
|
158
170
|
draft.artifactRetentionDays = s.artifactRetentionDays
|
|
159
171
|
draft.kaizenEnabled = s.kaizenEnabled
|
|
172
|
+
draft.reviewFrictionMode = s.reviewFrictionMode
|
|
173
|
+
draft.reviewFrictionWarnCount = s.reviewFrictionWarnCount
|
|
174
|
+
// The hard-block knobs are nullable (null ⇒ that trigger is off); a per-trigger checkbox is
|
|
175
|
+
// enabled from whether a value is stored, defaulting the input to a sensible starting number.
|
|
176
|
+
draft.reviewFrictionBlockCountEnabled = s.reviewFrictionBlockCount != null
|
|
177
|
+
draft.reviewFrictionBlockCount = s.reviewFrictionBlockCount ?? 10
|
|
178
|
+
draft.reviewFrictionBlockStuckEnabled = s.reviewFrictionBlockStuckMinutes != null
|
|
179
|
+
draft.reviewFrictionBlockStuckMinutes = s.reviewFrictionBlockStuckMinutes ?? 1440
|
|
160
180
|
}
|
|
161
181
|
|
|
162
182
|
// `store.settings` is always replaced wholesale (store hydrate/update reassign the ref),
|
|
@@ -166,6 +186,25 @@ watch(() => store.settings, hydrate, { immediate: true })
|
|
|
166
186
|
const saving = ref(false)
|
|
167
187
|
|
|
168
188
|
async function save() {
|
|
189
|
+
// The hard-block triggers only apply in `enforce` mode; a disabled trigger sends null.
|
|
190
|
+
const blockCount =
|
|
191
|
+
draft.reviewFrictionMode === 'enforce' && draft.reviewFrictionBlockCountEnabled
|
|
192
|
+
? draft.reviewFrictionBlockCount
|
|
193
|
+
: null
|
|
194
|
+
const blockStuckMinutes =
|
|
195
|
+
draft.reviewFrictionMode === 'enforce' && draft.reviewFrictionBlockStuckEnabled
|
|
196
|
+
? draft.reviewFrictionBlockStuckMinutes
|
|
197
|
+
: null
|
|
198
|
+
// Mirror the backend's enforce-mode validation client-side so the user gets an immediate,
|
|
199
|
+
// localized message instead of the raw 422 (enforce needs at least one hard trigger).
|
|
200
|
+
if (draft.reviewFrictionMode === 'enforce' && blockCount == null && blockStuckMinutes == null) {
|
|
201
|
+
toast.add({
|
|
202
|
+
title: t('settings.workspaceSettings.reviewFriction.needsTrigger'),
|
|
203
|
+
icon: 'i-lucide-triangle-alert',
|
|
204
|
+
color: 'warning',
|
|
205
|
+
})
|
|
206
|
+
return
|
|
207
|
+
}
|
|
169
208
|
saving.value = true
|
|
170
209
|
try {
|
|
171
210
|
await store.update({
|
|
@@ -185,6 +224,10 @@ async function save() {
|
|
|
185
224
|
storeAgentContext: draft.storeAgentContext,
|
|
186
225
|
artifactRetentionDays: draft.artifactRetentionDays,
|
|
187
226
|
kaizenEnabled: draft.kaizenEnabled,
|
|
227
|
+
reviewFrictionMode: draft.reviewFrictionMode,
|
|
228
|
+
reviewFrictionWarnCount: draft.reviewFrictionWarnCount,
|
|
229
|
+
reviewFrictionBlockCount: blockCount,
|
|
230
|
+
reviewFrictionBlockStuckMinutes: blockStuckMinutes,
|
|
188
231
|
})
|
|
189
232
|
toast.add({
|
|
190
233
|
title: t('settings.workspaceSettings.toast.saved'),
|
|
@@ -288,6 +331,80 @@ async function save() {
|
|
|
288
331
|
</div>
|
|
289
332
|
</section>
|
|
290
333
|
|
|
334
|
+
<!-- Review-debt friction on task creation -->
|
|
335
|
+
<section class="space-y-2">
|
|
336
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
337
|
+
{{ t('settings.workspaceSettings.reviewFriction.heading') }}
|
|
338
|
+
</h3>
|
|
339
|
+
<p class="text-[11px] text-slate-400">
|
|
340
|
+
{{ t('settings.workspaceSettings.reviewFriction.body') }}
|
|
341
|
+
</p>
|
|
342
|
+
<label class="block w-64">
|
|
343
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">{{
|
|
344
|
+
t('settings.workspaceSettings.reviewFriction.mode')
|
|
345
|
+
}}</span>
|
|
346
|
+
<USelect
|
|
347
|
+
v-model="draft.reviewFrictionMode"
|
|
348
|
+
:items="REVIEW_FRICTION_MODES"
|
|
349
|
+
value-key="value"
|
|
350
|
+
size="sm"
|
|
351
|
+
class="w-full"
|
|
352
|
+
/>
|
|
353
|
+
</label>
|
|
354
|
+
|
|
355
|
+
<label v-if="draft.reviewFrictionMode !== 'off'" class="block w-48">
|
|
356
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
357
|
+
{{ t('settings.workspaceSettings.reviewFriction.warnCount') }}
|
|
358
|
+
</span>
|
|
359
|
+
<UInput
|
|
360
|
+
v-model.number="draft.reviewFrictionWarnCount"
|
|
361
|
+
type="number"
|
|
362
|
+
:min="1"
|
|
363
|
+
size="sm"
|
|
364
|
+
/>
|
|
365
|
+
</label>
|
|
366
|
+
|
|
367
|
+
<div v-if="draft.reviewFrictionMode === 'enforce'" class="space-y-2">
|
|
368
|
+
<p class="text-[11px] text-slate-400">
|
|
369
|
+
{{ t('settings.workspaceSettings.reviewFriction.enforceHint') }}
|
|
370
|
+
</p>
|
|
371
|
+
<label class="flex items-center gap-2">
|
|
372
|
+
<USwitch v-model="draft.reviewFrictionBlockCountEnabled" size="sm" />
|
|
373
|
+
<span class="text-[13px] text-slate-300">{{
|
|
374
|
+
t('settings.workspaceSettings.reviewFriction.blockCountToggle')
|
|
375
|
+
}}</span>
|
|
376
|
+
</label>
|
|
377
|
+
<label v-if="draft.reviewFrictionBlockCountEnabled" class="block w-48">
|
|
378
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
379
|
+
{{ t('settings.workspaceSettings.reviewFriction.blockCount') }}
|
|
380
|
+
</span>
|
|
381
|
+
<UInput
|
|
382
|
+
v-model.number="draft.reviewFrictionBlockCount"
|
|
383
|
+
type="number"
|
|
384
|
+
:min="1"
|
|
385
|
+
size="sm"
|
|
386
|
+
/>
|
|
387
|
+
</label>
|
|
388
|
+
<label class="flex items-center gap-2">
|
|
389
|
+
<USwitch v-model="draft.reviewFrictionBlockStuckEnabled" size="sm" />
|
|
390
|
+
<span class="text-[13px] text-slate-300">{{
|
|
391
|
+
t('settings.workspaceSettings.reviewFriction.blockStuckToggle')
|
|
392
|
+
}}</span>
|
|
393
|
+
</label>
|
|
394
|
+
<label v-if="draft.reviewFrictionBlockStuckEnabled" class="block w-48">
|
|
395
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
396
|
+
{{ t('settings.workspaceSettings.reviewFriction.blockStuckMinutes') }}
|
|
397
|
+
</span>
|
|
398
|
+
<UInput
|
|
399
|
+
v-model.number="draft.reviewFrictionBlockStuckMinutes"
|
|
400
|
+
type="number"
|
|
401
|
+
:min="1"
|
|
402
|
+
size="sm"
|
|
403
|
+
/>
|
|
404
|
+
</label>
|
|
405
|
+
</div>
|
|
406
|
+
</section>
|
|
407
|
+
|
|
291
408
|
<!-- Agent observability -->
|
|
292
409
|
<section class="space-y-2">
|
|
293
410
|
<h3 class="text-sm font-semibold text-slate-200">
|
|
@@ -54,6 +54,7 @@ export function boardApi({ send, ws }: ApiContext) {
|
|
|
54
54
|
pipelineId?: string
|
|
55
55
|
agentConfig?: Record<string, string>
|
|
56
56
|
technical?: boolean
|
|
57
|
+
acknowledgeReviewDebt?: boolean
|
|
57
58
|
},
|
|
58
59
|
) => send(addTaskContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
|
|
59
60
|
|