@cat-factory/app 0.266.1 → 0.268.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/layout/AccountRiskPolicySettings.vue +120 -0
- package/app/components/pipeline/PipelineBuilder.vue +62 -39
- 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/AccountSettingsPanel.vue +17 -2
- package/app/components/settings/RiskPolicyCreateForm.vue +164 -0
- package/app/components/settings/RiskPolicyEditorRow.vue +315 -0
- package/app/components/settings/RiskPolicyInheritedRow.vue +69 -0
- package/app/components/settings/RiskPolicyPanel.vue +176 -576
- package/app/composables/api/presets.ts +50 -13
- package/app/composables/usePipelineErrorToast.ts +11 -0
- package/app/composables/usePipelineLibraryActions.ts +82 -0
- package/app/composables/useRiskPolicyHealth.ts +14 -1
- package/app/composables/useRunStart.spec.ts +9 -5
- package/app/stores/pipelines/persistence.ts +32 -2
- package/app/stores/pipelines.ts +25 -1
- package/app/stores/riskPolicies.ts +123 -13
- package/app/types/merge.ts +5 -0
- package/app/utils/riskPolicy.spec.ts +28 -0
- package/app/utils/riskPolicy.ts +24 -1
- package/app/utils/riskPolicyDraft.ts +203 -0
- package/i18n/locales/de.json +71 -5
- package/i18n/locales/en.json +71 -5
- package/i18n/locales/es.json +71 -5
- package/i18n/locales/fr.json +71 -5
- package/i18n/locales/he.json +71 -5
- package/i18n/locales/it.json +71 -5
- package/i18n/locales/ja.json +71 -5
- package/i18n/locales/pl.json +71 -5
- package/i18n/locales/tr.json +71 -5
- package/i18n/locales/uk.json +71 -5
- package/package.json +2 -2
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
+
cloneRiskPolicyContract,
|
|
2
3
|
createRiskPolicyContract,
|
|
4
|
+
listRiskPolicySuppressionsContract,
|
|
5
|
+
restoreRiskPolicyContract,
|
|
6
|
+
suppressRiskPolicyContract,
|
|
3
7
|
listMergeClassRollupsContract,
|
|
4
8
|
tagMergeReviewEffortContract,
|
|
5
9
|
createConsensusGroupContract,
|
|
@@ -16,7 +20,7 @@ import {
|
|
|
16
20
|
updateRiskPolicyContract,
|
|
17
21
|
updateModelPresetContract,
|
|
18
22
|
} from '@cat-factory/contracts'
|
|
19
|
-
import type { ReviewEffort, UpdateRiskPolicyInput } from '~/types/merge'
|
|
23
|
+
import type { ReviewEffort, RiskPolicyTier, UpdateRiskPolicyInput } from '~/types/merge'
|
|
20
24
|
import type { CreateModelPresetInput, UpdateModelPresetInput } from '~/types/model-presets'
|
|
21
25
|
import type { CreateConsensusGroupInput, UpdateConsensusGroupInput } from '~/types/consensus'
|
|
22
26
|
import type { SendParams } from './client'
|
|
@@ -28,30 +32,63 @@ import type { ApiContext } from './context'
|
|
|
28
32
|
type CreateRiskPolicyBody = NonNullable<SendParams<typeof createRiskPolicyContract>['body']>
|
|
29
33
|
|
|
30
34
|
/** The per-workspace preset libraries: merge-threshold policy + model->agent mapping. */
|
|
31
|
-
export function presetsApi({ send, ws }: ApiContext) {
|
|
35
|
+
export function presetsApi({ send, ws, scope }: ApiContext) {
|
|
32
36
|
return {
|
|
33
|
-
// ----
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
// ---- risk policies (per-task auto-merge policy library) ---------------
|
|
38
|
+
// The four CRUD calls are TIER-scoped (`account` or `workspace`, ADR 0055): the same routes are
|
|
39
|
+
// mounted under both prefixes, so one method serves either library and the caller states which
|
|
40
|
+
// one it is managing. The workspace read answers the MERGED library (its own rows plus the
|
|
41
|
+
// account policies it inherits, each tagged with its tier).
|
|
42
|
+
listRiskPolicies: (kind: RiskPolicyTier, id: string) =>
|
|
43
|
+
send(listRiskPoliciesContract, { pathPrefix: scope(kind, id) }),
|
|
44
|
+
|
|
45
|
+
createRiskPolicy: (kind: RiskPolicyTier, id: string, body: CreateRiskPolicyBody) =>
|
|
46
|
+
send(createRiskPolicyContract, { pathPrefix: scope(kind, id), body }),
|
|
47
|
+
|
|
48
|
+
updateRiskPolicy: (
|
|
49
|
+
kind: RiskPolicyTier,
|
|
50
|
+
id: string,
|
|
51
|
+
presetId: string,
|
|
52
|
+
body: UpdateRiskPolicyInput,
|
|
53
|
+
) =>
|
|
41
54
|
send(updateRiskPolicyContract, {
|
|
42
|
-
pathPrefix:
|
|
55
|
+
pathPrefix: scope(kind, id),
|
|
43
56
|
pathParams: { presetId },
|
|
44
57
|
body,
|
|
45
58
|
}),
|
|
46
59
|
|
|
47
|
-
deleteRiskPolicy: (
|
|
48
|
-
send(deleteRiskPolicyContract, { pathPrefix:
|
|
60
|
+
deleteRiskPolicy: (kind: RiskPolicyTier, id: string, presetId: string) =>
|
|
61
|
+
send(deleteRiskPolicyContract, { pathPrefix: scope(kind, id), pathParams: { presetId } }),
|
|
49
62
|
|
|
50
63
|
// Restore a built-in preset to its current catalog definition (adopt an update, repair a
|
|
51
64
|
// drifted one, or materialise a new built-in that appeared). Custom presets reject this.
|
|
65
|
+
// Workspace-only: the built-in catalog is copied into BOARDS, so only a board has one to restore.
|
|
52
66
|
reseedRiskPolicy: (workspaceId: string, presetId: string) =>
|
|
53
67
|
send(reseedRiskPolicyContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
54
68
|
|
|
69
|
+
// ---- inheritance (workspace only) -------------------------------------
|
|
70
|
+
// Copy an inherited account policy into the board's own tier, under a fresh id, so the board can
|
|
71
|
+
// edit its numbers. `name` is optional; the SPA sends the localized "copy" label.
|
|
72
|
+
cloneRiskPolicy: (workspaceId: string, presetId: string, body: { name?: string }) =>
|
|
73
|
+
send(cloneRiskPolicyContract, {
|
|
74
|
+
pathPrefix: ws(workspaceId),
|
|
75
|
+
pathParams: { presetId },
|
|
76
|
+
body,
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
// Hide an inherited account policy from this board, and the inverse. Deliberately NOT the delete
|
|
80
|
+
// above: that removes a row the board owns, this withholds one it does not and is reversible.
|
|
81
|
+
suppressRiskPolicy: (workspaceId: string, presetId: string) =>
|
|
82
|
+
send(suppressRiskPolicyContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
83
|
+
|
|
84
|
+
restoreRiskPolicy: (workspaceId: string, presetId: string) =>
|
|
85
|
+
send(restoreRiskPolicyContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
86
|
+
|
|
87
|
+
// What the board is hiding. Its own read because a hidden policy is by construction absent from
|
|
88
|
+
// the list above, so without it the editor could offer no way back.
|
|
89
|
+
listRiskPolicySuppressions: (workspaceId: string) =>
|
|
90
|
+
send(listRiskPolicySuppressionsContract, { pathPrefix: ws(workspaceId) }),
|
|
91
|
+
|
|
55
92
|
// ---- merge track record (the per-class evidence behind the policy) -----
|
|
56
93
|
// Every class in ONE request (a single SQL aggregate server-side), so the preset editor can
|
|
57
94
|
// show each class's rule next to the numbers that justify widening it without fanning out.
|
|
@@ -91,6 +91,17 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
91
91
|
titleKey: 'errors.conflict.title.input_gate_parked',
|
|
92
92
|
descriptionKey: 'errors.conflict.description.input_gate_parked',
|
|
93
93
|
},
|
|
94
|
+
// The two halves of "this policy is not yours to change" (ADR 0055). They get opposite copy on
|
|
95
|
+
// purpose: the first sends the reader to the clone action, the second to the delete, and one
|
|
96
|
+
// shared string could only describe whichever case the reader was not in.
|
|
97
|
+
risk_policy_inherited: {
|
|
98
|
+
titleKey: 'errors.conflict.title.risk_policy_inherited',
|
|
99
|
+
descriptionKey: 'errors.conflict.description.risk_policy_inherited',
|
|
100
|
+
},
|
|
101
|
+
risk_policy_not_inherited: {
|
|
102
|
+
titleKey: 'errors.conflict.title.risk_policy_not_inherited',
|
|
103
|
+
descriptionKey: 'errors.conflict.description.risk_policy_not_inherited',
|
|
104
|
+
},
|
|
94
105
|
task_limit_reached: {
|
|
95
106
|
titleKey: 'errors.conflict.title.task_limit_reached',
|
|
96
107
|
descriptionKey: 'errors.conflict.description.task_limit_reached',
|
|
@@ -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
|
+
}
|
|
@@ -31,16 +31,29 @@ function builtinName(id: string, stored: RiskPolicy | undefined): string {
|
|
|
31
31
|
* versions the snapshot ships ARE the set of built-in ids, so detection is entirely client-side:
|
|
32
32
|
* a stored preset is a built-in iff its id is a catalog key, and a catalog key with no stored
|
|
33
33
|
* preset is a new built-in.
|
|
34
|
+
*
|
|
35
|
+
* Asked of the WORKSPACE tier alone. Reseeding writes a board-owned row, so only a board-owned row
|
|
36
|
+
* can be out of date, and only an id no tier resolves is genuinely missing (ADR 0055).
|
|
34
37
|
*/
|
|
35
38
|
export function useRiskPolicyHealth() {
|
|
36
39
|
const store = useRiskPoliciesStore()
|
|
37
40
|
|
|
38
41
|
const issues = computed<RiskPolicyIssue[]>(() => {
|
|
39
42
|
const out: RiskPolicyIssue[] = []
|
|
40
|
-
|
|
43
|
+
// The BOARD'S OWN rows only. Since ADR 0055 `presets` is the merged two-tier library, and an
|
|
44
|
+
// account entry carries no `version`, so indexing the merge read an account policy that happens
|
|
45
|
+
// to use a catalog id as a stored built-in stuck at version 0 — an advisory whose reseed would
|
|
46
|
+
// have written a board-owned row shadowing the account's deliberate posture.
|
|
47
|
+
const own = store.presets.filter((p) => p.tier === 'workspace')
|
|
48
|
+
const byId = new Map(own.map((p) => [p.id, p]))
|
|
49
|
+
const inheritedIds = new Set(store.presets.filter((p) => p.tier === 'account').map((p) => p.id))
|
|
41
50
|
for (const [id, catalogVersion] of Object.entries(store.catalogVersions)) {
|
|
42
51
|
const stored = byId.get(id)
|
|
43
52
|
if (!stored) {
|
|
53
|
+
// A built-in the board does not hold, that its ACCOUNT defines, is not missing: the board
|
|
54
|
+
// already resolves a policy under that id. Adding the catalog copy would shadow it, so the
|
|
55
|
+
// advisory stays silent rather than offering a one-click override of the org's choice.
|
|
56
|
+
if (inheritedIds.has(id)) continue
|
|
44
57
|
out.push({ type: 'new', id, name: builtinName(id, undefined) })
|
|
45
58
|
continue
|
|
46
59
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { nextTick, ref, type Ref } from 'vue'
|
|
3
3
|
import type { Block, Pipeline } from '~/types/domain'
|
|
4
|
-
import type {
|
|
4
|
+
import type { RiskPolicyLibraryEntry, WorkspaceRole } from '~/types/merge'
|
|
5
5
|
import { useBoardStore } from '~/stores/board'
|
|
6
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
7
7
|
import { useRiskPoliciesStore } from '~/stores/riskPolicies'
|
|
@@ -14,7 +14,7 @@ import { useDryRunPolicy, useRunStart } from '~/composables/useRunStart'
|
|
|
14
14
|
// stubbed, since neither an HTTP call nor an auth gate is what these assertions are about.
|
|
15
15
|
const pipeline = { id: 'pl_build', name: 'Build' } as Pipeline
|
|
16
16
|
|
|
17
|
-
const preset = (over: Partial<
|
|
17
|
+
const preset = (over: Partial<RiskPolicyLibraryEntry> = {}): RiskPolicyLibraryEntry =>
|
|
18
18
|
({
|
|
19
19
|
id: 'mp_balanced',
|
|
20
20
|
name: 'Balanced',
|
|
@@ -35,9 +35,13 @@ const preset = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
|
|
|
35
35
|
classRulesByRole: {},
|
|
36
36
|
dryRunRoles: [],
|
|
37
37
|
isDefault: true,
|
|
38
|
+
// The board's OWN tier: what these assertions are about is the policy the board resolves, and an
|
|
39
|
+
// inherited one resolves identically (that is the point of the merge), so the fixture states the
|
|
40
|
+
// ordinary case rather than parameterising a tier nothing here branches on.
|
|
41
|
+
tier: 'workspace',
|
|
38
42
|
createdAt: 0,
|
|
39
43
|
...over,
|
|
40
|
-
}) as
|
|
44
|
+
}) as RiskPolicyLibraryEntry
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
47
|
* Seed a board governed by `policy`, signed in as `role`, and hand back the composable.
|
|
@@ -48,8 +52,8 @@ const preset = (over: Partial<RiskPolicy> = {}): RiskPolicy =>
|
|
|
48
52
|
*/
|
|
49
53
|
function setup(options: {
|
|
50
54
|
role: WorkspaceRole | null
|
|
51
|
-
policy?:
|
|
52
|
-
otherPolicy?:
|
|
55
|
+
policy?: RiskPolicyLibraryEntry
|
|
56
|
+
otherPolicy?: RiskPolicyLibraryEntry
|
|
53
57
|
advanced?: boolean
|
|
54
58
|
}) {
|
|
55
59
|
const start = vi.fn().mockResolvedValue(true)
|
|
@@ -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
|
}
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
RiskPolicyLibraryEntry,
|
|
5
|
+
RiskPolicySuppression,
|
|
6
|
+
UpdateRiskPolicyInput,
|
|
7
|
+
} from '~/types/merge'
|
|
4
8
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* The board's risk policy library — what a task picks its auto-merge policy from (the `merger` step
|
|
12
|
+
* compares the PR assessment against the resolved policy).
|
|
13
|
+
*
|
|
14
|
+
* Since ADR 0055 the list is the MERGE of two tiers: the board's own policies and the ones it
|
|
15
|
+
* inherits from its account, each entry carrying the `tier` that owns it. An inherited entry is
|
|
16
|
+
* read-only here and offers two actions instead of an edit — clone it into the board, or hide it —
|
|
17
|
+
* and the backend refuses the writes as well, so the affordances and the rules agree.
|
|
18
|
+
*
|
|
19
|
+
* Hydrated from the workspace snapshot, which carries the same merged list.
|
|
11
20
|
*/
|
|
12
21
|
export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
13
22
|
const api = useApi()
|
|
14
23
|
|
|
15
|
-
const presets = ref<
|
|
24
|
+
const presets = ref<RiskPolicyLibraryEntry[]>([])
|
|
16
25
|
/**
|
|
17
26
|
* Current built-in catalog versions (`seedRiskPolicies()`), keyed by preset id, from the
|
|
18
27
|
* workspace snapshot. The keys ARE the set of built-in ids: a stored preset whose id is a
|
|
@@ -21,9 +30,24 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
21
30
|
* `useRiskPolicyHealth`.
|
|
22
31
|
*/
|
|
23
32
|
const catalogVersions = ref<Record<string, number>>({})
|
|
33
|
+
/**
|
|
34
|
+
* The account policies this board is HIDING. Loaded on demand rather than from the snapshot: a
|
|
35
|
+
* hidden policy is by construction absent from `presets`, so this is the only way back, and it is
|
|
36
|
+
* read when the settings panel opens rather than on every board load.
|
|
37
|
+
*/
|
|
38
|
+
const suppressions = ref<RiskPolicySuppression[]>([])
|
|
24
39
|
|
|
25
|
-
|
|
26
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Adopt the server's list AS SENT, never re-sorted.
|
|
42
|
+
*
|
|
43
|
+
* `mergeRiskPolicyTiers` answers oldest-first within each tier with the ACCOUNT tier first, and
|
|
44
|
+
* both repositories order by `created_at`, so the order already carries the tier grouping every
|
|
45
|
+
* reader wants. Re-sorting the merged list by timestamp interleaved the two, which the settings
|
|
46
|
+
* panel hid by re-splitting per tier and the task picker showed as inherited and own policies
|
|
47
|
+
* shuffled together.
|
|
48
|
+
*/
|
|
49
|
+
function hydrate(list: RiskPolicyLibraryEntry[], versions?: Record<string, number>) {
|
|
50
|
+
presets.value = [...list]
|
|
27
51
|
if (versions) catalogVersions.value = versions
|
|
28
52
|
}
|
|
29
53
|
|
|
@@ -31,7 +55,7 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
31
55
|
const defaultPreset = computed(() => presets.value.find((p) => p.isDefault) ?? null)
|
|
32
56
|
|
|
33
57
|
/** Resolve a task's effective preset by id, falling back to the default. */
|
|
34
|
-
function resolve(presetId: string | undefined):
|
|
58
|
+
function resolve(presetId: string | undefined): RiskPolicyLibraryEntry | null {
|
|
35
59
|
if (presetId) {
|
|
36
60
|
const picked = presets.value.find((p) => p.id === presetId)
|
|
37
61
|
if (picked) return picked
|
|
@@ -39,23 +63,23 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
39
63
|
return defaultPreset.value
|
|
40
64
|
}
|
|
41
65
|
|
|
42
|
-
async function create(input: Parameters<typeof api.createRiskPolicy>[
|
|
66
|
+
async function create(input: Parameters<typeof api.createRiskPolicy>[2]) {
|
|
43
67
|
const ws = useWorkspaceStore()
|
|
44
|
-
const created = await api.createRiskPolicy(ws.requireId(), input)
|
|
68
|
+
const created = await api.createRiskPolicy('workspace', ws.requireId(), input)
|
|
45
69
|
await ws.refresh()
|
|
46
70
|
return created
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
async function update(presetId: string, patch: UpdateRiskPolicyInput) {
|
|
50
74
|
const ws = useWorkspaceStore()
|
|
51
|
-
const updated = await api.updateRiskPolicy(ws.requireId(), presetId, patch)
|
|
75
|
+
const updated = await api.updateRiskPolicy('workspace', ws.requireId(), presetId, patch)
|
|
52
76
|
await ws.refresh()
|
|
53
77
|
return updated
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
async function remove(presetId: string) {
|
|
57
81
|
const ws = useWorkspaceStore()
|
|
58
|
-
await api.deleteRiskPolicy(ws.requireId(), presetId)
|
|
82
|
+
await api.deleteRiskPolicy('workspace', ws.requireId(), presetId)
|
|
59
83
|
await ws.refresh()
|
|
60
84
|
}
|
|
61
85
|
|
|
@@ -71,9 +95,41 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
71
95
|
return updated
|
|
72
96
|
}
|
|
73
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Copy an inherited account policy into the board, under a fresh id, so the board can edit it.
|
|
100
|
+
* `name` is supplied by the caller because the label is localized copy and the backend does not
|
|
101
|
+
* localize prose.
|
|
102
|
+
*/
|
|
103
|
+
async function clone(presetId: string, name?: string) {
|
|
104
|
+
const ws = useWorkspaceStore()
|
|
105
|
+
const created = await api.cloneRiskPolicy(ws.requireId(), presetId, name ? { name } : {})
|
|
106
|
+
await ws.refresh()
|
|
107
|
+
return created
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Hide an inherited account policy from this board, then re-read what is hidden. */
|
|
111
|
+
async function hide(presetId: string) {
|
|
112
|
+
const ws = useWorkspaceStore()
|
|
113
|
+
await api.suppressRiskPolicy(ws.requireId(), presetId)
|
|
114
|
+
await Promise.all([ws.refresh(), loadSuppressions()])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stop hiding one, so the board offers it again. */
|
|
118
|
+
async function unhide(presetId: string) {
|
|
119
|
+
const ws = useWorkspaceStore()
|
|
120
|
+
await api.restoreRiskPolicy(ws.requireId(), presetId)
|
|
121
|
+
await Promise.all([ws.refresh(), loadSuppressions()])
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadSuppressions() {
|
|
125
|
+
const ws = useWorkspaceStore()
|
|
126
|
+
suppressions.value = await api.listRiskPolicySuppressions(ws.requireId())
|
|
127
|
+
}
|
|
128
|
+
|
|
74
129
|
return {
|
|
75
130
|
presets,
|
|
76
131
|
catalogVersions,
|
|
132
|
+
suppressions,
|
|
77
133
|
defaultPreset,
|
|
78
134
|
resolve,
|
|
79
135
|
hydrate,
|
|
@@ -81,5 +137,59 @@ export const useRiskPoliciesStore = defineStore('riskPolicies', () => {
|
|
|
81
137
|
update,
|
|
82
138
|
remove,
|
|
83
139
|
reseed,
|
|
140
|
+
clone,
|
|
141
|
+
hide,
|
|
142
|
+
unhide,
|
|
143
|
+
loadSuppressions,
|
|
84
144
|
}
|
|
85
145
|
})
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The ACCOUNT tier of the same library: the postures an org authors once, which every board under
|
|
149
|
+
* it inherits (ADR 0055).
|
|
150
|
+
*
|
|
151
|
+
* Its own store rather than a scope flag on the one above, because the two have different lifetimes
|
|
152
|
+
* and different sources of truth: the board library rides the workspace snapshot and refreshes with
|
|
153
|
+
* it, while the account library is loaded on demand by the account settings panel and has no
|
|
154
|
+
* snapshot to fold into. Keyed by account id so switching accounts cannot show the previous one's
|
|
155
|
+
* policies.
|
|
156
|
+
*/
|
|
157
|
+
export const useAccountRiskPoliciesStore = defineStore('accountRiskPolicies', () => {
|
|
158
|
+
const api = useApi()
|
|
159
|
+
|
|
160
|
+
const byAccount = ref<Record<string, RiskPolicyLibraryEntry[]>>({})
|
|
161
|
+
const loading = ref(false)
|
|
162
|
+
|
|
163
|
+
const policies = (accountId: string) => byAccount.value[accountId] ?? []
|
|
164
|
+
|
|
165
|
+
async function load(accountId: string) {
|
|
166
|
+
loading.value = true
|
|
167
|
+
try {
|
|
168
|
+
byAccount.value = {
|
|
169
|
+
...byAccount.value,
|
|
170
|
+
[accountId]: await api.listRiskPolicies('account', accountId),
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
loading.value = false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function create(accountId: string, input: Parameters<typeof api.createRiskPolicy>[2]) {
|
|
178
|
+
const created = await api.createRiskPolicy('account', accountId, input)
|
|
179
|
+
await load(accountId)
|
|
180
|
+
return created
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function update(accountId: string, presetId: string, patch: UpdateRiskPolicyInput) {
|
|
184
|
+
const updated = await api.updateRiskPolicy('account', accountId, presetId, patch)
|
|
185
|
+
await load(accountId)
|
|
186
|
+
return updated
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function remove(accountId: string, presetId: string) {
|
|
190
|
+
await api.deleteRiskPolicy('account', accountId, presetId)
|
|
191
|
+
await load(accountId)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { policies, loading, load, create, update, remove }
|
|
195
|
+
})
|
package/app/types/merge.ts
CHANGED
|
@@ -25,4 +25,9 @@ export type {
|
|
|
25
25
|
RiskPolicy,
|
|
26
26
|
CreateRiskPolicyInput,
|
|
27
27
|
UpdateRiskPolicyInput,
|
|
28
|
+
// The two tiers a policy can be stored at, the merged library entry a board picks from, and one
|
|
29
|
+
// account policy a board is hiding (ADR 0055).
|
|
30
|
+
RiskPolicyTier,
|
|
31
|
+
RiskPolicyLibraryEntry,
|
|
32
|
+
RiskPolicySuppression,
|
|
28
33
|
} from '@cat-factory/contracts'
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import type { RiskPolicy } from '~/types/merge'
|
|
3
|
+
import { RISK_POLICY_NAME_MAX_LENGTH } from '@cat-factory/contracts'
|
|
3
4
|
import {
|
|
4
5
|
RISK_POLICY_AXES,
|
|
5
6
|
RISK_POLICY_CEILING_FIELD,
|
|
6
7
|
riskPolicyCeilings,
|
|
8
|
+
riskPolicyCopyName,
|
|
7
9
|
rolePolicySummary,
|
|
8
10
|
} from '~/utils/riskPolicy'
|
|
9
11
|
|
|
@@ -101,3 +103,29 @@ describe('rolePolicySummary', () => {
|
|
|
101
103
|
expect(rolePolicySummary(p)).toEqual({ sandboxed: ['member'], narrowed: [], scoped: [] })
|
|
102
104
|
})
|
|
103
105
|
})
|
|
106
|
+
|
|
107
|
+
describe('riskPolicyCopyName', () => {
|
|
108
|
+
// The template the SPA actually uses for the clone action.
|
|
109
|
+
const copy = (name: string) => `${name} (copy)`
|
|
110
|
+
|
|
111
|
+
it('leaves a name with room to spare alone', () => {
|
|
112
|
+
expect(riskPolicyCopyName('Balanced', copy)).toBe('Balanced (copy)')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('keeps the copy marker by trimming the SOURCE name, not the result', () => {
|
|
116
|
+
// 58 chars, so ' (copy)' would put the composed name 5 over the 60-char contract limit. The
|
|
117
|
+
// clone action was simply unavailable for such a policy: a 422 against a name with no field to
|
|
118
|
+
// edit. The marker is the informative half, so the source name is what gives way.
|
|
119
|
+
const long = 'Balanced posture for revenue services with schema review!!'
|
|
120
|
+
expect(long.length).toBe(58)
|
|
121
|
+
const composed = riskPolicyCopyName(long, copy)
|
|
122
|
+
expect(composed.length).toBeLessThanOrEqual(RISK_POLICY_NAME_MAX_LENGTH)
|
|
123
|
+
expect(composed.endsWith('(copy)')).toBe(true)
|
|
124
|
+
expect(long.startsWith(composed.slice(0, composed.indexOf(' (copy)')))).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('still answers within the limit when the template alone would blow the budget', () => {
|
|
128
|
+
const verbose = (name: string) => `${name} ${'x'.repeat(RISK_POLICY_NAME_MAX_LENGTH)}`
|
|
129
|
+
expect(riskPolicyCopyName('Balanced', verbose).length).toBe(RISK_POLICY_NAME_MAX_LENGTH)
|
|
130
|
+
})
|
|
131
|
+
})
|
package/app/utils/riskPolicy.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
|
-
import { WORKSPACE_ROLES } from '@cat-factory/contracts'
|
|
1
|
+
import { RISK_POLICY_NAME_MAX_LENGTH, WORKSPACE_ROLES } from '@cat-factory/contracts'
|
|
2
2
|
import type { RiskPolicy, WorkspaceRole } from '~/types/merge'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The name a CLONE of an inherited policy is created under, guaranteed to satisfy the contract's
|
|
6
|
+
* length limit.
|
|
7
|
+
*
|
|
8
|
+
* The label is composed here because the backend does not localize prose, which puts the contract's
|
|
9
|
+
* ceiling on this side of the wire too: a source name near the limit plus whatever the locale's
|
|
10
|
+
* template adds is over it, and the clone action then failed with a 422 against a name the operator
|
|
11
|
+
* had no field to shorten. The SOURCE name is what gets trimmed rather than the composed string, so
|
|
12
|
+
* the copy marker (the informative half) always survives.
|
|
13
|
+
*
|
|
14
|
+
* `compose` is passed in rather than `t` so this stays a pure function with no i18n dependency.
|
|
15
|
+
*/
|
|
16
|
+
export function riskPolicyCopyName(sourceName: string, compose: (name: string) => string): string {
|
|
17
|
+
const full = compose(sourceName)
|
|
18
|
+
if (full.length <= RISK_POLICY_NAME_MAX_LENGTH) return full
|
|
19
|
+
// What the template itself costs, so the trim leaves room for it rather than guessing.
|
|
20
|
+
const overhead = full.length - sourceName.length
|
|
21
|
+
const room = Math.max(1, RISK_POLICY_NAME_MAX_LENGTH - overhead)
|
|
22
|
+
// The final slice is the backstop for a template long enough to blow the budget on its own,
|
|
23
|
+
// where no source name would fit: better a truncated label than an action that cannot be used.
|
|
24
|
+
return compose(sourceName.slice(0, room).trimEnd()).slice(0, RISK_POLICY_NAME_MAX_LENGTH)
|
|
25
|
+
}
|
|
26
|
+
|
|
4
27
|
/**
|
|
5
28
|
* The three axes a `merger` agent scores a pull request on. Presentation order is
|
|
6
29
|
* risk → impact → complexity: what the PR could break first, then how far it reaches, then
|