@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.
- 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/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/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 +6 -1
- package/i18n/locales/en.json +6 -1
- package/i18n/locales/es.json +6 -1
- package/i18n/locales/fr.json +6 -1
- package/i18n/locales/he.json +6 -1
- package/i18n/locales/it.json +6 -1
- package/i18n/locales/ja.json +6 -1
- package/i18n/locales/pl.json +6 -1
- package/i18n/locales/tr.json +6 -1
- package/i18n/locales/uk.json +6 -1
- package/package.json +2 -2
|
@@ -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
|
|
@@ -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
|
+
}
|