@cat-factory/app 0.79.0 → 0.79.2
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/BoardCanvas.vue +7 -1
- package/app/components/bootstrap/BootstrapModal.vue +18 -0
- package/app/components/github/AddServiceFromRepoModal.vue +6 -0
- package/app/components/panels/AgentStepDetail.vue +9 -0
- package/app/components/provisioning/ProvisioningLogsDrawer.vue +55 -7
- package/app/components/testing/TestReportWindow.vue +7 -0
- package/app/composables/useFramePlacement.ts +91 -0
- package/app/stores/board.ts +7 -1
- package/app/stores/provisioningLogs.spec.ts +95 -0
- package/app/stores/provisioningLogs.ts +18 -6
- package/app/utils/framePlacement.spec.ts +73 -0
- package/app/utils/framePlacement.ts +103 -0
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
|
|
|
10
10
|
import { useTaskExpansion } from '~/composables/useTaskExpansion'
|
|
11
11
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
12
12
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
13
|
+
import { useFramePlacement } from '~/composables/useFramePlacement'
|
|
13
14
|
import { useViewport } from '~/composables/useViewport'
|
|
14
15
|
import { boardPanMode } from '~/utils/boardPanMode'
|
|
15
16
|
|
|
@@ -24,6 +25,7 @@ const { t } = useI18n()
|
|
|
24
25
|
const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
|
|
25
26
|
const { draggingId } = useBlockDrag()
|
|
26
27
|
const { hoveredFrameId } = useFrameStacking()
|
|
28
|
+
const { freeFramePosition, focusFrame } = useFramePlacement()
|
|
27
29
|
// Touch drives the canvas gestures: a touch-capable surface needs one-finger pan.
|
|
28
30
|
// We gate on `hasTouch` (any-pointer: coarse), not `isTouch` (the *primary* pointer),
|
|
29
31
|
// so a touchscreen laptop / 2-in-1 — whose primary pointer is the trackpad — still
|
|
@@ -121,10 +123,14 @@ async function onDrop(event: DragEvent) {
|
|
|
121
123
|
if (!payload) return
|
|
122
124
|
|
|
123
125
|
if (payload.kind === 'block') {
|
|
124
|
-
|
|
126
|
+
// Drop where the cursor is, but nudge off any existing frame it lands on so a new
|
|
127
|
+
// frame never overlaps a neighbour; then centre the camera on it.
|
|
128
|
+
const dropped = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
|
|
129
|
+
const position = freeFramePosition({ near: dropped })
|
|
125
130
|
try {
|
|
126
131
|
const block = await board.addBlock(payload.blockType, position)
|
|
127
132
|
ui.select(block.id)
|
|
133
|
+
await focusFrame(block.id)
|
|
128
134
|
} catch {
|
|
129
135
|
toast.add({
|
|
130
136
|
title: t('board.canvas.addBlockFailedTitle'),
|
|
@@ -13,7 +13,9 @@ const ui = useUiStore()
|
|
|
13
13
|
const bootstrap = useBootstrapStore()
|
|
14
14
|
const agentRuns = useAgentRunsStore()
|
|
15
15
|
const github = useGitHubStore()
|
|
16
|
+
const board = useBoardStore()
|
|
16
17
|
const toast = useToast()
|
|
18
|
+
const { freeFramePosition, focusFrame } = useFramePlacement()
|
|
17
19
|
const { t } = useI18n()
|
|
18
20
|
const { confirmAction, toastDone } = useConfirmAction()
|
|
19
21
|
|
|
@@ -242,6 +244,22 @@ async function launch() {
|
|
|
242
244
|
instructions.value = ''
|
|
243
245
|
// Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
|
|
244
246
|
selectedType.value = 'service'
|
|
247
|
+
// The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
|
|
248
|
+
// free space so it never overlaps an existing service — the backend places it on a
|
|
249
|
+
// fixed diagonal stagger that can land on top of a large neighbour — then centre the
|
|
250
|
+
// camera on it. Best-effort: the run has already started, so a placement hiccup must
|
|
251
|
+
// NOT surface a bootstrap-failed toast or leave the dialog open — swallow it here
|
|
252
|
+
// rather than letting it reach the outer catch.
|
|
253
|
+
if (job.blockId && board.getBlock(job.blockId)) {
|
|
254
|
+
const id = job.blockId
|
|
255
|
+
try {
|
|
256
|
+
const position = freeFramePosition({ size: board.containerSize(id), exclude: id })
|
|
257
|
+
await board.moveBlock(id, position)
|
|
258
|
+
await focusFrame(id)
|
|
259
|
+
} catch {
|
|
260
|
+
// Placement is cosmetic; the run is tracked on the board regardless.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
245
263
|
// The run is now tracked on the board, so get out of the way: close the
|
|
246
264
|
// dialog as soon as bootstrapping has actually started.
|
|
247
265
|
ui.closeBootstrap()
|
|
@@ -28,6 +28,7 @@ const ui = useUiStore()
|
|
|
28
28
|
const github = useGitHubStore()
|
|
29
29
|
const board = useBoardStore()
|
|
30
30
|
const toast = useToast()
|
|
31
|
+
const { freeFramePosition, focusFrame } = useFramePlacement()
|
|
31
32
|
|
|
32
33
|
const open = computed({
|
|
33
34
|
get: () => ui.addServiceOpen,
|
|
@@ -218,9 +219,14 @@ async function add() {
|
|
|
218
219
|
directory: isMonorepo.value ? selectedDirectory.value : undefined,
|
|
219
220
|
isMonorepo: isMonorepo.value,
|
|
220
221
|
type: selectedType.value,
|
|
222
|
+
// Place the imported frame in free space (centred in view) instead of the
|
|
223
|
+
// backend's default stagger, so it never overlaps an existing service.
|
|
224
|
+
position: freeFramePosition(),
|
|
221
225
|
})
|
|
222
226
|
// Refresh the projection so the new repo↔block link is reflected locally.
|
|
223
227
|
await github.load()
|
|
228
|
+
// Centre the camera on the newly imported service.
|
|
229
|
+
await focusFrame(block.id)
|
|
224
230
|
configuredBlockId.value = block.id
|
|
225
231
|
configuredDirectory.value = isMonorepo.value ? selectedDirectory.value : undefined
|
|
226
232
|
toast.add({
|
|
@@ -89,6 +89,14 @@ const executionId = computed(() => instance.value?.id ?? null)
|
|
|
89
89
|
// "spinning up" phase, no spinner.
|
|
90
90
|
const runFailed = computed(() => instance.value?.status === 'failed')
|
|
91
91
|
|
|
92
|
+
// Whether the run is still doing something (can still spin infra up/down). A terminal
|
|
93
|
+
// run (`done`/`failed`) has nothing left to provision, so the infra-attempts drawer
|
|
94
|
+
// stops its background live-polling (manual refresh stays available).
|
|
95
|
+
const runLive = computed(() => {
|
|
96
|
+
const status = instance.value?.status
|
|
97
|
+
return status != null && status !== 'done' && status !== 'failed'
|
|
98
|
+
})
|
|
99
|
+
|
|
92
100
|
// Live elapsed-time clock for the open step.
|
|
93
101
|
const { isRunning, durationLabel } = useStepTimer({
|
|
94
102
|
step: () => step.value,
|
|
@@ -406,6 +414,7 @@ async function copyOutput() {
|
|
|
406
414
|
v-if="showProvisioning"
|
|
407
415
|
class="mt-2"
|
|
408
416
|
:execution-id="executionId"
|
|
417
|
+
:live="runLive"
|
|
409
418
|
/>
|
|
410
419
|
</div>
|
|
411
420
|
|
|
@@ -4,15 +4,28 @@
|
|
|
4
4
|
// container), with its outcome and — for failures — the verbatim provider/runtime
|
|
5
5
|
// error. Two modes, mutually exclusive: pass `subsystem` for the provider config
|
|
6
6
|
// panels' drawer, or `executionId` for a run's "Infrastructure attempts" drawer (which
|
|
7
|
-
// surfaces that run's container/runner/env attempts).
|
|
8
|
-
|
|
7
|
+
// surfaces that run's container/runner/env attempts).
|
|
8
|
+
//
|
|
9
|
+
// In `executionId` mode the drawer LIVE-tracks: while the run is active (`live`) it
|
|
10
|
+
// silently re-polls so each container spin-up / tear-down appears with its timestamp as
|
|
11
|
+
// it happens, and it does one final poll when the run goes terminal to catch the last
|
|
12
|
+
// tear-down row (written just before the terminal event), after which the auto-poll
|
|
13
|
+
// stops. Background polls never spin the refresh button (they're silent), but the manual
|
|
14
|
+
// refresh control stays available even once the run is terminal — so a tear-down row that
|
|
15
|
+
// was missed or not yet persisted at the terminal instant can always be refetched.
|
|
16
|
+
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
|
9
17
|
import type {
|
|
10
18
|
ProvisioningOperation,
|
|
11
19
|
ProvisioningOutcome,
|
|
12
20
|
ProvisioningSubsystem,
|
|
13
21
|
} from '~/types/provisioningLogs'
|
|
14
22
|
|
|
15
|
-
const props = defineProps<{
|
|
23
|
+
const props = defineProps<{
|
|
24
|
+
subsystem?: ProvisioningSubsystem
|
|
25
|
+
executionId?: string
|
|
26
|
+
/** Run-details mode only: whether the run is still active (drives live polling). */
|
|
27
|
+
live?: boolean
|
|
28
|
+
}>()
|
|
16
29
|
|
|
17
30
|
const { t, d } = useI18n()
|
|
18
31
|
|
|
@@ -23,12 +36,47 @@ const state = computed(() =>
|
|
|
23
36
|
: store.bySubsystem[props.subsystem ?? 'environment'],
|
|
24
37
|
)
|
|
25
38
|
|
|
26
|
-
function reload() {
|
|
27
|
-
if (props.executionId) void store.loadForExecution(props.executionId)
|
|
39
|
+
function reload(silent = false) {
|
|
40
|
+
if (props.executionId) void store.loadForExecution(props.executionId, { silent })
|
|
28
41
|
else if (props.subsystem) void store.load(props.subsystem)
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
// --- live polling (executionId mode only) --------------------------------
|
|
45
|
+
const POLL_MS = 4000
|
|
46
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
47
|
+
|
|
48
|
+
function stopPolling() {
|
|
49
|
+
if (timer) {
|
|
50
|
+
clearInterval(timer)
|
|
51
|
+
timer = undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startPolling() {
|
|
56
|
+
stopPolling()
|
|
57
|
+
timer = setInterval(() => reload(true), POLL_MS)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
watch(
|
|
61
|
+
() => props.live,
|
|
62
|
+
(live, wasLive) => {
|
|
63
|
+
if (live && props.executionId != null) {
|
|
64
|
+
startPolling()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
// Cleanup must NOT depend on `executionId` still being set: when the run's instance
|
|
68
|
+
// clears, `live` and `executionId` fall away in the same tick, so stop the interval
|
|
69
|
+
// unconditionally or it leaks (firing no-op reloads) for the component's lifetime.
|
|
70
|
+
stopPolling()
|
|
71
|
+
// On the active→terminal transition, poll once more (silently) to pick up the
|
|
72
|
+
// tear-down row the engine writes just before it emits the terminal state.
|
|
73
|
+
if (wasLive && props.executionId != null) reload(true)
|
|
74
|
+
},
|
|
75
|
+
{ immediate: true },
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
onMounted(() => reload())
|
|
79
|
+
onBeforeUnmount(stopPolling)
|
|
32
80
|
|
|
33
81
|
// Exhaustive enum→label maps of literal `t(...)` keys (keeps the typed-key drift guard
|
|
34
82
|
// live for these runtime-indexed lookups).
|
|
@@ -62,7 +110,7 @@ function when(epochMs: number): string {
|
|
|
62
110
|
variant="ghost"
|
|
63
111
|
size="xs"
|
|
64
112
|
:loading="state.loading"
|
|
65
|
-
@click="reload"
|
|
113
|
+
@click="reload()"
|
|
66
114
|
>
|
|
67
115
|
{{ t('provisioning.refresh') }}
|
|
68
116
|
</UButton>
|
|
@@ -60,6 +60,12 @@ const qualityVerdicts = computed(() => [...(quality.value?.verdicts ?? [])].reve
|
|
|
60
60
|
// run's infrastructure attempts + logs (container/runner/env spin-up), not just the
|
|
61
61
|
// report. The container/subtask signals already flow onto the step via the generic poll.
|
|
62
62
|
const runFailed = computed(() => instance.value?.status === 'failed')
|
|
63
|
+
// A terminal run (done/failed) can't spin more infra: the attempts drawer stops its
|
|
64
|
+
// background live-polling (manual refresh stays available).
|
|
65
|
+
const runLive = computed(() => {
|
|
66
|
+
const status = instance.value?.status
|
|
67
|
+
return status != null && status !== 'done' && status !== 'failed'
|
|
68
|
+
})
|
|
63
69
|
const stepEnvironment = computed(() => step.value?.environment ?? null)
|
|
64
70
|
const executionId = computed(() => instance.value?.id ?? null)
|
|
65
71
|
// The infra-attempts log drawer is opened on demand (it fetches the per-run log rows).
|
|
@@ -486,6 +492,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
486
492
|
v-if="showProvisioning"
|
|
487
493
|
class="mt-2"
|
|
488
494
|
:execution-id="executionId"
|
|
495
|
+
:live="runLive"
|
|
489
496
|
/>
|
|
490
497
|
</div>
|
|
491
498
|
</section>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { nextTick } from 'vue'
|
|
2
|
+
import { useVueFlow } from '@vue-flow/core'
|
|
3
|
+
import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
|
|
4
|
+
import {
|
|
5
|
+
EMPTY_FRAME_SIZE,
|
|
6
|
+
EPIC_NODE_SIZE,
|
|
7
|
+
findFreeFramePosition,
|
|
8
|
+
type Point,
|
|
9
|
+
type FrameRect,
|
|
10
|
+
} from '~/utils/framePlacement'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Placement + camera helpers for adding a service frame to the board, honouring the
|
|
14
|
+
* house rule: a new frame never overlaps an existing one and the camera centres on it.
|
|
15
|
+
*
|
|
16
|
+
* The board (Vue Flow) is the single source of truth for the camera, so this lives in a
|
|
17
|
+
* composable rather than the board store — every "add a frame" call site (palette drop,
|
|
18
|
+
* repo import, repo bootstrap) runs it right after the block exists.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The floor we snap the camera zoom up to when centring a new frame, so one added while
|
|
23
|
+
* the board is zoomed far out still lands legibly on screen. We only ever zoom the user
|
|
24
|
+
* *in* to this floor — a closer zoom is left alone (`Math.max`).
|
|
25
|
+
*/
|
|
26
|
+
const MIN_FOCUS_ZOOM = 0.6
|
|
27
|
+
|
|
28
|
+
export function useFramePlacement() {
|
|
29
|
+
const board = useBoardStore()
|
|
30
|
+
const { viewport, setCenter, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Every existing top-level board node's rect in flow-space — both service frames
|
|
34
|
+
* (stored position + rendered size) and epic grouping cards — so a placed frame
|
|
35
|
+
* clears epics as well as frames (both are drawn as Vue Flow nodes; see `BoardCanvas`).
|
|
36
|
+
*/
|
|
37
|
+
function existingNodeRects(exclude?: string): FrameRect[] {
|
|
38
|
+
const frames = board.frames
|
|
39
|
+
.filter((f) => f.id !== exclude)
|
|
40
|
+
.map((f) => {
|
|
41
|
+
const s = board.containerSize(f.id)
|
|
42
|
+
return { x: f.position.x, y: f.position.y, w: s.w, h: s.h }
|
|
43
|
+
})
|
|
44
|
+
const epics = board.epics
|
|
45
|
+
.filter((e) => e.id !== exclude)
|
|
46
|
+
.map((e) => ({ x: e.position.x, y: e.position.y, w: EPIC_NODE_SIZE.w, h: EPIC_NODE_SIZE.h }))
|
|
47
|
+
return [...frames, ...epics]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The flow-space top-left at which a frame of `size` would sit centred in the
|
|
52
|
+
* current view — the natural anchor for a frame added without a specific drop point
|
|
53
|
+
* (repo import / bootstrap), so it appears where the user is already looking.
|
|
54
|
+
*/
|
|
55
|
+
function viewportCenteredAnchor(size: { w: number; h: number }): Point {
|
|
56
|
+
const c = screenToFlowCoordinate({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
|
57
|
+
return { x: c.x - size.w / 2, y: c.y - size.h / 2 }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A non-overlapping top-left for a new frame. `near` is the preferred spot (a drop
|
|
62
|
+
* cursor's flow coords); omitted, the frame is anchored to the centre of the current
|
|
63
|
+
* view. `exclude` skips a frame's own rect when re-placing one that already exists
|
|
64
|
+
* (bootstrap re-homes its provisional frame).
|
|
65
|
+
*/
|
|
66
|
+
function freeFramePosition(opts?: {
|
|
67
|
+
near?: Point
|
|
68
|
+
size?: { w: number; h: number }
|
|
69
|
+
exclude?: string
|
|
70
|
+
}): Point {
|
|
71
|
+
const size = opts?.size ?? EMPTY_FRAME_SIZE
|
|
72
|
+
const desired = opts?.near ?? viewportCenteredAnchor(size)
|
|
73
|
+
return findFreeFramePosition(existingNodeRects(opts?.exclude), size, desired)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pan (and, if zoomed far out, gently zoom in) the camera to centre on a frame. */
|
|
77
|
+
async function focusFrame(id: string, opts?: { duration?: number }): Promise<void> {
|
|
78
|
+
const frame = board.getBlock(id)
|
|
79
|
+
if (!frame) return
|
|
80
|
+
const s = board.containerSize(id)
|
|
81
|
+
const cx = frame.position.x + s.w / 2
|
|
82
|
+
const cy = frame.position.y + s.h / 2
|
|
83
|
+
const zoom = Math.max(viewport.value.zoom, MIN_FOCUS_ZOOM)
|
|
84
|
+
// Let any layout the caller just triggered (e.g. the inspector opening on select)
|
|
85
|
+
// settle so the centre is computed against the real pane size.
|
|
86
|
+
await nextTick()
|
|
87
|
+
setCenter(cx, cy, { zoom, duration: opts?.duration ?? 400 })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { freeFramePosition, focusFrame }
|
|
91
|
+
}
|
package/app/stores/board.ts
CHANGED
|
@@ -90,13 +90,19 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
90
90
|
*/
|
|
91
91
|
async function addServiceFromRepo(
|
|
92
92
|
repoGithubId: number,
|
|
93
|
-
opts?: {
|
|
93
|
+
opts?: {
|
|
94
|
+
directory?: string
|
|
95
|
+
isMonorepo?: boolean
|
|
96
|
+
type?: FrameRepoType
|
|
97
|
+
position?: { x: number; y: number }
|
|
98
|
+
},
|
|
94
99
|
): Promise<Block> {
|
|
95
100
|
const block = await api.addServiceFromRepo(useWorkspaceStore().requireId(), {
|
|
96
101
|
repoGithubId,
|
|
97
102
|
...(opts?.directory ? { directory: opts.directory } : {}),
|
|
98
103
|
...(opts?.isMonorepo !== undefined ? { isMonorepo: opts.isMonorepo } : {}),
|
|
99
104
|
...(opts?.type ? { type: opts.type } : {}),
|
|
105
|
+
...(opts?.position ? { position: opts.position } : {}),
|
|
100
106
|
})
|
|
101
107
|
upsert(block)
|
|
102
108
|
return block
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useProvisioningLogsStore } from '~/stores/provisioningLogs'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { ProvisioningLogEntry } from '~/types/provisioningLogs'
|
|
5
|
+
|
|
6
|
+
/** Minimal attempt-row factory — only the fields the store passes through. */
|
|
7
|
+
function entry(over: Partial<ProvisioningLogEntry> = {}): ProvisioningLogEntry {
|
|
8
|
+
return {
|
|
9
|
+
id: 'p1',
|
|
10
|
+
workspaceId: 'ws1',
|
|
11
|
+
subsystem: 'container',
|
|
12
|
+
operation: 'dispatch',
|
|
13
|
+
outcome: 'success',
|
|
14
|
+
targetId: 'job1',
|
|
15
|
+
providerId: null,
|
|
16
|
+
blockId: null,
|
|
17
|
+
executionId: 'exec1',
|
|
18
|
+
error: null,
|
|
19
|
+
detail: null,
|
|
20
|
+
createdAt: 1,
|
|
21
|
+
...over,
|
|
22
|
+
} as ProvisioningLogEntry
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('provisioningLogs store — loadForExecution', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('a visible load flips the loading spinner and stores the entries', async () => {
|
|
31
|
+
let resolveFetch!: (r: { entries: ProvisioningLogEntry[] }) => void
|
|
32
|
+
const pending = new Promise<{ entries: ProvisioningLogEntry[] }>((res) => {
|
|
33
|
+
resolveFetch = res
|
|
34
|
+
})
|
|
35
|
+
vi.stubGlobal('useApi', () => ({ listProvisioningLogs: () => pending }))
|
|
36
|
+
|
|
37
|
+
const store = useProvisioningLogsStore()
|
|
38
|
+
const load = store.loadForExecution('exec1')
|
|
39
|
+
// In flight: the button spinner is on.
|
|
40
|
+
expect(store.byExecution.exec1!.loading).toBe(true)
|
|
41
|
+
|
|
42
|
+
resolveFetch({ entries: [entry()] })
|
|
43
|
+
await load
|
|
44
|
+
|
|
45
|
+
expect(store.byExecution.exec1!.loading).toBe(false)
|
|
46
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('a silent poll never flips the loading spinner', async () => {
|
|
50
|
+
vi.stubGlobal('useApi', () => ({
|
|
51
|
+
listProvisioningLogs: () => Promise.resolve({ entries: [entry({ operation: 'release' })] }),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const store = useProvisioningLogsStore()
|
|
55
|
+
await store.loadForExecution('exec1', { silent: true })
|
|
56
|
+
|
|
57
|
+
// Never went truthy — a background poll must not show a "refreshing" spinner.
|
|
58
|
+
expect(store.byExecution.exec1!.loading).toBe(false)
|
|
59
|
+
// But it still updates the timeline (the tear-down row now shows).
|
|
60
|
+
expect(store.byExecution.exec1!.entries[0]!.operation).toBe('release')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('a silent poll failure keeps the last-good entries and surfaces no error', async () => {
|
|
64
|
+
const store = useProvisioningLogsStore()
|
|
65
|
+
|
|
66
|
+
// Seed a good snapshot via a visible load.
|
|
67
|
+
vi.stubGlobal('useApi', () => ({
|
|
68
|
+
listProvisioningLogs: () => Promise.resolve({ entries: [entry()] }),
|
|
69
|
+
}))
|
|
70
|
+
await store.loadForExecution('exec1')
|
|
71
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
72
|
+
|
|
73
|
+
// A background poll then blips — the drawer must keep showing what it had.
|
|
74
|
+
vi.stubGlobal('useApi', () => ({
|
|
75
|
+
listProvisioningLogs: () => Promise.reject(new Error('network')),
|
|
76
|
+
}))
|
|
77
|
+
await store.loadForExecution('exec1', { silent: true })
|
|
78
|
+
|
|
79
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(1)
|
|
80
|
+
expect(store.byExecution.exec1!.error).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('a visible load failure clears entries and reports the error', async () => {
|
|
84
|
+
vi.stubGlobal('useApi', () => ({
|
|
85
|
+
listProvisioningLogs: () => Promise.reject(new Error('503')),
|
|
86
|
+
}))
|
|
87
|
+
|
|
88
|
+
const store = useProvisioningLogsStore()
|
|
89
|
+
await store.loadForExecution('exec1')
|
|
90
|
+
|
|
91
|
+
expect(store.byExecution.exec1!.loading).toBe(false)
|
|
92
|
+
expect(store.byExecution.exec1!.entries).toHaveLength(0)
|
|
93
|
+
expect(store.byExecution.exec1!.error).toBe('503')
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -47,22 +47,34 @@ export const useProvisioningLogsStore = defineStore('provisioningLogs', () => {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Load a run's provisioning attempts. `silent` is for the drawer's background poll
|
|
52
|
+
* while the run is live: it must NOT flip the `loading` spinner (it would flicker
|
|
53
|
+
* every poll) and a transient failure must NOT clear the last-good entries or surface
|
|
54
|
+
* an error banner — the visible refresh path (initial open / manual refresh) owns those.
|
|
55
|
+
*/
|
|
56
|
+
async function loadForExecution(executionId: string, opts?: { silent?: boolean }) {
|
|
51
57
|
const ws = useWorkspaceStore()
|
|
52
58
|
const s = (byExecution[executionId] ??= emptyState())
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
if (!opts?.silent) {
|
|
60
|
+
s.loading = true
|
|
61
|
+
s.error = null
|
|
62
|
+
}
|
|
55
63
|
try {
|
|
56
64
|
const { entries } = await api.listProvisioningLogs(ws.requireId(), {
|
|
57
65
|
executionId,
|
|
58
66
|
limit: 200,
|
|
59
67
|
})
|
|
60
68
|
s.entries = entries
|
|
69
|
+
s.error = null
|
|
61
70
|
} catch (err) {
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// A background poll keeps the last snapshot on a blip; only a visible load reports.
|
|
72
|
+
if (!opts?.silent) {
|
|
73
|
+
s.error = err instanceof Error ? err.message : 'Failed to load logs'
|
|
74
|
+
s.entries = []
|
|
75
|
+
}
|
|
64
76
|
} finally {
|
|
65
|
-
s.loading = false
|
|
77
|
+
if (!opts?.silent) s.loading = false
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { findFreeFramePosition, framesCollide, FRAME_GAP, type FrameRect } from './framePlacement'
|
|
3
|
+
|
|
4
|
+
const size = { w: 360, h: 220 }
|
|
5
|
+
|
|
6
|
+
describe('framesCollide', () => {
|
|
7
|
+
it('reports overlapping rects as colliding', () => {
|
|
8
|
+
const a: FrameRect = { x: 0, y: 0, w: 100, h: 100 }
|
|
9
|
+
const b: FrameRect = { x: 50, y: 50, w: 100, h: 100 }
|
|
10
|
+
expect(framesCollide(a, b)).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('treats rects separated by a clear channel as not colliding', () => {
|
|
14
|
+
const a: FrameRect = { x: 0, y: 0, w: 100, h: 100 }
|
|
15
|
+
const b: FrameRect = { x: 200, y: 0, w: 100, h: 100 }
|
|
16
|
+
expect(framesCollide(a, b)).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('honours the gap margin: touching-but-clear rects collide once a gap is required', () => {
|
|
20
|
+
const a: FrameRect = { x: 0, y: 0, w: 100, h: 100 }
|
|
21
|
+
const b: FrameRect = { x: 100, y: 0, w: 100, h: 100 } // flush against `a`
|
|
22
|
+
expect(framesCollide(a, b)).toBe(false) // no gap: exactly touching clears
|
|
23
|
+
expect(framesCollide(a, b, 1)).toBe(true) // any required gap is violated
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('findFreeFramePosition', () => {
|
|
28
|
+
it('returns the desired spot verbatim when it is already free', () => {
|
|
29
|
+
const desired = { x: 500, y: 500 }
|
|
30
|
+
expect(findFreeFramePosition([], size, desired)).toEqual(desired)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('keeps a deliberate drop that clears every existing frame', () => {
|
|
34
|
+
const existing: FrameRect[] = [{ x: 0, y: 0, ...size }]
|
|
35
|
+
const desired = { x: 900, y: 0 }
|
|
36
|
+
expect(findFreeFramePosition(existing, size, desired)).toEqual(desired)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('moves off a spot that overlaps an existing frame, and the result clears it', () => {
|
|
40
|
+
const existing: FrameRect[] = [{ x: 0, y: 0, ...size }]
|
|
41
|
+
const desired = { x: 40, y: 20 } // squarely on top of the existing frame
|
|
42
|
+
const placed = findFreeFramePosition(existing, size, desired)
|
|
43
|
+
const placedRect: FrameRect = { ...placed, ...size }
|
|
44
|
+
expect(framesCollide(placedRect, existing[0]!, FRAME_GAP)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('finds a free cell even when the desired spot is boxed in by neighbours', () => {
|
|
48
|
+
// Frames all around the origin; the desired centre cell is taken and crowded.
|
|
49
|
+
const step = size.w + FRAME_GAP
|
|
50
|
+
const existing: FrameRect[] = [
|
|
51
|
+
{ x: 0, y: 0, ...size },
|
|
52
|
+
{ x: step, y: 0, ...size },
|
|
53
|
+
{ x: -step, y: 0, ...size },
|
|
54
|
+
{ x: 0, y: size.h + FRAME_GAP, ...size },
|
|
55
|
+
]
|
|
56
|
+
const placed = findFreeFramePosition(existing, size, { x: 0, y: 0 })
|
|
57
|
+
const placedRect: FrameRect = { ...placed, ...size }
|
|
58
|
+
for (const r of existing) {
|
|
59
|
+
expect(framesCollide(placedRect, r, FRAME_GAP)).toBe(false)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('places the nearest free cell to the desired point', () => {
|
|
64
|
+
// Only the desired cell is occupied; the closest free cell is one step away.
|
|
65
|
+
const existing: FrameRect[] = [{ x: 0, y: 0, ...size }]
|
|
66
|
+
const placed = findFreeFramePosition(existing, size, { x: 0, y: 0 })
|
|
67
|
+
const step = size.w + FRAME_GAP
|
|
68
|
+
// The nearest ring cell is a single frame-step away on one axis.
|
|
69
|
+
const dist = Math.hypot(placed.x, placed.y)
|
|
70
|
+
expect(dist).toBeLessThanOrEqual(Math.hypot(step, step) + 1)
|
|
71
|
+
expect(dist).toBeGreaterThan(0)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry for placing a new service frame on the board without overlapping
|
|
3
|
+
* the ones already there. Split out from the composable that reads the live board
|
|
4
|
+
* so the placement decision is a plain, deterministically testable function.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is flow-space (the absolute `{ x, y }` a block stores), with the
|
|
7
|
+
* origin at a frame's top-left corner — the same coordinate system Vue Flow renders
|
|
8
|
+
* nodes in. Sizes are the frame's rendered pixel footprint (see
|
|
9
|
+
* {@link useBlockQueries.containerSize}).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Point {
|
|
13
|
+
x: number
|
|
14
|
+
y: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FrameRect extends Point {
|
|
18
|
+
w: number
|
|
19
|
+
h: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Spacing kept between frames so a placed frame never sits flush against a neighbour. */
|
|
23
|
+
export const FRAME_GAP = 48
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Footprint of a freshly-added, empty service frame in flow-space. Mirrors the
|
|
27
|
+
* empty-frame floor in {@link useBlockQueries.contentSize} (w 360, inner h 220) so a
|
|
28
|
+
* placement decision made BEFORE the block exists matches how it will actually render.
|
|
29
|
+
*/
|
|
30
|
+
export const EMPTY_FRAME_SIZE = { w: 360, h: 220 }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Footprint of an epic grouping node in flow-space. Epics are top-level board nodes
|
|
34
|
+
* drawn alongside frames (see `BoardCanvas`), so a placed frame must clear them too.
|
|
35
|
+
* Mirrors the compact `EpicNode` card (`w-56` = 224px, ~96px tall); a slight
|
|
36
|
+
* over-estimate is harmless — it only widens the clearance around an epic.
|
|
37
|
+
*/
|
|
38
|
+
export const EPIC_NODE_SIZE = { w: 224, h: 96 }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Do rects `a` and `b` come within `gap` px of each other — i.e. fail to clear? Two
|
|
42
|
+
* rects clear when a full `gap`-wide channel separates them on any axis; if none does,
|
|
43
|
+
* they collide.
|
|
44
|
+
*/
|
|
45
|
+
export function framesCollide(a: FrameRect, b: FrameRect, gap = 0): boolean {
|
|
46
|
+
return !(
|
|
47
|
+
a.x + a.w + gap <= b.x ||
|
|
48
|
+
b.x + b.w + gap <= a.x ||
|
|
49
|
+
a.y + a.h + gap <= b.y ||
|
|
50
|
+
b.y + b.h + gap <= a.y
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fits(candidate: FrameRect, existing: FrameRect[], gap: number): boolean {
|
|
55
|
+
return existing.every((r) => !framesCollide(candidate, r, gap))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function dist2(a: Point, b: Point): number {
|
|
59
|
+
const dx = a.x - b.x
|
|
60
|
+
const dy = a.y - b.y
|
|
61
|
+
return dx * dx + dy * dy
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find a top-left position for a new frame of `size` that clears every rect in
|
|
66
|
+
* `existing` by at least `gap`, staying as close as possible to `desired`.
|
|
67
|
+
*
|
|
68
|
+
* `desired` is used verbatim when it's already free, so a deliberate drop lands where
|
|
69
|
+
* the user aimed. Otherwise we spiral outward on a grid of frame-sized steps and take
|
|
70
|
+
* the nearest free cell. The ring search is bounded, so as a guaranteed last resort we
|
|
71
|
+
* drop the frame in a fresh column to the right of everything — a board can't have a
|
|
72
|
+
* position that never clears.
|
|
73
|
+
*/
|
|
74
|
+
export function findFreeFramePosition(
|
|
75
|
+
existing: FrameRect[],
|
|
76
|
+
size: { w: number; h: number },
|
|
77
|
+
desired: Point,
|
|
78
|
+
gap = FRAME_GAP,
|
|
79
|
+
): Point {
|
|
80
|
+
const rectAt = (p: Point): FrameRect => ({ x: p.x, y: p.y, w: size.w, h: size.h })
|
|
81
|
+
if (fits(rectAt(desired), existing, gap)) return desired
|
|
82
|
+
|
|
83
|
+
const stepX = size.w + gap
|
|
84
|
+
const stepY = size.h + gap
|
|
85
|
+
const MAX_RADIUS = 12
|
|
86
|
+
for (let radius = 1; radius <= MAX_RADIUS; radius++) {
|
|
87
|
+
// The candidates on this square ring, nearest-to-`desired` first, so the chosen
|
|
88
|
+
// free cell is the closest one at this radius (a ring is scanned whole before we
|
|
89
|
+
// widen, so overall we still take the nearest free cell on the board).
|
|
90
|
+
const ring: Point[] = []
|
|
91
|
+
for (let dy = -radius; dy <= radius; dy++) {
|
|
92
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
93
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) !== radius) continue
|
|
94
|
+
ring.push({ x: desired.x + dx * stepX, y: desired.y + dy * stepY })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
ring.sort((p, q) => dist2(p, desired) - dist2(q, desired))
|
|
98
|
+
for (const c of ring) if (fits(rectAt(c), existing, gap)) return c
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rightmost = existing.reduce((m, r) => Math.max(m, r.x + r.w), desired.x)
|
|
102
|
+
return { x: rightmost + gap, y: desired.y }
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.79.
|
|
3
|
+
"version": "0.79.2",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|