@cat-factory/app 0.259.1 → 0.260.1
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/README.md +34 -0
- package/app/components/board/BoardCanvas.vue +9 -1
- package/app/components/board/TaskDependencyEdges.vue +35 -26
- package/app/components/github/AddServiceFromRepoModal.vue +132 -29
- package/app/components/panels/StepToolServers.logic.ts +2 -0
- package/app/components/pipeline/BinaryOutputStepPicker.vue +59 -0
- package/app/composables/useBoardActivity.ts +111 -0
- package/app/composables/useSettlingRaf.ts +32 -0
- package/app/composables/useTaskExpansion.ts +29 -11
- package/app/stores/board/placement.ts +12 -3
- package/app/stores/board.spec.ts +22 -2
- package/app/utils/binaryOutput.spec.ts +85 -0
- package/app/utils/binaryOutput.ts +58 -2
- package/app/utils/edgeSegments.spec.ts +49 -0
- package/app/utils/edgeSegments.ts +49 -0
- package/app/utils/monorepoImport.spec.ts +120 -0
- package/app/utils/monorepoImport.ts +154 -0
- package/app/utils/settlingLoop.spec.ts +236 -0
- package/app/utils/settlingLoop.ts +101 -0
- package/i18n/locales/de.json +12 -2
- package/i18n/locales/en.json +12 -2
- package/i18n/locales/es.json +12 -2
- package/i18n/locales/fr.json +12 -2
- package/i18n/locales/he.json +12 -2
- package/i18n/locales/it.json +12 -2
- package/i18n/locales/ja.json +12 -2
- package/i18n/locales/pl.json +12 -2
- package/i18n/locales/tr.json +12 -2
- package/i18n/locales/uk.json +12 -2
- package/package.json +2 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
2
|
import { onMounted, onBeforeUnmount } from 'vue'
|
|
3
|
-
import { useRafFn } from '@vueuse/core'
|
|
4
3
|
import { lodAtLeast } from '~/composables/useSemanticZoom'
|
|
4
|
+
import { onBoardActivity, type BoardActivity } from '~/composables/useBoardActivity'
|
|
5
|
+
import { useSettlingRaf } from '~/composables/useSettlingRaf'
|
|
5
6
|
import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
|
|
6
7
|
|
|
7
8
|
function intersects(a: Rect, b: Rect) {
|
|
@@ -33,8 +34,12 @@ function sameSet(a: Set<string>, b: Set<string>) {
|
|
|
33
34
|
*
|
|
34
35
|
* Only tasks with a running pipeline (steps to show) are candidates for either grant — a
|
|
35
36
|
* task that wouldn't expand never blocks a neighbour and never lifts an empty card.
|
|
37
|
+
*
|
|
38
|
+
* Deciding costs a rect per candidate plus an `elementFromPoint`, so it runs only while the
|
|
39
|
+
* board is moving: the canvas activity pulse wakes it and `useSettlingRaf` parks it again once
|
|
40
|
+
* the two grants stop changing.
|
|
36
41
|
*/
|
|
37
|
-
export function useTaskExpansion(container: Ref<HTMLElement | null
|
|
42
|
+
export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: BoardActivity) {
|
|
38
43
|
const board = useBoardStore()
|
|
39
44
|
const execution = useExecutionStore()
|
|
40
45
|
const ui = useUiStore()
|
|
@@ -79,21 +84,29 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
79
84
|
return id
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
|
|
87
|
+
/** Re-decide both grants; reports whether either of them changed. */
|
|
88
|
+
function recompute(): boolean {
|
|
83
89
|
// Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
|
|
84
90
|
// zoom gate below — resolving it after would collapse the hovered card the moment the
|
|
85
91
|
// user zoomed back out past the `steps` band.
|
|
86
92
|
const hovered = hoveredTaskId()
|
|
87
|
-
|
|
93
|
+
let changed = false
|
|
94
|
+
if (store.hoveredId !== hovered) {
|
|
95
|
+
store.setHovered(hovered)
|
|
96
|
+
changed = true
|
|
97
|
+
}
|
|
88
98
|
|
|
89
99
|
// The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
|
|
90
100
|
// only; clear its grants otherwise. The hover grant above stands on its own.
|
|
91
101
|
if (!lodAtLeast(ui.lod, 'steps')) {
|
|
92
|
-
if (store.allowed.size)
|
|
93
|
-
|
|
102
|
+
if (store.allowed.size) {
|
|
103
|
+
store.setAllowed(new Set())
|
|
104
|
+
changed = true
|
|
105
|
+
}
|
|
106
|
+
return changed
|
|
94
107
|
}
|
|
95
108
|
const view = container.value?.getBoundingClientRect()
|
|
96
|
-
if (!view) return
|
|
109
|
+
if (!view) return changed
|
|
97
110
|
const cx = view.left + view.width / 2
|
|
98
111
|
const cy = view.top + view.height / 2
|
|
99
112
|
|
|
@@ -145,19 +158,24 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
145
158
|
next.add(c.id)
|
|
146
159
|
claimed.push(c.rect)
|
|
147
160
|
}
|
|
148
|
-
if (!sameSet(next, store.allowed))
|
|
161
|
+
if (!sameSet(next, store.allowed)) {
|
|
162
|
+
store.setAllowed(next)
|
|
163
|
+
changed = true
|
|
164
|
+
}
|
|
165
|
+
return changed
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
const {
|
|
168
|
+
const { poke } = useSettlingRaf(recompute)
|
|
169
|
+
// The pointer listeners below only record where the pointer IS; the pulse (which watches the
|
|
170
|
+
// same gestures) is what schedules the frame that acts on it.
|
|
171
|
+
onBoardActivity(activity, poke)
|
|
152
172
|
onMounted(() => {
|
|
153
173
|
store.setDriverActive(true)
|
|
154
174
|
const el = container.value
|
|
155
175
|
el?.addEventListener('pointermove', onPointerMove)
|
|
156
176
|
el?.addEventListener('pointerleave', onPointerLeave)
|
|
157
|
-
resume()
|
|
158
177
|
})
|
|
159
178
|
onBeforeUnmount(() => {
|
|
160
|
-
pause()
|
|
161
179
|
const el = container.value
|
|
162
180
|
el?.removeEventListener('pointermove', onPointerMove)
|
|
163
181
|
el?.removeEventListener('pointerleave', onPointerLeave)
|
|
@@ -207,10 +207,17 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/**
|
|
211
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Patch the user-editable fields of a block (title, features, threshold…).
|
|
212
|
+
*
|
|
213
|
+
* Returns whether the patch was PERSISTED. Both failure modes are already reported here (an
|
|
214
|
+
* unknown block is a no-op, a rejected write rolls back and toasts), so an inspector control
|
|
215
|
+
* firing and forgetting stays correct. A caller that goes on to ASSERT what the patch achieved
|
|
216
|
+
* must read it, or it announces links the rollback has just undone.
|
|
217
|
+
*/
|
|
218
|
+
async function updateBlock(id: string, patch: UpdateBlockInput): Promise<boolean> {
|
|
212
219
|
const b = getBlock(id)
|
|
213
|
-
if (!b) return
|
|
220
|
+
if (!b) return false
|
|
214
221
|
// Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
|
|
215
222
|
// (a patch may set several at once) rather than leaving a stale optimistic value stuck on
|
|
216
223
|
// screen with no feedback — the same rollback contract the other mutations here follow.
|
|
@@ -224,6 +231,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
224
231
|
Object.assign(b, patch) // optimistic
|
|
225
232
|
try {
|
|
226
233
|
upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
|
|
234
|
+
return true
|
|
227
235
|
} catch (e) {
|
|
228
236
|
// Re-resolve the block: a live event may have replaced its object reference (`upsert`
|
|
229
237
|
// swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
|
|
@@ -242,6 +250,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
242
250
|
icon: 'i-lucide-triangle-alert',
|
|
243
251
|
color: 'error',
|
|
244
252
|
})
|
|
253
|
+
return false
|
|
245
254
|
}
|
|
246
255
|
}
|
|
247
256
|
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -256,12 +256,22 @@ describe('board store read getters', () => {
|
|
|
256
256
|
s.hydrate([frame('f1', { title: 'Original', description: 'orig' })])
|
|
257
257
|
// With no active workspace, `requireId()` throws inside updateBlock's try — the same catch
|
|
258
258
|
// that a rejected API write hits — so this exercises the optimistic-rollback + toast path.
|
|
259
|
-
|
|
259
|
+
// The outcome is REPORTED to the caller, not only toasted: a caller that goes on to announce
|
|
260
|
+
// what the patch achieved (the monorepo import's frontend wiring) has to see the rollback.
|
|
261
|
+
await expect(s.updateBlock('f1', { title: 'Edited', description: 'changed' })).resolves.toBe(
|
|
262
|
+
false,
|
|
263
|
+
)
|
|
260
264
|
expect(s.getBlock('f1')?.title).toBe('Original')
|
|
261
265
|
expect(s.getBlock('f1')?.description).toBe('orig')
|
|
262
266
|
expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
|
|
263
267
|
})
|
|
264
268
|
|
|
269
|
+
it('updateBlock reports a no-op for a block that is not on the board', async () => {
|
|
270
|
+
// Nothing is patched and nothing is toasted, so the return value is the ONLY signal that the
|
|
271
|
+
// write did not happen.
|
|
272
|
+
await expect(store.updateBlock('missing', { title: 'Edited' })).resolves.toBe(false)
|
|
273
|
+
})
|
|
274
|
+
|
|
265
275
|
it('hydrate replaces and upsert inserts/updates cached blocks', () => {
|
|
266
276
|
store.hydrate([frame('f1')])
|
|
267
277
|
store.upsert(task('t1', 'f1', { title: 'first' }))
|
|
@@ -305,11 +315,21 @@ describe('board store optimistic rollback', () => {
|
|
|
305
315
|
}))
|
|
306
316
|
const store = useBoardStore()
|
|
307
317
|
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig', description: 'keep' })])
|
|
308
|
-
await store.updateBlock('t1', { title: 'renamed' })
|
|
318
|
+
await expect(store.updateBlock('t1', { title: 'renamed' })).resolves.toBe(false)
|
|
309
319
|
expect(store.getBlock('t1')?.title).toBe('orig')
|
|
310
320
|
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
311
321
|
})
|
|
312
322
|
|
|
323
|
+
it('updateBlock reports the patch persisted when the API accepts it', async () => {
|
|
324
|
+
vi.stubGlobal('useApi', () => ({
|
|
325
|
+
updateBlock: async () => task('t1', 'f1', { title: 'renamed' }),
|
|
326
|
+
}))
|
|
327
|
+
const store = useBoardStore()
|
|
328
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig' })])
|
|
329
|
+
await expect(store.updateBlock('t1', { title: 'renamed' })).resolves.toBe(true)
|
|
330
|
+
expect(store.getBlock('t1')?.title).toBe('renamed')
|
|
331
|
+
})
|
|
332
|
+
|
|
313
333
|
it('previewResize translates the children when the drag moves the content origin', () => {
|
|
314
334
|
// A child's position is relative to its container's content origin, so growing the frame
|
|
315
335
|
// 40px west (origin -40) has to move every direct child +40 or the whole content slides with
|
|
@@ -521,6 +521,91 @@ describe('binaryOutputPickIssues, generative half', () => {
|
|
|
521
521
|
expect(pick.conflictingSizeOptions).toEqual(['upscale'])
|
|
522
522
|
})
|
|
523
523
|
|
|
524
|
+
// The refusal a value axis adds over the capability one: every selected endpoint takes an
|
|
525
|
+
// aspect ratio and none of them takes THIS ratio. Stated here because the builder is where the
|
|
526
|
+
// fix is (pick a listed ratio, or select an integration that renders this one), and because the
|
|
527
|
+
// set it names is already on the snapshot the picker is holding.
|
|
528
|
+
it('names a value nothing selected accepts, and what they do accept', () => {
|
|
529
|
+
const pick = binaryOutputPickIssues(
|
|
530
|
+
{ storageServiceId: 'files', generatorIds: ['bucketed'], generation: { aspectRatio: '7:3' } },
|
|
531
|
+
catalog,
|
|
532
|
+
true,
|
|
533
|
+
[
|
|
534
|
+
{
|
|
535
|
+
id: 'bucketed',
|
|
536
|
+
modalities: ['image' as const],
|
|
537
|
+
capabilities: ['aspect-ratio' as const],
|
|
538
|
+
accepts: { aspectRatios: ['1:1', '16:9'] },
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
)
|
|
542
|
+
expect(pick.issues).toContain('option_value_unaccepted')
|
|
543
|
+
expect(pick.unacceptedValues).toEqual([
|
|
544
|
+
{ option: 'aspectRatio', requested: '7:3', accepted: ['1:1', '16:9'] },
|
|
545
|
+
])
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// ADVISORY, and the state that keeps the refusal above from firing on a working selection: one
|
|
549
|
+
// integration refuses the ratio and another has not said what it takes.
|
|
550
|
+
it('advises rather than refuses when a silent declarer might still serve the value', () => {
|
|
551
|
+
const pick = binaryOutputPickIssues(
|
|
552
|
+
{
|
|
553
|
+
storageServiceId: 'files',
|
|
554
|
+
generatorIds: ['bucketed', 'open'],
|
|
555
|
+
generation: { aspectRatio: '7:3' },
|
|
556
|
+
},
|
|
557
|
+
catalog,
|
|
558
|
+
true,
|
|
559
|
+
[
|
|
560
|
+
{
|
|
561
|
+
id: 'bucketed',
|
|
562
|
+
modalities: ['image' as const],
|
|
563
|
+
capabilities: ['aspect-ratio' as const],
|
|
564
|
+
accepts: { aspectRatios: ['1:1', '16:9'] },
|
|
565
|
+
},
|
|
566
|
+
{ id: 'open', modalities: ['image' as const], capabilities: ['aspect-ratio' as const] },
|
|
567
|
+
],
|
|
568
|
+
)
|
|
569
|
+
expect(pick.issues).toContain('option_value_unverifiable')
|
|
570
|
+
expect(pick.issues).not.toContain('option_value_unaccepted')
|
|
571
|
+
expect(pick.unverifiableValues).toEqual(['aspectRatio'])
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// ADVISORY too, and the one the reader can act on precisely: one selected endpoint takes the
|
|
575
|
+
// ratio and another has written down that it does not. Naming the second is the whole remedy,
|
|
576
|
+
// and it is the finding a first-accepting-declarer short-circuit reported as nothing at all.
|
|
577
|
+
it('names the integrations that enumerated a value away when another accepts it', () => {
|
|
578
|
+
const pick = binaryOutputPickIssues(
|
|
579
|
+
{
|
|
580
|
+
storageServiceId: 'files',
|
|
581
|
+
generatorIds: ['wide', 'bucketed'],
|
|
582
|
+
generation: { aspectRatio: '7:3' },
|
|
583
|
+
},
|
|
584
|
+
catalog,
|
|
585
|
+
true,
|
|
586
|
+
[
|
|
587
|
+
{
|
|
588
|
+
id: 'wide',
|
|
589
|
+
modalities: ['image' as const],
|
|
590
|
+
capabilities: ['aspect-ratio' as const],
|
|
591
|
+
accepts: { aspectRatios: ['7:3', '1:1'] },
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
id: 'bucketed',
|
|
595
|
+
modalities: ['image' as const],
|
|
596
|
+
capabilities: ['aspect-ratio' as const],
|
|
597
|
+
accepts: { aspectRatios: ['1:1', '16:9'] },
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
)
|
|
601
|
+
expect(pick.issues).toContain('option_value_partial')
|
|
602
|
+
expect(pick.issues).not.toContain('option_value_unaccepted')
|
|
603
|
+
expect(pick.issues).not.toContain('option_value_unverifiable')
|
|
604
|
+
expect(pick.partiallyAcceptedValues).toEqual([
|
|
605
|
+
{ option: 'aspectRatio', requested: '7:3', refusedBy: ['bucketed'] },
|
|
606
|
+
])
|
|
607
|
+
})
|
|
608
|
+
|
|
524
609
|
it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
|
|
525
610
|
// One edit should clear the step. Naming only the missing id would leave the user to
|
|
526
611
|
// discover the uncovered requirement on the next round trip.
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
binaryCapabilityCoverage,
|
|
4
4
|
binaryFormatCoverage,
|
|
5
5
|
binaryModalityOverlaps,
|
|
6
|
+
binaryValueCoverage,
|
|
6
7
|
conflictingOutputSizeOptions,
|
|
7
8
|
isBinaryModality,
|
|
8
9
|
modalityCarriesPixelDimensions,
|
|
@@ -13,6 +14,9 @@ import type {
|
|
|
13
14
|
BinaryGeneratorCapability,
|
|
14
15
|
BinaryModality,
|
|
15
16
|
BinaryModalityOverlap,
|
|
17
|
+
BinaryPartiallyAcceptedValue,
|
|
18
|
+
BinaryUnacceptedValue,
|
|
19
|
+
BinaryValueOption,
|
|
16
20
|
ConflictingOutputSizeOption,
|
|
17
21
|
RegisteredBinaryGenerator,
|
|
18
22
|
} from '@cat-factory/contracts'
|
|
@@ -532,6 +536,28 @@ export type BinaryOutputPickIssue =
|
|
|
532
536
|
* flag most working selections in the product.
|
|
533
537
|
*/
|
|
534
538
|
| 'capability_unverifiable'
|
|
539
|
+
/**
|
|
540
|
+
* A generation option every selected integration can be ASKED for and none of them accepts the
|
|
541
|
+
* step's VALUE at: a `7:3` aspect ratio against endpoints whose picklists offer ten others
|
|
542
|
+
* (kernel's `option_value_unaccepted` spelling verbatim, like the members above it). A refusal.
|
|
543
|
+
*/
|
|
544
|
+
| 'option_value_unaccepted'
|
|
545
|
+
/**
|
|
546
|
+
* A selected integration ACCEPTS the step's value and another has enumerated it away, so the
|
|
547
|
+
* step is servable by part of what it selected and the rest would quietly deliver something
|
|
548
|
+
* else. ADVISORY, and the reason is the same one that makes a capability covered when a single
|
|
549
|
+
* integration declares it: which endpoint renders which artifact is the agent's call. What is
|
|
550
|
+
* NOT optional is naming the ones that refuse it, since routing around them is the whole remedy.
|
|
551
|
+
*/
|
|
552
|
+
| 'option_value_partial'
|
|
553
|
+
/**
|
|
554
|
+
* The step's value is on no stated set, and a selected integration that declares the capability
|
|
555
|
+
* states no set at all, so it may still be served. ADVISORY, for the reason
|
|
556
|
+
* `capability_unverifiable` is, and it is deliberately silent where NOBODY states a set: that is
|
|
557
|
+
* the state every registration is in until an endpoint is audited, and a line that fired there
|
|
558
|
+
* would ride nearly every step carrying an aspect ratio.
|
|
559
|
+
*/
|
|
560
|
+
| 'option_value_unverifiable'
|
|
535
561
|
/**
|
|
536
562
|
* The step states an exact output size AND another option that restates the delivered
|
|
537
563
|
* dimensions (`aspectRatio`, `upscale`). A refusal, mirroring `assertUnambiguousOutputSize` at
|
|
@@ -567,6 +593,14 @@ export interface BinaryOutputPickState {
|
|
|
567
593
|
unsupportedCapabilities: readonly BinaryGeneratorCapability[]
|
|
568
594
|
/** The ones that could not be judged, kept apart from the refusal above. */
|
|
569
595
|
unverifiableCapabilities: readonly BinaryGeneratorCapability[]
|
|
596
|
+
/** The requested option values nothing selected accepts, each with what IS accepted, so the
|
|
597
|
+
* message names a value the reader can pick instead of only the one they cannot. */
|
|
598
|
+
unacceptedValues: readonly BinaryUnacceptedValue[]
|
|
599
|
+
/** The requested values a selected integration accepts and another enumerated away, naming the
|
|
600
|
+
* ones that refuse them, since the remedy is dropping or re-routing around those. */
|
|
601
|
+
partiallyAcceptedValues: readonly BinaryPartiallyAcceptedValue[]
|
|
602
|
+
/** The ones a silent declarer left open, kept apart from the refusal above. */
|
|
603
|
+
unverifiableValues: readonly BinaryValueOption[]
|
|
570
604
|
/** The options restating the delivered dimensions beside an exact size, for the line that names
|
|
571
605
|
* which field to delete. Computed through contracts' own rule, so this cannot come to a
|
|
572
606
|
* different answer from the save that refuses it. */
|
|
@@ -598,7 +632,7 @@ function generatorPickIssues(
|
|
|
598
632
|
config: BinaryOutputConfig | undefined,
|
|
599
633
|
generators: readonly Pick<
|
|
600
634
|
RegisteredBinaryGenerator,
|
|
601
|
-
'id' | 'modalities' | 'mediaTypes' | 'capabilities'
|
|
635
|
+
'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
|
|
602
636
|
>[],
|
|
603
637
|
unavailable: boolean,
|
|
604
638
|
): {
|
|
@@ -610,6 +644,9 @@ function generatorPickIssues(
|
|
|
610
644
|
overlaps: BinaryModalityOverlap[]
|
|
611
645
|
unsupportedCapabilities: BinaryGeneratorCapability[]
|
|
612
646
|
unverifiableCapabilities: BinaryGeneratorCapability[]
|
|
647
|
+
unacceptedValues: BinaryUnacceptedValue[]
|
|
648
|
+
partiallyAcceptedValues: BinaryPartiallyAcceptedValue[]
|
|
649
|
+
unverifiableValues: BinaryValueOption[]
|
|
613
650
|
} {
|
|
614
651
|
const none = {
|
|
615
652
|
unknownGeneratorIds: [],
|
|
@@ -619,6 +656,9 @@ function generatorPickIssues(
|
|
|
619
656
|
overlaps: [],
|
|
620
657
|
unsupportedCapabilities: [],
|
|
621
658
|
unverifiableCapabilities: [],
|
|
659
|
+
unacceptedValues: [],
|
|
660
|
+
partiallyAcceptedValues: [],
|
|
661
|
+
unverifiableValues: [],
|
|
622
662
|
}
|
|
623
663
|
if (unavailable) return { issues: ['generators_unavailable'], ...none }
|
|
624
664
|
const byId = new Map(generators.map((g) => [g.id, g]))
|
|
@@ -646,6 +686,10 @@ function generatorPickIssues(
|
|
|
646
686
|
requiredBinaryCapabilities(config?.generation),
|
|
647
687
|
selected,
|
|
648
688
|
)
|
|
689
|
+
// One notch finer: the option is supported and the VALUE is not. Imported like every rule
|
|
690
|
+
// beside it, so the line this surface shows and the refusal the backend raises are one
|
|
691
|
+
// judgement rather than two that agree until somebody edits one of them.
|
|
692
|
+
const value = binaryValueCoverage(config?.generation, selected)
|
|
649
693
|
const issues: BinaryOutputPickIssue[] = []
|
|
650
694
|
if (unknownGeneratorIds.length) issues.push('unknown_generator')
|
|
651
695
|
if (uncovered.length) issues.push('modality_uncovered')
|
|
@@ -654,6 +698,9 @@ function generatorPickIssues(
|
|
|
654
698
|
if (overlaps.length) issues.push('generator_overlap')
|
|
655
699
|
if (capability.uncovered.length) issues.push('capability_unsupported')
|
|
656
700
|
if (capability.unverifiable.length) issues.push('capability_unverifiable')
|
|
701
|
+
if (value.unaccepted.length) issues.push('option_value_unaccepted')
|
|
702
|
+
if (value.partial.length) issues.push('option_value_partial')
|
|
703
|
+
if (value.unverifiable.length) issues.push('option_value_unverifiable')
|
|
657
704
|
return {
|
|
658
705
|
issues,
|
|
659
706
|
unknownGeneratorIds,
|
|
@@ -663,6 +710,9 @@ function generatorPickIssues(
|
|
|
663
710
|
overlaps,
|
|
664
711
|
unsupportedCapabilities: capability.uncovered,
|
|
665
712
|
unverifiableCapabilities: capability.unverifiable,
|
|
713
|
+
unacceptedValues: value.unaccepted,
|
|
714
|
+
partiallyAcceptedValues: value.partial,
|
|
715
|
+
unverifiableValues: value.unverifiable,
|
|
666
716
|
}
|
|
667
717
|
}
|
|
668
718
|
|
|
@@ -699,7 +749,7 @@ export function binaryOutputPickIssues(
|
|
|
699
749
|
// stays a legitimate value rather than a hole.
|
|
700
750
|
generators: readonly Pick<
|
|
701
751
|
RegisteredBinaryGenerator,
|
|
702
|
-
'id' | 'modalities' | 'mediaTypes' | 'capabilities'
|
|
752
|
+
'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
|
|
703
753
|
>[] = [],
|
|
704
754
|
// Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
|
|
705
755
|
// default, since every deployment but a mothership-mode node reads them in-process and cannot
|
|
@@ -737,6 +787,9 @@ export function binaryOutputPickIssues(
|
|
|
737
787
|
generatorOverlaps: generative.overlaps,
|
|
738
788
|
unsupportedCapabilities: generative.unsupportedCapabilities,
|
|
739
789
|
unverifiableCapabilities: generative.unverifiableCapabilities,
|
|
790
|
+
unacceptedValues: generative.unacceptedValues,
|
|
791
|
+
partiallyAcceptedValues: generative.partiallyAcceptedValues,
|
|
792
|
+
unverifiableValues: generative.unverifiableValues,
|
|
740
793
|
conflictingSizeOptions,
|
|
741
794
|
}
|
|
742
795
|
}
|
|
@@ -764,6 +817,9 @@ export function binaryOutputPickIssues(
|
|
|
764
817
|
generatorOverlaps: generative.overlaps,
|
|
765
818
|
unsupportedCapabilities: generative.unsupportedCapabilities,
|
|
766
819
|
unverifiableCapabilities: generative.unverifiableCapabilities,
|
|
820
|
+
unacceptedValues: generative.unacceptedValues,
|
|
821
|
+
partiallyAcceptedValues: generative.partiallyAcceptedValues,
|
|
822
|
+
unverifiableValues: generative.unverifiableValues,
|
|
767
823
|
conflictingSizeOptions,
|
|
768
824
|
}
|
|
769
825
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { shallowRef } from 'vue'
|
|
3
|
+
import { commitSegments, sameSegments, type EdgeSegment } from './edgeSegments'
|
|
4
|
+
|
|
5
|
+
const line = (over: Partial<EdgeSegment> = {}): EdgeSegment => ({
|
|
6
|
+
id: 'a__b',
|
|
7
|
+
x1: 0,
|
|
8
|
+
y1: 0,
|
|
9
|
+
x2: 10,
|
|
10
|
+
y2: 10,
|
|
11
|
+
...over,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('sameSegments', () => {
|
|
15
|
+
it('accepts a freshly measured list that resolved to the same overlay', () => {
|
|
16
|
+
expect(sameSegments([line()], [line()])).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('rejects a moved endpoint, however slightly', () => {
|
|
20
|
+
expect(sameSegments([line()], [line({ y2: 10.5 })])).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('rejects a changed link set', () => {
|
|
24
|
+
expect(sameSegments([line()], [])).toBe(false)
|
|
25
|
+
expect(sameSegments([line()], [line({ id: 'a__c' })])).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('rejects a dependency whose source finished, since it restyles the arrow', () => {
|
|
29
|
+
expect(sameSegments([line({ done: false })], [line({ done: true })])).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('commitSegments', () => {
|
|
34
|
+
it('publishes a changed list and reports it', () => {
|
|
35
|
+
const target = shallowRef<EdgeSegment[]>([line()])
|
|
36
|
+
const next = [line({ x2: 20 })]
|
|
37
|
+
expect(commitSegments(target, next)).toBe(true)
|
|
38
|
+
expect(target.value).toBe(next)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('leaves the published array untouched when nothing moved', () => {
|
|
42
|
+
const published = [line()]
|
|
43
|
+
const target = shallowRef<EdgeSegment[]>(published)
|
|
44
|
+
// Identity has to survive, not just the values: reassigning an equal-but-new array is
|
|
45
|
+
// what re-rendered the whole overlay on every frame of an idle board.
|
|
46
|
+
expect(commitSegments(target, [line()])).toBe(false)
|
|
47
|
+
expect(target.value).toBe(published)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One drawable link on the board's screen-space overlay: a border-to-border line between two
|
|
5
|
+
* block cards. `done` rides only on dependency edges, where it picks the stroke and arrowhead.
|
|
6
|
+
*/
|
|
7
|
+
export type EdgeSegment = {
|
|
8
|
+
id: string
|
|
9
|
+
x1: number
|
|
10
|
+
y1: number
|
|
11
|
+
x2: number
|
|
12
|
+
y2: number
|
|
13
|
+
done?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Whether two resolved segment lists would draw exactly the same overlay. */
|
|
17
|
+
export function sameSegments(a: readonly EdgeSegment[], b: readonly EdgeSegment[]): boolean {
|
|
18
|
+
if (a.length !== b.length) return false
|
|
19
|
+
for (let i = 0; i < a.length; i++) {
|
|
20
|
+
const left = a[i]!
|
|
21
|
+
const right = b[i]!
|
|
22
|
+
if (
|
|
23
|
+
left.id !== right.id ||
|
|
24
|
+
left.x1 !== right.x1 ||
|
|
25
|
+
left.y1 !== right.y1 ||
|
|
26
|
+
left.x2 !== right.x2 ||
|
|
27
|
+
left.y2 !== right.y2 ||
|
|
28
|
+
left.done !== right.done
|
|
29
|
+
) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Publish a freshly measured list, and report whether it moved anything. Writing an
|
|
38
|
+
* equal-but-new array every frame is what re-rendered the whole overlay 60 times a second on
|
|
39
|
+
* a board where nothing was moving, and it is also the signal the settling frame loop reads
|
|
40
|
+
* to decide it can park.
|
|
41
|
+
*
|
|
42
|
+
* The target is a `shallowRef`: the lists are replaced wholesale, so deep-proxying every
|
|
43
|
+
* segment object would be pure overhead.
|
|
44
|
+
*/
|
|
45
|
+
export function commitSegments(target: Ref<EdgeSegment[]>, next: EdgeSegment[]): boolean {
|
|
46
|
+
if (sameSegments(target.value, next)) return false
|
|
47
|
+
target.value = next
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CreatedMonorepoFrame } from './monorepoImport'
|
|
3
|
+
import {
|
|
4
|
+
canDesignateFrontend,
|
|
5
|
+
planFrontendConfigPatches,
|
|
6
|
+
planMonorepoImport,
|
|
7
|
+
} from './monorepoImport'
|
|
8
|
+
|
|
9
|
+
/** Pair a plan with block ids, the way the modal does as each create call returns. */
|
|
10
|
+
function created(entries: ReturnType<typeof planMonorepoImport>): CreatedMonorepoFrame[] {
|
|
11
|
+
return entries.map((entry, i) => ({ blockId: `blk_${i}`, entry }))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('canDesignateFrontend', () => {
|
|
15
|
+
it('offers the mark for two or more backend services', () => {
|
|
16
|
+
expect(canDesignateFrontend('service', 2)).toBe(true)
|
|
17
|
+
expect(canDesignateFrontend('service', 5)).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('withholds it below two directories: there is no rest to bind to', () => {
|
|
21
|
+
expect(canDesignateFrontend('service', 1)).toBe(false)
|
|
22
|
+
expect(canDesignateFrontend('service', 0)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('withholds it for roles a backend binding cannot point at', () => {
|
|
26
|
+
// A binding's `service` source names a `service` frame; a library/document frame is not one,
|
|
27
|
+
// and when everything is already a frontend the mark divides nothing.
|
|
28
|
+
expect(canDesignateFrontend('library', 3)).toBe(false)
|
|
29
|
+
expect(canDesignateFrontend('document', 3)).toBe(false)
|
|
30
|
+
expect(canDesignateFrontend('frontend', 3)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('planMonorepoImport', () => {
|
|
35
|
+
it('creates the marked directory as a frontend and the rest with the picked role', () => {
|
|
36
|
+
expect(
|
|
37
|
+
planMonorepoImport(['apps/web', 'services/api', 'services/auth'], 'service', 'apps/web'),
|
|
38
|
+
).toEqual([
|
|
39
|
+
{ directory: 'apps/web', type: 'frontend', designatedFrontend: true },
|
|
40
|
+
{ directory: 'services/api', type: 'service', designatedFrontend: false },
|
|
41
|
+
{ directory: 'services/auth', type: 'service', designatedFrontend: false },
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('keeps the picked order, wherever the marked directory sits in it', () => {
|
|
46
|
+
const plan = planMonorepoImport(['services/api', 'apps/web'], 'service', 'apps/web')
|
|
47
|
+
expect(plan.map((e) => e.directory)).toEqual(['services/api', 'apps/web'])
|
|
48
|
+
expect(plan[1]?.type).toBe('frontend')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('gives every directory the picked role when nothing is marked', () => {
|
|
52
|
+
expect(planMonorepoImport(['a', 'b'], 'service', undefined)).toEqual([
|
|
53
|
+
{ directory: 'a', type: 'service', designatedFrontend: false },
|
|
54
|
+
{ directory: 'b', type: 'service', designatedFrontend: false },
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('ignores a mark on a directory that is not being created', () => {
|
|
59
|
+
// The cart entry was removed (or was already on the board and got filtered out) after being
|
|
60
|
+
// marked. Designating nothing is right; promoting some other frame to frontend would not be.
|
|
61
|
+
expect(planMonorepoImport(['a', 'b'], 'service', 'apps/web')).toEqual([
|
|
62
|
+
{ directory: 'a', type: 'service', designatedFrontend: false },
|
|
63
|
+
{ directory: 'b', type: 'service', designatedFrontend: false },
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('designates nobody when the whole cart is imported under the frontend role', () => {
|
|
68
|
+
// Every entry is `type: 'frontend'` here, so the flag is the ONLY thing that separates
|
|
69
|
+
// "the app the others talk to" from "a cart of frontends". A mark is never on offer for this
|
|
70
|
+
// role (`canDesignateFrontend`), and one carried over from a role change must not act.
|
|
71
|
+
const plan = planMonorepoImport(['apps/web', 'apps/admin'], 'frontend', 'apps/web')
|
|
72
|
+
expect(plan.every((e) => e.type === 'frontend')).toBe(true)
|
|
73
|
+
expect(plan.some((e) => e.designatedFrontend)).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('planFrontendConfigPatches', () => {
|
|
78
|
+
it('binds the designated frontend to every other frame created beside it', () => {
|
|
79
|
+
const plan = planMonorepoImport(
|
|
80
|
+
['apps/web', 'services/api', 'services/auth'],
|
|
81
|
+
'service',
|
|
82
|
+
'apps/web',
|
|
83
|
+
)
|
|
84
|
+
expect(planFrontendConfigPatches(created(plan))).toEqual([
|
|
85
|
+
{
|
|
86
|
+
blockId: 'blk_0',
|
|
87
|
+
config: {
|
|
88
|
+
directory: 'apps/web',
|
|
89
|
+
backendBindings: [
|
|
90
|
+
{ envVar: '', source: { kind: 'service', serviceBlockId: 'blk_1' } },
|
|
91
|
+
{ envVar: '', source: { kind: 'service', serviceBlockId: 'blk_2' } },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leaves every env var name empty rather than inventing one', () => {
|
|
99
|
+
const plan = planMonorepoImport(['apps/web', 'services/api'], 'service', 'apps/web')
|
|
100
|
+
const [patch] = planFrontendConfigPatches(created(plan))
|
|
101
|
+
expect(patch?.config.backendBindings.every((b) => b.envVar === '')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('carries the subdirectory to every frontend frame, designated or not', () => {
|
|
105
|
+
// `frontendConfig.directory` is what the harness's install/build/serve reads; the service-level
|
|
106
|
+
// directory that scopes an agent's checkout is a different field and does not stand in. A cart
|
|
107
|
+
// imported under the `frontend` role has no designated frame, and every frame in it would
|
|
108
|
+
// otherwise build the repo root.
|
|
109
|
+
const plan = planMonorepoImport(['apps/web', 'apps/admin'], 'frontend', undefined)
|
|
110
|
+
expect(planFrontendConfigPatches(created(plan))).toEqual([
|
|
111
|
+
{ blockId: 'blk_0', config: { directory: 'apps/web', backendBindings: [] } },
|
|
112
|
+
{ blockId: 'blk_1', config: { directory: 'apps/admin', backendBindings: [] } },
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('patches nothing when the import creates no frontend frame', () => {
|
|
117
|
+
const plan = planMonorepoImport(['services/api', 'services/auth'], 'service', undefined)
|
|
118
|
+
expect(planFrontendConfigPatches(created(plan))).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
})
|