@cat-factory/app 0.259.3 → 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.
@@ -207,10 +207,17 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
207
207
  }
208
208
  }
209
209
 
210
- /** Patch the user-editable fields of a block (title, features, threshold…). */
211
- async function updateBlock(id: string, patch: UpdateBlockInput) {
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
 
@@ -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
- await s.updateBlock('f1', { title: 'Edited', description: 'changed' })
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
@@ -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
+ })
@@ -0,0 +1,154 @@
1
+ import type { FrameRepoType, FrontendBackendBinding, FrontendConfig } from '~/types/domain'
2
+
3
+ /**
4
+ * Planning rules for a monorepo import: turning the picked subdirectories into the frames to
5
+ * create, and the `frontendConfig` patches those frames owe once they have ids.
6
+ *
7
+ * The relationship model already exists: a `frontend` frame's `backendBindings` ARE the
8
+ * frontend→service board link (`@cat-factory/contracts`'s `frontendBackendBindingSchema`), the
9
+ * frontend counterpart of a service frame's `serviceConnections`. All this adds is declaring it
10
+ * at IMPORT time, while the user still has the whole selection in front of them, instead of
11
+ * making them open the frontend's inspector afterwards and re-pick services one row at a time.
12
+ *
13
+ * Pure and frontend-only: the modal is the sole caller, so these rules stay here rather than in
14
+ * contracts (nothing on the backend has to agree about them, since it sees ordinary frame creates
15
+ * and ordinary `frontendConfig` patches).
16
+ */
17
+
18
+ /**
19
+ * Whether the import may offer a frontend mark for the directories it is about to create.
20
+ *
21
+ * Two conditions, both about the mark meaning something:
22
+ *
23
+ * - The picked role is `service`. A backend binding may only point at a `service` frame, so
24
+ * marking a frontend among libraries or document repos would wire nothing, and when the role
25
+ * already IS `frontend`, every frame is one and singling one out says nothing.
26
+ * - At least two directories are being CREATED. The mark divides that set into "the frontend" and
27
+ * "the backends it talks to"; with one there is no rest to bind to, and the role select above
28
+ * already covers importing a lone frontend. The count is of what the add will actually create,
29
+ * never of the raw cart: a cart entry whose frame already exists (a retry after a partial
30
+ * failure) is filtered out of the import, so counting it would offer a mark that binds nothing.
31
+ */
32
+ export function canDesignateFrontend(type: FrameRepoType, directoryCount: number): boolean {
33
+ return type === 'service' && directoryCount >= 2
34
+ }
35
+
36
+ /** One frame the import will create: its repo subdirectory and the role it takes. */
37
+ export interface MonorepoImportEntry {
38
+ /** Repo-root-relative subdirectory the frame is pinned to. */
39
+ directory: string
40
+ /** The frame's repo role: `frontend` for the marked directory, the picked role for the rest. */
41
+ type: FrameRepoType
42
+ /**
43
+ * Whether this is the ONE directory marked as the frontend for the others, so its config binds
44
+ * every other frame the import creates.
45
+ *
46
+ * Stated here rather than left to be re-derived from `type`, because `type === 'frontend'` does
47
+ * NOT identify it: when the picked ROLE is `frontend`, every entry carries that type and no mark
48
+ * was ever on offer (see {@link canDesignateFrontend}), so a type test picks an arbitrary frame
49
+ * and binds it to a backend set that does not exist.
50
+ */
51
+ designatedFrontend: boolean
52
+ }
53
+
54
+ /**
55
+ * The frames to create for a monorepo import, in the order the user picked them.
56
+ *
57
+ * A `frontendDirectory` is honoured only when the mark would MEAN something, and this is the one
58
+ * place that decides. Two ways it can be stale, both of which designate nothing rather than
59
+ * promoting some other frame:
60
+ *
61
+ * - The role or the size of the import no longer admits a mark ({@link canDesignateFrontend}), so
62
+ * the picker is not even on screen. Enforced here rather than trusted to the caller, because a
63
+ * caller that re-derives the condition can drift from it.
64
+ * - The marked directory is not among the ones being created: it was removed from the cart, or an
65
+ * earlier add already created it.
66
+ */
67
+ export function planMonorepoImport(
68
+ directories: readonly string[],
69
+ backendType: FrameRepoType,
70
+ frontendDirectory: string | undefined,
71
+ ): MonorepoImportEntry[] {
72
+ const marked =
73
+ canDesignateFrontend(backendType, directories.length) && frontendDirectory !== undefined
74
+ const frontend = marked && directories.includes(frontendDirectory) ? frontendDirectory : undefined
75
+ return directories.map((directory) => ({
76
+ directory,
77
+ type: directory === frontend ? 'frontend' : backendType,
78
+ designatedFrontend: directory === frontend,
79
+ }))
80
+ }
81
+
82
+ /** A frame the import created, paired with the plan entry it was created from. */
83
+ export interface CreatedMonorepoFrame {
84
+ /** Id of the block the create call minted. */
85
+ blockId: string
86
+ /** The entry that produced it, carrying its role and whether it is the designated frontend. */
87
+ entry: MonorepoImportEntry
88
+ }
89
+
90
+ /** One `frontendConfig` write the import owes a frame it created. */
91
+ export interface FrontendConfigPatch {
92
+ blockId: string
93
+ config: FrontendConfig
94
+ }
95
+
96
+ /**
97
+ * The `frontendConfig` each created `frontend` frame needs, once the create calls have minted
98
+ * their ids.
99
+ *
100
+ * EVERY frontend frame gets one, not only a designated one, because the patch carries two
101
+ * separable facts:
102
+ *
103
+ * - **`directory`, for all of them.** A frame's monorepo subdirectory is a SERVICE-level fact (the
104
+ * repo projection's `directory`, which scopes an agent's checkout); the harness's frontend
105
+ * install/build/serve reads `frontendConfig.directory` instead, and defaults to the repo root
106
+ * when it is absent. So a monorepo frontend whose config does not repeat it builds the wrong
107
+ * tree, silently. That is true of every frontend frame an import creates, including a whole cart
108
+ * imported under the `frontend` role, where no mark is ever offered. The user picked the
109
+ * directory, so it is copied, never guessed.
110
+ * - **`backendBindings`, for the designated one only.** The mark is what says "these other frames
111
+ * are the backends this app talks to"; an undesignated frontend frame is bound to nothing, which
112
+ * is the same empty list the inspector would have shown.
113
+ */
114
+ export function planFrontendConfigPatches(
115
+ created: readonly CreatedMonorepoFrame[],
116
+ ): FrontendConfigPatch[] {
117
+ const designated = created.find((frame) => frame.entry.designatedFrontend)
118
+ const backendBlockIds = designated
119
+ ? created.filter((frame) => frame !== designated).map((frame) => frame.blockId)
120
+ : []
121
+ return created
122
+ .filter((frame) => frame.entry.type === 'frontend')
123
+ .map((frame) => ({
124
+ blockId: frame.blockId,
125
+ config: frontendConfigForImport(
126
+ frame.entry.directory,
127
+ frame === designated ? backendBlockIds : [],
128
+ ),
129
+ }))
130
+ }
131
+
132
+ /**
133
+ * The `frontendConfig` for one created frontend frame: where the app lives in the repo, and one
134
+ * backend binding per frame it was marked the frontend for.
135
+ *
136
+ * **`envVar` is left EMPTY.** The env var a frontend reads for an upstream URL is a fact about the
137
+ * frontend's own source, which an import that never looks inside the repo cannot know, and
138
+ * inventing a plausible name (`PUB_API_URL`) would inject a variable nothing reads while looking
139
+ * configured. Empty is the contract's designed inert state: the job-body builder filters those
140
+ * bindings out of the injected env, `duplicateBindingEnvVars` ignores them, `frontendOriginsForService`
141
+ * skips them, and the board still draws the frontend→service edge. What the import DOES know (which
142
+ * services this frontend talks to) is recorded; the names are left for the inspector's "Detect from
143
+ * repo" (which reads the repo's dotenv examples) or the user, and the modal says so.
144
+ */
145
+ function frontendConfigForImport(
146
+ directory: string,
147
+ backendBlockIds: readonly string[],
148
+ ): FrontendConfig {
149
+ const backendBindings: FrontendBackendBinding[] = backendBlockIds.map((serviceBlockId) => ({
150
+ envVar: '',
151
+ source: { kind: 'service', serviceBlockId },
152
+ }))
153
+ return { directory, backendBindings }
154
+ }