@cat-factory/app 0.192.0 → 0.194.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 +7 -5
- package/app/components/board/RecurringPipelineModal.vue +11 -6
- package/app/components/board/nodes/BlockNode.vue +28 -46
- package/app/components/board/nodes/ModuleFrame.vue +4 -33
- package/app/components/board/nodes/ResizeGrips.vue +130 -0
- package/app/components/focus/BlockFocusView.vue +11 -3
- package/app/components/panels/InspectorPanel.vue +15 -4
- package/app/components/panels/inspector/TaskRunSettings.vue +5 -1
- package/app/components/requirements/RequirementsReviewWindow.vue +34 -0
- package/app/composables/api/board.ts +7 -1
- package/app/composables/api/context.ts +2 -0
- package/app/composables/useFrameResize.ts +116 -22
- package/app/composables/usePipelineHealth.spec.ts +32 -0
- package/app/composables/usePipelineHealth.ts +23 -4
- package/app/stores/board/placement.ts +64 -1
- package/app/stores/board.spec.ts +51 -0
- package/app/types/requirements.ts +1 -0
- package/app/utils/pipeline.spec.ts +85 -5
- package/app/utils/pipeline.ts +32 -11
- package/i18n/locales/de.json +7 -1
- package/i18n/locales/en.json +11 -1
- package/i18n/locales/es.json +7 -1
- package/i18n/locales/fr.json +7 -1
- package/i18n/locales/he.json +7 -1
- package/i18n/locales/it.json +7 -1
- package/i18n/locales/ja.json +7 -1
- package/i18n/locales/pl.json +7 -1
- package/i18n/locales/tr.json +7 -1
- package/i18n/locales/uk.json +7 -1
- package/package.json +2 -2
|
@@ -2,56 +2,150 @@ import { ref } from 'vue'
|
|
|
2
2
|
import type { Block } from '~/types/domain'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* The eight border/corner grips, each as the pair of unit factors saying how far the drag moves
|
|
6
|
+
* the container's ORIGIN versus its far edge. `1` on an origin axis means that axis's border is
|
|
7
|
+
* the one being dragged (west/north), so the box grows the OPPOSITE way from the pointer delta.
|
|
8
|
+
*
|
|
9
|
+
* Encoding the geometry as data rather than a `switch` per axis is what keeps the corners honest:
|
|
10
|
+
* `nw` is exactly `n` and `w` applied together, and there is no eighth case to forget.
|
|
11
|
+
*/
|
|
12
|
+
const HANDLES = {
|
|
13
|
+
n: { ox: 0, oy: 1, sx: 0, sy: -1, cursor: 'ns-resize' },
|
|
14
|
+
s: { ox: 0, oy: 0, sx: 0, sy: 1, cursor: 'ns-resize' },
|
|
15
|
+
e: { ox: 0, oy: 0, sx: 1, sy: 0, cursor: 'ew-resize' },
|
|
16
|
+
w: { ox: 1, oy: 0, sx: -1, sy: 0, cursor: 'ew-resize' },
|
|
17
|
+
ne: { ox: 0, oy: 1, sx: 1, sy: -1, cursor: 'nesw-resize' },
|
|
18
|
+
nw: { ox: 1, oy: 1, sx: -1, sy: -1, cursor: 'nwse-resize' },
|
|
19
|
+
se: { ox: 0, oy: 0, sx: 1, sy: 1, cursor: 'nwse-resize' },
|
|
20
|
+
sw: { ox: 1, oy: 0, sx: -1, sy: 1, cursor: 'nesw-resize' },
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type ResizeEdge = keyof typeof HANDLES
|
|
24
|
+
|
|
25
|
+
/** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
|
|
26
|
+
export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
|
|
27
|
+
|
|
28
|
+
/** The `cursor` a given grip shows, and holds on `<body>` while its drag runs. */
|
|
29
|
+
export function resizeCursor(edge: ResizeEdge): string {
|
|
30
|
+
return HANDLES[edge].cursor
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pointer-driven resizing for containers (service frames and modules) by dragging any border or
|
|
35
|
+
* corner, Miro-style. The drag delta is divided by the board zoom so the border tracks the
|
|
36
|
+
* cursor, and the new size is clamped to the container's content extent so dragging inwards never
|
|
37
|
+
* clips the tasks/modules inside.
|
|
38
|
+
*
|
|
39
|
+
* Dragging the north or west border also moves the container's ORIGIN, and a child's position is
|
|
40
|
+
* stored relative to that origin — so the store translates the children by the inverse (see
|
|
41
|
+
* `previewResize`) and the backend does the same on commit, which is what makes the border extend
|
|
42
|
+
* past the contents instead of dragging them along. The origin is derived from the CLAMPED size
|
|
43
|
+
* rather than from the raw pointer delta: once the box has hit its content floor the border must
|
|
44
|
+
* stop dead, and a separately-clamped origin would keep sliding, walking the whole container
|
|
45
|
+
* across the board.
|
|
46
|
+
*
|
|
47
|
+
* The container grows live off the store's optimistic geometry, and the final bounds are
|
|
48
|
+
* persisted ONCE on release rather than on every move.
|
|
11
49
|
*/
|
|
12
50
|
export function useFrameResize() {
|
|
13
51
|
const board = useBoardStore()
|
|
14
52
|
const ui = useUiStore()
|
|
15
53
|
const access = useWorkspaceAccess()
|
|
16
|
-
/** Id of the
|
|
54
|
+
/** Id of the container currently being resized, for cursor/grip styling. */
|
|
17
55
|
const resizingId = ref<string | null>(null)
|
|
18
56
|
|
|
19
57
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
58
|
+
* How far the origin may travel INWARD before the nearest child would land at a negative
|
|
59
|
+
* offset — i.e. outside the box, spilling over the very border being dragged. `contentSize` is
|
|
60
|
+
* no help here: it measures only the FAR edge of the contents, which a north/west shrink moves
|
|
61
|
+
* inward in step with the border, so nothing there ever objects. `Infinity` for an empty
|
|
62
|
+
* container, which is then bounded by `contentSize`'s empty floor alone.
|
|
22
63
|
*/
|
|
23
|
-
function
|
|
64
|
+
function originSlack(id: string): { x: number; y: number } {
|
|
65
|
+
const children = board.childrenOf(id)
|
|
66
|
+
if (!children.length) return { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY }
|
|
67
|
+
return {
|
|
68
|
+
x: Math.min(...children.map((c) => c.position.x)),
|
|
69
|
+
y: Math.min(...children.map((c) => c.position.y)),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Begin a resize from one of the container's borders or corners. */
|
|
74
|
+
function startResize(block: Block, e: PointerEvent, edge: ResizeEdge) {
|
|
24
75
|
if (e.button !== 0) return
|
|
25
|
-
// Resizing
|
|
26
|
-
//
|
|
76
|
+
// Resizing persists geometry — a `board.write` mutation, so a read-only viewer's resize
|
|
77
|
+
// no-ops (the grips are hidden for them at the component level).
|
|
27
78
|
if (!access.canWriteBoard.value) return
|
|
28
79
|
e.preventDefault()
|
|
29
80
|
e.stopPropagation()
|
|
81
|
+
const handle = HANDLES[edge]
|
|
30
82
|
const startX = e.clientX
|
|
31
83
|
const startY = e.clientY
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
// Seed from the current rendered size so the first move doesn't jump.
|
|
84
|
+
// Seed from the current rendered geometry so the first move doesn't jump. `size` may be
|
|
85
|
+
// absent (an auto-sized container), which is also what a rejected resize must restore.
|
|
35
86
|
const start = board.containerSize(block.id)
|
|
87
|
+
const from = {
|
|
88
|
+
position: { ...block.position },
|
|
89
|
+
size: block.size ? { ...block.size } : undefined,
|
|
90
|
+
}
|
|
91
|
+
// The floors, snapshotted once: on an origin-axis drag the children MOVE, so a floor re-read
|
|
92
|
+
// mid-drag would chase them. `contentSize` bounds the far edge; the near-edge bound applies
|
|
93
|
+
// only where the origin travels, and only inwards.
|
|
94
|
+
const min = board.contentSize(block.id)
|
|
95
|
+
const slack = originSlack(block.id)
|
|
96
|
+
const floor = {
|
|
97
|
+
w: handle.ox ? Math.max(min.w, start.w - slack.x) : min.w,
|
|
98
|
+
h: handle.oy ? Math.max(min.h, start.h - slack.y) : min.h,
|
|
99
|
+
}
|
|
36
100
|
resizingId.value = block.id
|
|
37
101
|
|
|
102
|
+
// Hold the resize cursor on `<body>` (and kill text selection) for the whole drag: the
|
|
103
|
+
// pointer routinely outruns the 12px grip, and without this the cursor flips back to the
|
|
104
|
+
// default mid-drag, which reads as "the grab was dropped" even though the border is still
|
|
105
|
+
// tracking.
|
|
106
|
+
const body = document.body
|
|
107
|
+
const priorCursor = body.style.cursor
|
|
108
|
+
const priorUserSelect = body.style.userSelect
|
|
109
|
+
body.style.cursor = handle.cursor
|
|
110
|
+
body.style.userSelect = 'none'
|
|
111
|
+
|
|
112
|
+
let bounds = { position: from.position, size: start }
|
|
113
|
+
let moved = false
|
|
38
114
|
const onMove = (ev: PointerEvent) => {
|
|
39
115
|
const z = ui.zoom || 1
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
116
|
+
const dx = (ev.clientX - startX) / z
|
|
117
|
+
const dy = (ev.clientY - startY) / z
|
|
118
|
+
const w = Math.round(Math.max(floor.w, start.w + handle.sx * dx))
|
|
119
|
+
const h = Math.round(Math.max(floor.h, start.h + handle.sy * dy))
|
|
120
|
+
// A grown box on an origin axis extends BACKWARDS from where the far edge stays put, so
|
|
121
|
+
// the origin moves by whatever the size actually gained after clamping.
|
|
122
|
+
bounds = {
|
|
123
|
+
position: {
|
|
124
|
+
x: from.position.x - handle.ox * (w - start.w),
|
|
125
|
+
y: from.position.y - handle.oy * (h - start.h),
|
|
126
|
+
},
|
|
127
|
+
size: { w, h },
|
|
128
|
+
}
|
|
129
|
+
moved = true
|
|
130
|
+
// Optimistic, local-only (the store also translates the children): no round-trip per move.
|
|
131
|
+
board.previewResize(block.id, bounds.position, bounds.size)
|
|
45
132
|
}
|
|
46
133
|
const onUp = () => {
|
|
47
134
|
window.removeEventListener('pointermove', onMove)
|
|
48
135
|
window.removeEventListener('pointerup', onUp)
|
|
136
|
+
window.removeEventListener('pointercancel', onUp)
|
|
137
|
+
body.style.cursor = priorCursor
|
|
138
|
+
body.style.userSelect = priorUserSelect
|
|
49
139
|
resizingId.value = null
|
|
50
|
-
//
|
|
51
|
-
|
|
140
|
+
// A press with no movement is not a resize: committing it would emit a coarse board signal
|
|
141
|
+
// (every other client re-hydrates) to store the geometry it already had.
|
|
142
|
+
if (moved) void board.resizeBlock(block.id, bounds, from)
|
|
52
143
|
}
|
|
53
144
|
window.addEventListener('pointermove', onMove)
|
|
54
145
|
window.addEventListener('pointerup', onUp)
|
|
146
|
+
// A cancelled pointer (touch interrupted by a gesture, window losing the pointer) never fires
|
|
147
|
+
// `pointerup`, so without this the body cursor stays stuck on `ew-resize` for the session.
|
|
148
|
+
window.addEventListener('pointercancel', onUp)
|
|
55
149
|
}
|
|
56
150
|
|
|
57
151
|
return { resizingId, startResize }
|
|
@@ -140,6 +140,38 @@ describe('usePipelineHealth', () => {
|
|
|
140
140
|
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
141
141
|
})
|
|
142
142
|
|
|
143
|
+
// The regression this pins: the advisory carried its own "only a companion may be gated" rule,
|
|
144
|
+
// so when the engine generalised gating to `BUILTIN_GATABLE_KINDS` the shipped `pl_simple`
|
|
145
|
+
// ("Adaptive build" — an estimate-gated `architect`) was reported invalid in EVERY workspace.
|
|
146
|
+
// Because the advisory auto-opens a modal over the board, that made the board unusable rather
|
|
147
|
+
// than merely warning wrongly. Both sides now read the shared contracts constant.
|
|
148
|
+
it('accepts an estimate-gated NON-companion producer that the shared gatable set allows', () => {
|
|
149
|
+
const adaptive = builtin(['task-estimator', 'architect', 'architect-companion', 'coder'], {
|
|
150
|
+
gating: [null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }, null, null],
|
|
151
|
+
})
|
|
152
|
+
const { hasIssues } = scan([adaptive])
|
|
153
|
+
expect(hasIssues.value).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('still flags an estimate-gated kind the shared gatable set excludes (merger)', () => {
|
|
157
|
+
const gatedMerger = builtin(['task-estimator', 'coder', 'merger'], {
|
|
158
|
+
gating: [null, null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
|
|
159
|
+
})
|
|
160
|
+
const { invalid } = scan([gatedMerger])
|
|
161
|
+
expect(invalid.value).toHaveLength(1)
|
|
162
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('flags a step carrying BOTH a human approval gate and an estimate gate (shape)', () => {
|
|
166
|
+
const both = builtin(['task-estimator', 'architect'], {
|
|
167
|
+
gates: [false, true],
|
|
168
|
+
gating: [null, { enabled: true, minComplexity: 0.4, onMissingEstimate: 'run' }],
|
|
169
|
+
})
|
|
170
|
+
const { invalid } = scan([both])
|
|
171
|
+
expect(invalid.value).toHaveLength(1)
|
|
172
|
+
expect(invalid.value[0]!.problems.some((p) => p.type === 'shape')).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
143
175
|
it('reports a built-in whose catalog version moved ahead as outdated (not invalid)', () => {
|
|
144
176
|
const stale = builtin(['coder', 'reviewer'], { id: 'pl_stale', version: 1 })
|
|
145
177
|
const { invalid, outdated } = scan([stale], { pl_stale: 2 })
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed } from 'vue'
|
|
2
2
|
import type { Pipeline } from '~/types/domain'
|
|
3
3
|
import type { StepGating } from '~/types/consensus'
|
|
4
|
+
import { isBuiltinGatableKind } from '@cat-factory/contracts'
|
|
4
5
|
import { COMPANION_FOR_PRODUCER, isKnownAgentKind, isProducerCompanion } from '~/utils/catalog'
|
|
5
6
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
6
7
|
|
|
@@ -78,6 +79,11 @@ const isEnabledAt = (p: Pipeline, i: number) => p.enabled?.[i] !== false
|
|
|
78
79
|
* gating, over the ENABLED subset), collecting the first problem instead of throwing. Returns a
|
|
79
80
|
* human message, or null when the shape is valid. Kept in step with
|
|
80
81
|
* `backend/packages/orchestration/src/modules/pipelines/pipelineShape.ts`.
|
|
82
|
+
*
|
|
83
|
+
* A rule here must be keyed off vocabulary SHARED with that module (`@cat-factory/contracts`)
|
|
84
|
+
* wherever one exists, never re-stated locally — see the gating note below for what a drifted copy
|
|
85
|
+
* costs. Adding a rule to `assertValidGating` without adding it here is the milder half of the same
|
|
86
|
+
* drift: a pipeline the engine refuses at save that this advisory calls healthy.
|
|
81
87
|
*/
|
|
82
88
|
function shapeProblem(p: Pipeline): string | null {
|
|
83
89
|
const kinds = p.agentKinds
|
|
@@ -102,16 +108,29 @@ function shapeProblem(p: Pipeline): string | null {
|
|
|
102
108
|
return `Companion '${kind}' must run immediately after an enabled step it can review (${targets.join(', ')}).`
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
|
-
// Estimate gating: an enabled gated step must be a
|
|
106
|
-
// enabled task-estimator earlier in the
|
|
111
|
+
// Estimate gating: an enabled gated step must be a GATABLE kind, must not also carry a human
|
|
112
|
+
// approval gate, must set ≥1 threshold, and must have an enabled task-estimator earlier in the
|
|
113
|
+
// chain. Gatability reads the SHARED `BUILTIN_GATABLE_KINDS` rather than a local rule, because
|
|
114
|
+
// this advisory auto-opens a modal over the board: a copy of the rule that drifts behind the
|
|
115
|
+
// engine's does not merely warn wrongly, it calls a pipeline the product SHIPS invalid and leaves
|
|
116
|
+
// the board unusable. A DEPLOYMENT-registered kind can override gatability for itself through the
|
|
117
|
+
// agent-kind registry, which the SPA cannot see, so the two are not perfectly symmetric: such a
|
|
118
|
+
// kind is reported here and accepted by the engine. That is the safe direction of the asymmetry —
|
|
119
|
+
// a dismissible advisory rather than a refused save — and the only one available without shipping
|
|
120
|
+
// the registry to the browser.
|
|
107
121
|
const gating = p.gating
|
|
108
122
|
if (gating) {
|
|
109
123
|
for (let i = 0; i < kinds.length; i++) {
|
|
110
124
|
const g = gating[i] as StepGating | null | undefined
|
|
111
125
|
if (!g?.enabled || !isEnabledAt(p, i)) continue
|
|
112
126
|
const kind = kinds[i]
|
|
113
|
-
if (!kind || !
|
|
114
|
-
return `Step '${kind}'
|
|
127
|
+
if (!kind || !isBuiltinGatableKind(kind)) {
|
|
128
|
+
return `Step '${kind}' may not be estimate-gated — its output is required by the rest of the run. Only a step whose result later steps read as context (a design, a review, an extra verification pass) may be skipped on the estimate.`
|
|
129
|
+
}
|
|
130
|
+
// A human approval gate and an estimate gate on the same step contradict: the estimate may
|
|
131
|
+
// ADD a human checkpoint but never CANCEL a pause the pipeline author asked for.
|
|
132
|
+
if (p.gates?.[i] === true) {
|
|
133
|
+
return `Step '${kind}' carries a human approval gate, so it cannot also be estimate-gated — the estimate may add a human checkpoint but never remove one.`
|
|
115
134
|
}
|
|
116
135
|
if (g.minComplexity === undefined && g.minRisk === undefined && g.minImpact === undefined) {
|
|
117
136
|
return `Step '${kind}' is estimate-gated but sets no threshold (complexity / risk / impact).`
|
|
@@ -12,7 +12,7 @@ import { UNDO_WINDOW_MS } from './context'
|
|
|
12
12
|
* in-closure functions, and the split is purely to keep every function within the size budget.
|
|
13
13
|
*/
|
|
14
14
|
export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
15
|
-
const { getBlock, upsert, api, toast, tr } = ctx
|
|
15
|
+
const { blocks, getBlock, upsert, api, toast, tr } = ctx
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Move a block into a new container at a new local position. Drag-reparent commits
|
|
@@ -120,6 +120,67 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Translate every DIRECT child of a container — the client half of the compensation the
|
|
125
|
+
* backend's `shiftChildPositions` applies. A child's position is relative to its container's
|
|
126
|
+
* content origin, so moving that origin (a north/west border drag) has to move the children
|
|
127
|
+
* the other way or the contents slide with the border. Grandchildren ride their module.
|
|
128
|
+
*/
|
|
129
|
+
function shiftChildren(parentId: string, dx: number, dy: number) {
|
|
130
|
+
if (!dx && !dy) return
|
|
131
|
+
for (const child of blocks.value) {
|
|
132
|
+
if (child.parentId !== parentId) continue
|
|
133
|
+
child.position = { x: child.position.x + dx, y: child.position.y + dy }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Local-only geometry update during an active border drag — the resize counterpart of
|
|
139
|
+
* {@link previewMove}, and for the same reason: persisting every pointer move would let an
|
|
140
|
+
* out-of-order response land a stale size after the user let go. Takes ABSOLUTE bounds and
|
|
141
|
+
* derives the origin delta itself, so a caller can drive it from a running drag without
|
|
142
|
+
* tracking what it has already applied. {@link resizeBlock} commits the final bounds once.
|
|
143
|
+
*/
|
|
144
|
+
function previewResize(
|
|
145
|
+
id: string,
|
|
146
|
+
position: { x: number; y: number },
|
|
147
|
+
size?: { w: number; h: number },
|
|
148
|
+
) {
|
|
149
|
+
const b = getBlock(id)
|
|
150
|
+
if (!b) return
|
|
151
|
+
shiftChildren(id, b.position.x - position.x, b.position.y - position.y)
|
|
152
|
+
b.position = position
|
|
153
|
+
b.size = size
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Commit a border-drag resize: ONE call carrying both halves of the geometry, because only an
|
|
158
|
+
* operation that sees the origin delta can translate the container's children with it (see
|
|
159
|
+
* `BoardService.resizeBlock`). `from` is the pre-drag geometry — a rejected resize replays it
|
|
160
|
+
* through {@link previewResize}, which undoes the child translation by the same arithmetic that
|
|
161
|
+
* applied it, so a failure can't leave the contents offset from a box the server never stored.
|
|
162
|
+
*/
|
|
163
|
+
async function resizeBlock(
|
|
164
|
+
id: string,
|
|
165
|
+
bounds: { position: { x: number; y: number }; size: { w: number; h: number } },
|
|
166
|
+
from: { position: { x: number; y: number }; size?: { w: number; h: number } },
|
|
167
|
+
) {
|
|
168
|
+
const b = getBlock(id)
|
|
169
|
+
if (!b) return
|
|
170
|
+
previewResize(id, bounds.position, bounds.size)
|
|
171
|
+
try {
|
|
172
|
+
upsert(await api.resizeBlock(useWorkspaceStore().requireId(), id, bounds))
|
|
173
|
+
} catch (e) {
|
|
174
|
+
previewResize(id, from.position, from.size)
|
|
175
|
+
toast.add({
|
|
176
|
+
title: tr('board.toast.resizeFailed'),
|
|
177
|
+
description: e instanceof Error ? e.message : String(e),
|
|
178
|
+
icon: 'i-lucide-triangle-alert',
|
|
179
|
+
color: 'error',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
123
184
|
/** Patch the user-editable fields of a block (title, features, threshold…). */
|
|
124
185
|
async function updateBlock(id: string, patch: UpdateBlockInput) {
|
|
125
186
|
const b = getBlock(id)
|
|
@@ -196,6 +257,8 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
196
257
|
reparentBlock,
|
|
197
258
|
previewMove,
|
|
198
259
|
moveBlock,
|
|
260
|
+
previewResize,
|
|
261
|
+
resizeBlock,
|
|
199
262
|
updateBlock,
|
|
200
263
|
toggleDependency,
|
|
201
264
|
removeDependency,
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -310,6 +310,57 @@ describe('board store optimistic rollback', () => {
|
|
|
310
310
|
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
+
it('previewResize translates the children when the drag moves the content origin', () => {
|
|
314
|
+
// A child's position is relative to its container's content origin, so growing the frame
|
|
315
|
+
// 40px west (origin -40) has to move every direct child +40 or the whole content slides with
|
|
316
|
+
// the border. A grandchild rides its module and must NOT move on its own.
|
|
317
|
+
const store = useBoardStore()
|
|
318
|
+
store.hydrate([
|
|
319
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
320
|
+
moduleBlock('m1', 'f1', { position: { x: 20, y: 30 } }),
|
|
321
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
322
|
+
task('t2', 'm1', { position: { x: 5, y: 5 } }),
|
|
323
|
+
])
|
|
324
|
+
store.previewResize('f1', { x: 60, y: 100 }, { w: 640, h: 400 })
|
|
325
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 50, y: 20 })
|
|
326
|
+
expect(store.getBlock('m1')?.position).toEqual({ x: 60, y: 30 })
|
|
327
|
+
expect(store.getBlock('t2')?.position).toEqual({ x: 5, y: 5 })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('previewResize leaves the children alone when only the far border moved', () => {
|
|
331
|
+
const store = useBoardStore()
|
|
332
|
+
store.hydrate([
|
|
333
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
334
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
335
|
+
])
|
|
336
|
+
store.previewResize('f1', { x: 100, y: 100 }, { w: 700, h: 500 })
|
|
337
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
|
|
338
|
+
expect(store.getBlock('f1')?.size).toEqual({ w: 700, h: 500 })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('resizeBlock rolls the bounds AND the child translation back when the API rejects', async () => {
|
|
342
|
+
// The rollback has to undo both halves: a restored box with its contents still offset is the
|
|
343
|
+
// one failure mode that looks fine until the next refresh moves everything.
|
|
344
|
+
vi.stubGlobal('useApi', () => ({
|
|
345
|
+
resizeBlock: () => Promise.reject(new Error('conflict')),
|
|
346
|
+
}))
|
|
347
|
+
setActivePinia(createPinia())
|
|
348
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
349
|
+
const store = useBoardStore()
|
|
350
|
+
store.hydrate([
|
|
351
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
352
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
353
|
+
])
|
|
354
|
+
await store.resizeBlock(
|
|
355
|
+
'f1',
|
|
356
|
+
{ position: { x: 60, y: 70 }, size: { w: 640, h: 430 } },
|
|
357
|
+
{ position: { x: 100, y: 100 }, size: { w: 600, h: 400 } },
|
|
358
|
+
)
|
|
359
|
+
expect(store.getBlock('f1')?.position).toEqual({ x: 100, y: 100 })
|
|
360
|
+
expect(store.getBlock('f1')?.size).toEqual({ w: 600, h: 400 })
|
|
361
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
|
|
362
|
+
})
|
|
363
|
+
|
|
313
364
|
it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
|
|
314
365
|
vi.stubGlobal('useApi', () => ({
|
|
315
366
|
reparentBlock: async (
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
pipelineAllowedForBlockLevel,
|
|
4
|
+
pipelineAllowedForTaskType,
|
|
5
|
+
purposeAllowsAgentCategory,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
3
7
|
import type { Block, Pipeline } from '~/types/domain'
|
|
4
8
|
import {
|
|
5
9
|
pipelineAllowedForManualStart,
|
|
10
|
+
pipelineAllowedForSchedule,
|
|
6
11
|
pipelineDisplaySteps,
|
|
7
12
|
pipelineGateCount,
|
|
8
13
|
} from '~/utils/pipeline'
|
|
@@ -65,13 +70,67 @@ describe('pipelineAllowedForTaskType', () => {
|
|
|
65
70
|
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
|
|
66
71
|
})
|
|
67
72
|
|
|
68
|
-
it('
|
|
69
|
-
|
|
73
|
+
it('a programmatic task (feature / bug) hides only what cannot ship code', () => {
|
|
74
|
+
// These ship code, so a doc-authoring or PR-review preset is meaningless for them — the mirror
|
|
75
|
+
// of the narrowing document/review tasks already had. `research` stays because reaching for a
|
|
76
|
+
// spike before committing to an approach is legitimate on an unscoped feature.
|
|
77
|
+
for (const type of ['feature', 'bug'] as const) {
|
|
70
78
|
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'build' }), type)).toBe(true)
|
|
71
|
-
expect(pipelineAllowedForTaskType(pipeline({ purpose: '
|
|
72
|
-
expect(pipelineAllowedForTaskType(pipeline({ purpose: '
|
|
79
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'research' }), type)).toBe(true)
|
|
80
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'document' }), type)).toBe(false)
|
|
81
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'review' }), type)).toBe(false)
|
|
82
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: 'planning' }), type)).toBe(false)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('keeps an UNCLASSIFIED pipeline on a feature / bug task', () => {
|
|
87
|
+
// The one place this narrowing runs opposite to the document/review one, and it has to: a
|
|
88
|
+
// `purpose` is optional at every write boundary (the builder leaves it unset by default, a
|
|
89
|
+
// registered deployment pipeline need not declare one), so requiring it here would hide a
|
|
90
|
+
// workspace's own hand-built pipelines from the picker they were built for — silently, with
|
|
91
|
+
// nothing on screen to explain the absence. Unclassified is not known-wrong for a feature the
|
|
92
|
+
// way a document preset is.
|
|
93
|
+
for (const type of ['feature', 'bug'] as const) {
|
|
73
94
|
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), type)).toBe(true)
|
|
74
95
|
}
|
|
96
|
+
// Still hidden from the types whose narrowing DOES demand the explicit classifier.
|
|
97
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'document')).toBe(false)
|
|
98
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose: undefined }), 'review')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('an un-narrowed task type stays unrestricted (spike, ralph, custom, undefined)', () => {
|
|
102
|
+
// A custom (namespaced) deployment type has no purpose mapping we could infer, and `spike` /
|
|
103
|
+
// `ralph` pin their own default pipeline instead of narrowing the picker.
|
|
104
|
+
for (const type of ['spike', 'ralph', 'acme:incident', undefined] as const) {
|
|
105
|
+
for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
|
|
106
|
+
expect(pipelineAllowedForTaskType(pipeline({ purpose }), type)).toBe(true)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('pipelineAllowedForBlockLevel (initiative binding)', () => {
|
|
113
|
+
it('offers an initiative block only planning pipelines', () => {
|
|
114
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'planning' }), 'initiative')).toBe(true)
|
|
115
|
+
for (const purpose of ['build', 'document', 'review', 'research', undefined] as const) {
|
|
116
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose }), 'initiative')).toBe(false)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('hides planning pipelines from every ordinary block level', () => {
|
|
121
|
+
// The surface half of the engine's BIDIRECTIONAL guard. Without it the planning presets were
|
|
122
|
+
// offered on ordinary tasks and then refused at start with a 409 — the user having already
|
|
123
|
+
// chosen before learning it could not run.
|
|
124
|
+
for (const level of ['task', 'frame', 'module', 'epic'] as const) {
|
|
125
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'planning' }), level)).toBe(false)
|
|
126
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose: 'build' }), level)).toBe(true)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('is unrestricted when the level is unknown', () => {
|
|
131
|
+
for (const purpose of ['build', 'planning', undefined] as const) {
|
|
132
|
+
expect(pipelineAllowedForBlockLevel(pipeline({ purpose }), undefined)).toBe(true)
|
|
133
|
+
}
|
|
75
134
|
})
|
|
76
135
|
})
|
|
77
136
|
|
|
@@ -118,3 +177,24 @@ describe('pipelineAllowedForManualStart composes the task-type gate', () => {
|
|
|
118
177
|
expect(pipelineAllowedForManualStart(recurring, noFrame, blocks, 'document')).toBe(false)
|
|
119
178
|
})
|
|
120
179
|
})
|
|
180
|
+
|
|
181
|
+
describe('pipelineAllowedForSchedule', () => {
|
|
182
|
+
const noFrame = undefined
|
|
183
|
+
const blocks: Block[] = []
|
|
184
|
+
|
|
185
|
+
it('keeps an ordinary build pipeline and drops a one-off-only one', () => {
|
|
186
|
+
expect(pipelineAllowedForSchedule(pipeline({ purpose: 'build' }), noFrame, blocks)).toBe(true)
|
|
187
|
+
const oneOff = pipeline({ purpose: 'build', availability: 'one-off' })
|
|
188
|
+
expect(pipelineAllowedForSchedule(oneOff, noFrame, blocks)).toBe(false)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('drops the planning presets, which nothing else keeps out of this picker', () => {
|
|
192
|
+
// A schedule seeds a `level: 'task'` block on every fire, so the engine refuses a planning
|
|
193
|
+
// pipeline exactly as it would on a manual start — and the planning presets carry no
|
|
194
|
+
// `availability`, so the one-off filter above never touched them. Worse than the manual case
|
|
195
|
+
// because a schedule fires unattended: nobody sees the refusal, the work just stops happening.
|
|
196
|
+
expect(pipelineAllowedForSchedule(pipeline({ purpose: 'planning' }), noFrame, blocks)).toBe(
|
|
197
|
+
false,
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
})
|
package/app/utils/pipeline.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
frameAllowsVisualPipeline,
|
|
3
|
+
pipelineAllowedForBlockLevel,
|
|
3
4
|
pipelineAllowedForTaskType,
|
|
4
5
|
pipelineHasVisualStep,
|
|
5
6
|
} from '@cat-factory/contracts'
|
|
6
|
-
import type { AgentKind, Block, Pipeline } from '~/types/domain'
|
|
7
|
+
import type { AgentKind, Block, BlockLevel, Pipeline } from '~/types/domain'
|
|
7
8
|
|
|
8
9
|
/** One agent step of a pipeline as shown in a preview: its kind + whether it's a human-gated step. */
|
|
9
10
|
export interface PipelineDisplayStep {
|
|
@@ -38,9 +39,9 @@ export function pipelineGateCount(pipeline: Pipeline): number {
|
|
|
38
39
|
return pipelineDisplaySteps(pipeline).filter((s) => s.gated).length
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
// Re-exported so a picker can import the
|
|
42
|
-
//
|
|
43
|
-
export { pipelineAllowedForTaskType }
|
|
42
|
+
// Re-exported so a picker can import the purpose gates from the same module as the launch/frame
|
|
43
|
+
// gates they compose with (the classifiers themselves live in `@cat-factory/contracts`).
|
|
44
|
+
export { pipelineAllowedForBlockLevel, pipelineAllowedForTaskType }
|
|
44
45
|
|
|
45
46
|
// Surface counterpart to the backend's slice-4c run-start gate: a pipeline with a visual step
|
|
46
47
|
// (`tester-ui` / `visual-confirmation`) may run only on a frame with a UI to exercise — a
|
|
@@ -69,32 +70,52 @@ export function pipelineAllowedForFrame(
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Whether `pipeline` may be started as a MANUAL one-off task run (the board/inspector Run menus,
|
|
72
|
-
* the add-task modal, the task run-settings default). Excludes
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* pipelines
|
|
73
|
+
* the add-task modal, the task run-settings default). Excludes, in turn:
|
|
74
|
+
*
|
|
75
|
+
* - `'recurring'`-only pipelines the backend would refuse;
|
|
76
|
+
* - visual pipelines on a frame with no UI;
|
|
77
|
+
* - pipelines whose `purpose` doesn't fit the given `taskType` (a `document` task offers only
|
|
78
|
+
* document pipelines; a `feature`/`bug` task only build + research ones);
|
|
79
|
+
* - pipelines whose `purpose` doesn't fit the given `blockLevel` (planning pipelines run only on
|
|
80
|
+
* an initiative block, and an initiative block runs only those).
|
|
81
|
+
*
|
|
82
|
+
* `taskType` / `blockLevel` omitted ⇒ that restriction is not applied, so an un-typed context still
|
|
83
|
+
* shows everything.
|
|
76
84
|
*/
|
|
77
85
|
export function pipelineAllowedForManualStart(
|
|
78
86
|
pipeline: Pipeline,
|
|
79
87
|
frame: Block | undefined,
|
|
80
88
|
blocks: readonly Block[],
|
|
81
89
|
taskType?: Block['taskType'],
|
|
90
|
+
blockLevel?: BlockLevel,
|
|
82
91
|
): boolean {
|
|
83
92
|
return (
|
|
84
93
|
pipeline.availability !== 'recurring' &&
|
|
85
94
|
pipelineAllowedForFrame(pipeline, frame, blocks) &&
|
|
86
|
-
pipelineAllowedForTaskType(pipeline, taskType)
|
|
95
|
+
pipelineAllowedForTaskType(pipeline, taskType) &&
|
|
96
|
+
pipelineAllowedForBlockLevel(pipeline, blockLevel)
|
|
87
97
|
)
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
/**
|
|
91
101
|
* Whether `pipeline` may be attached to a RECURRING schedule (the recurring-pipeline modal).
|
|
92
|
-
* Excludes `'one-off'`-only pipelines the backend would refuse
|
|
102
|
+
* Excludes `'one-off'`-only pipelines the backend would refuse, visual pipelines on a frame with no
|
|
103
|
+
* UI, and the planning presets.
|
|
104
|
+
*
|
|
105
|
+
* The block-level gate applies here for the same reason it applies to a manual start, and it is
|
|
106
|
+
* keyed to `'task'` because a schedule seeds a `level: 'task'` block under its frame on every fire
|
|
107
|
+
* (`RecurringPipelineService`). The planning presets carry no `availability`, so nothing else keeps
|
|
108
|
+
* them out of this picker — and a schedule the engine refuses is WORSE than a manual start it
|
|
109
|
+
* refuses: it fires unattended, so nobody sees the error and the work simply never happens.
|
|
93
110
|
*/
|
|
94
111
|
export function pipelineAllowedForSchedule(
|
|
95
112
|
pipeline: Pipeline,
|
|
96
113
|
frame: Block | undefined,
|
|
97
114
|
blocks: readonly Block[],
|
|
98
115
|
): boolean {
|
|
99
|
-
return
|
|
116
|
+
return (
|
|
117
|
+
pipeline.availability !== 'one-off' &&
|
|
118
|
+
pipelineAllowedForFrame(pipeline, frame, blocks) &&
|
|
119
|
+
pipelineAllowedForBlockLevel(pipeline, 'task')
|
|
120
|
+
)
|
|
100
121
|
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -2313,6 +2313,7 @@
|
|
|
2313
2313
|
"updateFailed": "Änderungen konnten nicht gespeichert werden",
|
|
2314
2314
|
"epicFailed": "Epic konnte nicht geändert werden",
|
|
2315
2315
|
"moveFailed": "Verschieben nicht möglich",
|
|
2316
|
+
"resizeFailed": "Größe konnte nicht geändert werden",
|
|
2316
2317
|
"deleteFailed": "Löschen nicht möglich",
|
|
2317
2318
|
"linkFailed": "Aufgaben konnten nicht verknüpft werden",
|
|
2318
2319
|
"unlinkFailed": "Abhängigkeit konnte nicht entfernt werden",
|
|
@@ -2566,7 +2567,6 @@
|
|
|
2566
2567
|
"mergedOfTotal": "{merged}/{total} gemergt",
|
|
2567
2568
|
"noTasksYet": "Noch keine Aufgaben",
|
|
2568
2569
|
"prCount": "{count} PR",
|
|
2569
|
-
"implemented": "{merged}/{total} implementiert",
|
|
2570
2570
|
"prReadyCount": "{count} PR bereit",
|
|
2571
2571
|
"taskCount": "{count} Aufgabe | {count} Aufgaben",
|
|
2572
2572
|
"moduleCount": "{count} Modul | {count} Module",
|
|
@@ -3864,6 +3864,12 @@
|
|
|
3864
3864
|
"recommendationProgress": "{ready} / {total} bereit",
|
|
3865
3865
|
"generatingSuggestion": "Ein fundierter Vorschlag wird generiert…",
|
|
3866
3866
|
"currentStandard": "Aktueller Standard: {title}",
|
|
3867
|
+
"grounding": {
|
|
3868
|
+
"standard": "Team-Standard",
|
|
3869
|
+
"project-spec": "Projektspezifikation",
|
|
3870
|
+
"web": "Webquelle",
|
|
3871
|
+
"general-practice": "Allgemeine Praxis"
|
|
3872
|
+
},
|
|
3867
3873
|
"accept": "Annehmen",
|
|
3868
3874
|
"reject": "Ablehnen",
|
|
3869
3875
|
"reRequestPlaceholder": "Nach einer anderen Empfehlung fragen…",
|