@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.
Files changed (35) hide show
  1. package/app/components/board/AddTaskModal.vue +3 -0
  2. package/app/components/board/nodes/TaskCard.vue +7 -1
  3. package/app/components/layout/AccountRiskPolicySettings.vue +120 -0
  4. package/app/components/pipeline/PipelineBuilder.vue +62 -39
  5. package/app/components/requirements/RequirementsReviewWindow.logic.spec.ts +52 -10
  6. package/app/components/requirements/RequirementsReviewWindow.logic.ts +44 -6
  7. package/app/components/requirements/RequirementsReviewWindow.vue +114 -17
  8. package/app/components/settings/AccountSettingsPanel.vue +17 -2
  9. package/app/components/settings/RiskPolicyCreateForm.vue +164 -0
  10. package/app/components/settings/RiskPolicyEditorRow.vue +315 -0
  11. package/app/components/settings/RiskPolicyInheritedRow.vue +69 -0
  12. package/app/components/settings/RiskPolicyPanel.vue +176 -576
  13. package/app/composables/api/presets.ts +50 -13
  14. package/app/composables/usePipelineErrorToast.ts +11 -0
  15. package/app/composables/usePipelineLibraryActions.ts +82 -0
  16. package/app/composables/useRiskPolicyHealth.ts +14 -1
  17. package/app/composables/useRunStart.spec.ts +9 -5
  18. package/app/stores/pipelines/persistence.ts +32 -2
  19. package/app/stores/pipelines.ts +25 -1
  20. package/app/stores/riskPolicies.ts +123 -13
  21. package/app/types/merge.ts +5 -0
  22. package/app/utils/riskPolicy.spec.ts +28 -0
  23. package/app/utils/riskPolicy.ts +24 -1
  24. package/app/utils/riskPolicyDraft.ts +203 -0
  25. package/i18n/locales/de.json +71 -5
  26. package/i18n/locales/en.json +71 -5
  27. package/i18n/locales/es.json +71 -5
  28. package/i18n/locales/fr.json +71 -5
  29. package/i18n/locales/he.json +71 -5
  30. package/i18n/locales/it.json +71 -5
  31. package/i18n/locales/ja.json +71 -5
  32. package/i18n/locales/pl.json +71 -5
  33. package/i18n/locales/tr.json +71 -5
  34. package/i18n/locales/uk.json +71 -5
  35. 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
- // ---- merge threshold presets (per-task auto-merge policy library) -----
34
- listRiskPolicies: (workspaceId: string) =>
35
- send(listRiskPoliciesContract, { pathPrefix: ws(workspaceId) }),
36
-
37
- createRiskPolicy: (workspaceId: string, body: CreateRiskPolicyBody) =>
38
- send(createRiskPolicyContract, { pathPrefix: ws(workspaceId), body }),
39
-
40
- updateRiskPolicy: (workspaceId: string, presetId: string, body: UpdateRiskPolicyInput) =>
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: ws(workspaceId),
55
+ pathPrefix: scope(kind, id),
43
56
  pathParams: { presetId },
44
57
  body,
45
58
  }),
46
59
 
47
- deleteRiskPolicy: (workspaceId: string, presetId: string) =>
48
- send(deleteRiskPolicyContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
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
- const byId = new Map(store.presets.map((p) => [p.id, p]))
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 { RiskPolicy, WorkspaceRole } from '~/types/merge'
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<RiskPolicy> = {}): RiskPolicy =>
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 RiskPolicy
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?: RiskPolicy
52
- otherPolicy?: RiskPolicy
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
- /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
116
- async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
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
  }
@@ -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 { GateConfigForm, PipelinePurpose, RetiredPipelineWire } from '@cat-factory/contracts'
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 { RiskPolicy, UpdateRiskPolicyInput } from '~/types/domain'
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 workspace's merge threshold presetsthe library a task picks its
8
- * auto-merge policy from (the `merger` step compares the PR assessment against the
9
- * resolved preset). Hydrated from the workspace snapshot; managed via a small
10
- * settings UI. The backend always keeps at least one default preset.
11
+ * The board's risk policy librarywhat 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<RiskPolicy[]>([])
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
- function hydrate(list: RiskPolicy[], versions?: Record<string, number>) {
26
- presets.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
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): RiskPolicy | null {
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>[1]) {
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
+ })
@@ -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
+ })
@@ -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