@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createSettlingLoop, type FrameScheduler } from './settlingLoop'
|
|
3
|
+
|
|
4
|
+
/** A hand-driven frame clock: `flush()` runs exactly one scheduled frame. */
|
|
5
|
+
function fakeScheduler() {
|
|
6
|
+
let nextHandle = 1
|
|
7
|
+
const pending = new Map<number, () => void>()
|
|
8
|
+
const scheduler: FrameScheduler = {
|
|
9
|
+
schedule(run) {
|
|
10
|
+
const handle = nextHandle++
|
|
11
|
+
pending.set(handle, run)
|
|
12
|
+
return handle
|
|
13
|
+
},
|
|
14
|
+
cancel(handle) {
|
|
15
|
+
pending.delete(handle)
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
scheduler,
|
|
20
|
+
pending: () => pending.size,
|
|
21
|
+
/** Run every currently scheduled frame; frames they schedule wait for the next flush. */
|
|
22
|
+
flush() {
|
|
23
|
+
const due = [...pending.entries()]
|
|
24
|
+
pending.clear()
|
|
25
|
+
for (const [, run] of due) run()
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('createSettlingLoop', () => {
|
|
31
|
+
it('does not run until poked', () => {
|
|
32
|
+
const clock = fakeScheduler()
|
|
33
|
+
let frames = 0
|
|
34
|
+
const loop = createSettlingLoop({
|
|
35
|
+
compute: () => {
|
|
36
|
+
frames++
|
|
37
|
+
return false
|
|
38
|
+
},
|
|
39
|
+
scheduler: clock.scheduler,
|
|
40
|
+
settleFrames: 3,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(loop.awake()).toBe(false)
|
|
44
|
+
clock.flush()
|
|
45
|
+
expect(frames).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('keeps running while the output changes, and parks once it holds still', () => {
|
|
49
|
+
const clock = fakeScheduler()
|
|
50
|
+
let changed = true
|
|
51
|
+
let frames = 0
|
|
52
|
+
const loop = createSettlingLoop({
|
|
53
|
+
compute: () => {
|
|
54
|
+
frames++
|
|
55
|
+
return changed
|
|
56
|
+
},
|
|
57
|
+
scheduler: clock.scheduler,
|
|
58
|
+
settleFrames: 3,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
loop.poke()
|
|
62
|
+
for (let i = 0; i < 10; i++) clock.flush()
|
|
63
|
+
expect(frames).toBe(10)
|
|
64
|
+
expect(loop.awake()).toBe(true)
|
|
65
|
+
|
|
66
|
+
// The animation ends: three unchanged frames later the loop is parked and the frame
|
|
67
|
+
// count stops moving no matter how many times the clock ticks.
|
|
68
|
+
changed = false
|
|
69
|
+
clock.flush()
|
|
70
|
+
clock.flush()
|
|
71
|
+
expect(loop.awake()).toBe(true)
|
|
72
|
+
clock.flush()
|
|
73
|
+
expect(loop.awake()).toBe(false)
|
|
74
|
+
|
|
75
|
+
const settledAt = frames
|
|
76
|
+
for (let i = 0; i < 10; i++) clock.flush()
|
|
77
|
+
expect(frames).toBe(settledAt)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('runs the settle tail after a poke that changed nothing, then parks', () => {
|
|
81
|
+
const clock = fakeScheduler()
|
|
82
|
+
let frames = 0
|
|
83
|
+
const loop = createSettlingLoop({
|
|
84
|
+
compute: () => {
|
|
85
|
+
frames++
|
|
86
|
+
return false
|
|
87
|
+
},
|
|
88
|
+
scheduler: clock.scheduler,
|
|
89
|
+
settleFrames: 3,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// A signal fires one frame BEFORE the transition it starts produces geometry, so a wake
|
|
93
|
+
// that measures no change still owes the tail rather than parking immediately.
|
|
94
|
+
loop.poke()
|
|
95
|
+
clock.flush()
|
|
96
|
+
expect(loop.awake()).toBe(true)
|
|
97
|
+
clock.flush()
|
|
98
|
+
clock.flush()
|
|
99
|
+
expect(frames).toBe(3)
|
|
100
|
+
expect(loop.awake()).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('wakes a parked loop again on the next poke', () => {
|
|
104
|
+
const clock = fakeScheduler()
|
|
105
|
+
let frames = 0
|
|
106
|
+
const loop = createSettlingLoop({
|
|
107
|
+
compute: () => {
|
|
108
|
+
frames++
|
|
109
|
+
return false
|
|
110
|
+
},
|
|
111
|
+
scheduler: clock.scheduler,
|
|
112
|
+
settleFrames: 1,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
loop.poke()
|
|
116
|
+
clock.flush()
|
|
117
|
+
expect(loop.awake()).toBe(false)
|
|
118
|
+
|
|
119
|
+
loop.poke()
|
|
120
|
+
clock.flush()
|
|
121
|
+
expect(frames).toBe(2)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('restarts the countdown on a poke without scheduling a second frame', () => {
|
|
125
|
+
const clock = fakeScheduler()
|
|
126
|
+
let frames = 0
|
|
127
|
+
const loop = createSettlingLoop({
|
|
128
|
+
compute: () => {
|
|
129
|
+
frames++
|
|
130
|
+
return false
|
|
131
|
+
},
|
|
132
|
+
scheduler: clock.scheduler,
|
|
133
|
+
settleFrames: 2,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
loop.poke()
|
|
137
|
+
loop.poke()
|
|
138
|
+
loop.poke()
|
|
139
|
+
expect(clock.pending()).toBe(1)
|
|
140
|
+
clock.flush()
|
|
141
|
+
expect(frames).toBe(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('does not schedule a second frame when the compute itself pokes', () => {
|
|
145
|
+
const clock = fakeScheduler()
|
|
146
|
+
let frames = 0
|
|
147
|
+
// A compute that writes to a store can wake watchers that poke back synchronously. That
|
|
148
|
+
// must reset the countdown, not double the frame rate.
|
|
149
|
+
const loop = createSettlingLoop({
|
|
150
|
+
compute: () => {
|
|
151
|
+
frames++
|
|
152
|
+
loop.poke()
|
|
153
|
+
return false
|
|
154
|
+
},
|
|
155
|
+
scheduler: clock.scheduler,
|
|
156
|
+
settleFrames: 2,
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
loop.poke()
|
|
160
|
+
for (let i = 0; i < 5; i++) {
|
|
161
|
+
expect(clock.pending()).toBe(1)
|
|
162
|
+
clock.flush()
|
|
163
|
+
}
|
|
164
|
+
expect(frames).toBe(5)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('parks a throwing compute so a later poke still wakes it', () => {
|
|
168
|
+
const clock = fakeScheduler()
|
|
169
|
+
let frames = 0
|
|
170
|
+
let broken = true
|
|
171
|
+
const loop = createSettlingLoop({
|
|
172
|
+
compute: () => {
|
|
173
|
+
frames++
|
|
174
|
+
if (broken) throw new Error('measured a card that just unmounted')
|
|
175
|
+
return false
|
|
176
|
+
},
|
|
177
|
+
scheduler: clock.scheduler,
|
|
178
|
+
settleFrames: 3,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// The frame the throw escaped from is already spent. Staying awake would leave the loop
|
|
182
|
+
// holding a stream it can never schedule on again, and every later poke a no-op.
|
|
183
|
+
loop.poke()
|
|
184
|
+
expect(() => clock.flush()).toThrow('measured a card that just unmounted')
|
|
185
|
+
expect(loop.awake()).toBe(false)
|
|
186
|
+
expect(clock.pending()).toBe(0)
|
|
187
|
+
|
|
188
|
+
broken = false
|
|
189
|
+
loop.poke()
|
|
190
|
+
clock.flush()
|
|
191
|
+
expect(frames).toBe(2)
|
|
192
|
+
expect(loop.awake()).toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('stays parked when the compute stops the loop', () => {
|
|
196
|
+
const clock = fakeScheduler()
|
|
197
|
+
let frames = 0
|
|
198
|
+
// A compute whose store write unmounts the board runs `stop()` from inside the frame it
|
|
199
|
+
// is halfway through. The frame it was about to schedule must not resurrect it.
|
|
200
|
+
const loop = createSettlingLoop({
|
|
201
|
+
compute: () => {
|
|
202
|
+
frames++
|
|
203
|
+
loop.stop()
|
|
204
|
+
return true
|
|
205
|
+
},
|
|
206
|
+
scheduler: clock.scheduler,
|
|
207
|
+
settleFrames: 3,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
loop.poke()
|
|
211
|
+
clock.flush()
|
|
212
|
+
expect(loop.awake()).toBe(false)
|
|
213
|
+
expect(clock.pending()).toBe(0)
|
|
214
|
+
clock.flush()
|
|
215
|
+
expect(frames).toBe(1)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('drops the pending frame on stop', () => {
|
|
219
|
+
const clock = fakeScheduler()
|
|
220
|
+
let frames = 0
|
|
221
|
+
const loop = createSettlingLoop({
|
|
222
|
+
compute: () => {
|
|
223
|
+
frames++
|
|
224
|
+
return true
|
|
225
|
+
},
|
|
226
|
+
scheduler: clock.scheduler,
|
|
227
|
+
settleFrames: 3,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
loop.poke()
|
|
231
|
+
loop.stop()
|
|
232
|
+
expect(loop.awake()).toBe(false)
|
|
233
|
+
clock.flush()
|
|
234
|
+
expect(frames).toBe(0)
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A frame loop that stops itself once its output stops changing.
|
|
3
|
+
*
|
|
4
|
+
* The board's DOM-measuring drivers (dependency edges, task expansion) have to follow
|
|
5
|
+
* animations they cannot observe directly: a CSS height transition, a Vue Flow pan, a card
|
|
6
|
+
* reflowing after its text changed. Running them unconditionally every frame makes an idle
|
|
7
|
+
* board pay O(edges) forced layout reads 60 times a second; running them only on a change
|
|
8
|
+
* signal makes them stop mid-transition, because the signal fires when the transition
|
|
9
|
+
* STARTS and says nothing about the frames that follow.
|
|
10
|
+
*
|
|
11
|
+
* This resolves both: an external signal `poke()`s the loop awake, and the loop keeps
|
|
12
|
+
* running while `compute()` reports it changed something. Once the output has held still
|
|
13
|
+
* for `settleFrames` frames the animation is over and the loop parks at zero cost until the
|
|
14
|
+
* next poke.
|
|
15
|
+
*
|
|
16
|
+
* The scheduler is injected so the behaviour is testable without a browser frame clock.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** `requestAnimationFrame` / `cancelAnimationFrame`, injected so tests can drive frames by hand. */
|
|
20
|
+
export type FrameScheduler = {
|
|
21
|
+
schedule: (run: () => void) => number
|
|
22
|
+
cancel: (handle: number) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SettlingLoop = {
|
|
26
|
+
/** Wake the loop, and reset the settle countdown if it is already awake. */
|
|
27
|
+
poke: () => void
|
|
28
|
+
/** Park the loop and drop the pending frame. Idempotent. */
|
|
29
|
+
stop: () => void
|
|
30
|
+
/**
|
|
31
|
+
* Whether the loop still owns the frame stream: a frame is scheduled, or `compute()` is
|
|
32
|
+
* running right now. The two are deliberately not the same fact, and only this one decides
|
|
33
|
+
* whether a `poke()` has to schedule anything.
|
|
34
|
+
*/
|
|
35
|
+
awake: () => boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How many unchanged frames end a run. A signal fires when a style or class changes, one
|
|
40
|
+
* frame BEFORE the transition it starts produces any geometry, so parking on the first
|
|
41
|
+
* unchanged frame would miss every animation. Four frames (~66ms at 60Hz) clears that gap
|
|
42
|
+
* while keeping a false wake-up cheap.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_SETTLE_FRAMES = 4
|
|
45
|
+
|
|
46
|
+
export function createSettlingLoop(options: {
|
|
47
|
+
/** Runs one frame; returns whether it changed anything the user can see. */
|
|
48
|
+
compute: () => boolean
|
|
49
|
+
scheduler: FrameScheduler
|
|
50
|
+
settleFrames?: number
|
|
51
|
+
}): SettlingLoop {
|
|
52
|
+
const { compute, scheduler } = options
|
|
53
|
+
const settleFrames = options.settleFrames ?? DEFAULT_SETTLE_FRAMES
|
|
54
|
+
/** The scheduled frame's handle, and ONLY that: null the whole time `compute()` runs. */
|
|
55
|
+
let pending: number | null = null
|
|
56
|
+
/** Whether the loop owns the frame stream, which stays true across `compute()`. */
|
|
57
|
+
let isAwake = false
|
|
58
|
+
let unchangedFrames = 0
|
|
59
|
+
|
|
60
|
+
function frame() {
|
|
61
|
+
// This callback's own handle is spent the moment it runs, so nothing may cancel it later;
|
|
62
|
+
// `isAwake` is what carries "the loop is running" across the compute below.
|
|
63
|
+
pending = null
|
|
64
|
+
let changed: boolean
|
|
65
|
+
try {
|
|
66
|
+
changed = compute()
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Park before letting the error reach the frame callback, where the browser reports it.
|
|
69
|
+
// Staying awake with no frame scheduled would make every later `poke()` a no-op, so one
|
|
70
|
+
// throwing frame would freeze the board for the rest of the session; rescheduling would
|
|
71
|
+
// be worse still, since a compute that threw on this frame throws on the next one too
|
|
72
|
+
// and a 60Hz error storm costs more than an arrow that waits for the next pulse.
|
|
73
|
+
isAwake = false
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
// A `stop()` that ran during `compute()` (an unmount driven by the compute's own store
|
|
77
|
+
// write) parks the loop for good: it must not be resurrected by the frame below.
|
|
78
|
+
if (!isAwake) return
|
|
79
|
+
unchangedFrames = changed ? 0 : unchangedFrames + 1
|
|
80
|
+
if (unchangedFrames < settleFrames) pending = scheduler.schedule(frame)
|
|
81
|
+
else isAwake = false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
poke() {
|
|
86
|
+
unchangedFrames = 0
|
|
87
|
+
// A poke triggered by the compute's own store write (a watcher, a re-render) resets the
|
|
88
|
+
// countdown and nothing more: `isAwake` is still set, so it cannot schedule a second
|
|
89
|
+
// frame beside the one `frame()` is about to schedule itself.
|
|
90
|
+
if (isAwake) return
|
|
91
|
+
isAwake = true
|
|
92
|
+
pending = scheduler.schedule(frame)
|
|
93
|
+
},
|
|
94
|
+
stop() {
|
|
95
|
+
isAwake = false
|
|
96
|
+
if (pending !== null) scheduler.cancel(pending)
|
|
97
|
+
pending = null
|
|
98
|
+
},
|
|
99
|
+
awake: () => isAwake,
|
|
100
|
+
}
|
|
101
|
+
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -1997,6 +1997,7 @@
|
|
|
1997
1997
|
"oauthNotConnected": "war nicht verfügbar: dieses Board wurde noch nicht damit verbunden.",
|
|
1998
1998
|
"oauthTokenFailed": "war nicht verfügbar: die Verbindung liefert kein Zugriffstoken mehr.",
|
|
1999
1999
|
"overBudget": "war nicht verfügbar: dieser Agent deklariert mehr Tool-Server, als ein Lauf mitführt.",
|
|
2000
|
+
"consensusPanel": "war nicht verfügbar: dieser Schritt lief als Konsens-Panel, und ein Panel hat keine Agentenlaufzeit, an die sich ein Tool-Server anschließen ließe.",
|
|
2000
2001
|
"unknown": "war nicht verfügbar ({reason})."
|
|
2001
2002
|
},
|
|
2002
2003
|
"remedy": {
|
|
@@ -2007,7 +2008,8 @@
|
|
|
2007
2008
|
"unusableSecret": "Korrigiere die Deklaration im Code der Installation: Die Zugangsdaten eines entfernten Servers reisen in einem Header, die eines lokalen werden in den Serverprozess injiziert.",
|
|
2008
2009
|
"oauthNotConnected": "Verbinden Sie dieses Board im Infrastruktur-Fenster damit. Ein Deployment ohne ENCRYPTION_KEY hat keinen Ort für eine Berechtigung, das muss ein Betreiber also zuerst setzen.",
|
|
2009
2010
|
"oauthTokenFailed": "Verbinden Sie es im Infrastruktur-Fenster neu, oder warten Sie die Störung des Anbieters ab.",
|
|
2010
|
-
"overBudget": "Kürzen Sie, was der Agent deklariert, damit ein Lauf alles mitführen kann."
|
|
2011
|
+
"overBudget": "Kürzen Sie, was der Agent deklariert, damit ein Lauf alles mitführen kann.",
|
|
2012
|
+
"consensusPanel": "Schalten Sie Konsens für diesen Schritt ab, wenn er das Tool braucht, oder nehmen Sie in Kauf, dass das Panel ohne es urteilt. Am Server selbst muss sich nichts ändern."
|
|
2011
2013
|
},
|
|
2012
2014
|
"observed": {
|
|
2013
2015
|
"ready": "gestartet",
|
|
@@ -3401,6 +3403,9 @@
|
|
|
3401
3403
|
"monorepoBrowseHint": "Durchsuchen Sie das Repository und wählen Sie die Verzeichnisse der Services aus, die Sie hinzufügen möchten – aus jedem beliebigen Ordner. Agents, die an einem Service arbeiten, laufen innerhalb seines Unterverzeichnisses.",
|
|
3402
3404
|
"selectedServices": "Ausgewählte Services",
|
|
3403
3405
|
"noServicesSelected": "Noch keine Services ausgewählt. Wählen Sie oben Verzeichnisse aus.",
|
|
3406
|
+
"frontendLabel": "Frontend-App (optional)",
|
|
3407
|
+
"frontendHint": "Markieren Sie eines der ausgewählten Verzeichnisse als Frontend für die übrigen. Es wird als Frontend-App angelegt, auf dieses Unterverzeichnis festgelegt und mit jedem daneben hinzugefügten Backend-Service verknüpft. Die Umgebungsvariablen für die Backend-URLs benennen Sie anschließend im Inspector des Frontends.",
|
|
3408
|
+
"frontendNone": "Keines: alle Auswahlen sind Backend-Services",
|
|
3404
3409
|
"addServices": "{count} Service hinzufügen | {count} Services hinzufügen",
|
|
3405
3410
|
"removeService": "{directory} entfernen",
|
|
3406
3411
|
"addedConfigure": "{title} hinzugefügt, konfigurieren Sie es",
|
|
@@ -3414,7 +3419,9 @@
|
|
|
3414
3419
|
"addedDescription": "{title} ist auf dem Board, konfigurieren Sie es unten.",
|
|
3415
3420
|
"addFailedTitle": "Service konnte nicht hinzugefügt werden",
|
|
3416
3421
|
"servicesAddedTitle": "Services hinzugefügt",
|
|
3417
|
-
"servicesAddedDescription": "{count} Service zum Board hinzugefügt. | {count} Services zum Board hinzugefügt."
|
|
3422
|
+
"servicesAddedDescription": "{count} Service zum Board hinzugefügt. | {count} Services zum Board hinzugefügt.",
|
|
3423
|
+
"frontendLinkedNote": "{directory} wurde als Frontend hinzugefügt und mit den übrigen verknüpft. Benennen Sie die Umgebungsvariablen für die Backend-URLs im Inspector.",
|
|
3424
|
+
"frontendWiringFailedNote": "Die Frontend-Einstellungen wurden nicht gespeichert. Öffnen Sie den Inspector jeder Frontend-App, um ihr Unterverzeichnis und ihre Backend-Services zu setzen."
|
|
3418
3425
|
}
|
|
3419
3426
|
},
|
|
3420
3427
|
"repoTree": {
|
|
@@ -4363,6 +4370,9 @@
|
|
|
4363
4370
|
"binaryCapabilityUnknown": "{capability} (eine Fähigkeit, die diese Installation nicht definiert)",
|
|
4364
4371
|
"binaryCapabilityUnsupported": "Keine ausgewählte Integration unterstützt {capabilities}, was die Generierungsoptionen dieses Schritts verlangen. Entferne die Option oder wähle eine Integration, die die Fähigkeit deklariert.",
|
|
4365
4372
|
"binaryCapabilityUnverifiable": "Keine ausgewählte Integration deklariert Unterstützung für {capabilities}, aber eine von ihnen deklariert überhaupt keine Fähigkeiten, daher konnte das nicht geprüft werden. Der Schritt startet trotzdem; prüfe es in der API der Integration.",
|
|
4373
|
+
"binaryOptionValueUnaccepted": "Keine der ausgewählten Integrationen akzeptiert {option} von {requested}. Zusammen akzeptieren sie: {accepted}. Fordern Sie einen dieser Werte an oder wählen Sie eine Integration, die diesen Wert rendert.",
|
|
4374
|
+
"binaryOptionValuePartial": "{generators} akzeptiert {option} von {requested} nicht, eine andere ausgewählte Integration jedoch schon. Der Schritt startet trotzdem: Senden Sie diese Option nur an die Integrationen, die sie akzeptieren, oder entfernen Sie die übrigen.",
|
|
4375
|
+
"binaryOptionValueUnverifiable": "Eine ausgewählte Integration nennt die Werte, die sie akzeptiert, und akzeptiert nicht, was dieser Schritt verlangt ({options}); eine andere nennt gar keine, deshalb ließ sich das nicht klären. Der Schritt startet trotzdem, und nur ein Teil Ihrer Integrationen wird ihn bedienen.",
|
|
4366
4376
|
"binaryReferenceImages": "Referenzbilder",
|
|
4367
4377
|
"binaryReferenceImagesPlaceholder": "Rolle{'|'}Ort{'|'}Dienst, eine pro Zeile",
|
|
4368
4378
|
"binaryReferenceImagesUnusable": "Nicht als Referenz gespeichert: {entries}. Jede Zeile muss Rolle{'|'}Ort sein, wobei die Rolle style, subject, composition oder base ist.",
|
package/i18n/locales/en.json
CHANGED
|
@@ -1541,6 +1541,7 @@
|
|
|
1541
1541
|
"oauthNotConnected": "was not available: nobody has connected this board to it yet.",
|
|
1542
1542
|
"oauthTokenFailed": "was not available: the connection stopped producing an access token.",
|
|
1543
1543
|
"overBudget": "was not available: this agent declares more tool servers than one run carries.",
|
|
1544
|
+
"consensusPanel": "was not available: this step ran as a consensus panel, and a panel has no agent runtime to connect a tool server to.",
|
|
1544
1545
|
"unknown": "was not available ({reason})."
|
|
1545
1546
|
},
|
|
1546
1547
|
"remedy": {
|
|
@@ -1551,7 +1552,8 @@
|
|
|
1551
1552
|
"unusableSecret": "Fix the declaration in the deployment's code: a remote server's credential rides a header, and a local one is injected into the server's own process.",
|
|
1552
1553
|
"oauthNotConnected": "Connect this board to it from the Infrastructure window. A deployment with no ENCRYPTION_KEY has nowhere to keep a grant, so an operator has to set that first.",
|
|
1553
1554
|
"oauthTokenFailed": "Reconnect it from the Infrastructure window, or wait out the vendor's outage.",
|
|
1554
|
-
"overBudget": "Trim what the agent declares, so one run can carry all of it."
|
|
1555
|
+
"overBudget": "Trim what the agent declares, so one run can carry all of it.",
|
|
1556
|
+
"consensusPanel": "Turn consensus off for this step if it needs the tool, or accept that the panel judges without it. Nothing about the server has to change."
|
|
1555
1557
|
},
|
|
1556
1558
|
"observed": {
|
|
1557
1559
|
"ready": "started",
|
|
@@ -4269,6 +4271,9 @@
|
|
|
4269
4271
|
"monorepoBrowseHint": "Browse the repository and select the directories of the services you want to add — from any folder. Agents working on a service run within its subdirectory.",
|
|
4270
4272
|
"selectedServices": "Selected services",
|
|
4271
4273
|
"noServicesSelected": "No services selected yet. Pick directories above.",
|
|
4274
|
+
"frontendLabel": "Frontend app (optional)",
|
|
4275
|
+
"frontendHint": "Mark one of the selected directories as the frontend for the rest. It is added as a frontend app pinned to that subdirectory and linked to every backend service added beside it. Name each backend URL environment variable afterwards in the frontend's inspector.",
|
|
4276
|
+
"frontendNone": "None: every selection is a backend service",
|
|
4272
4277
|
"addServices": "Add {count} service | Add {count} services",
|
|
4273
4278
|
"@addServices": {
|
|
4274
4279
|
"description": "Count-driven button label; resolved via t(key, { count }, count) so {count} also drives the plural choice. Provide ALL plural forms your language needs (English has 2; Polish/Ukrainian need 3 - one/few/many - via the custom pluralRules in i18n.config.ts)."
|
|
@@ -4288,7 +4293,9 @@
|
|
|
4288
4293
|
"servicesAddedDescription": "{count} service added to the board. | {count} services added to the board.",
|
|
4289
4294
|
"@servicesAddedDescription": {
|
|
4290
4295
|
"description": "Count-driven toast; resolved via t(key, { count }, count) so {count} also drives the plural choice. Provide ALL plural forms your language needs (Polish/Ukrainian need 3 - one/few/many)."
|
|
4291
|
-
}
|
|
4296
|
+
},
|
|
4297
|
+
"frontendLinkedNote": "{directory} was added as the frontend and linked to the others. Name its backend URL environment variables in its inspector.",
|
|
4298
|
+
"frontendWiringFailedNote": "The frontend settings did not save. Open each frontend app's inspector to set its subdirectory and its backend services."
|
|
4292
4299
|
}
|
|
4293
4300
|
},
|
|
4294
4301
|
"repoTree": {
|
|
@@ -4953,6 +4960,9 @@
|
|
|
4953
4960
|
"binaryCapabilityUnknown": "{capability} (a capability this deployment does not define)",
|
|
4954
4961
|
"binaryCapabilityUnsupported": "No selected integration supports {capabilities}, which this step's generation options ask for. Remove the option, or select an integration that declares the capability.",
|
|
4955
4962
|
"binaryCapabilityUnverifiable": "No selected integration declares support for {capabilities}, but one of them declares no capabilities at all, so this could not be checked. The step still starts; confirm it from the integration’s API.",
|
|
4963
|
+
"binaryOptionValueUnaccepted": "No selected integration accepts {option} of {requested}. Between them they accept: {accepted}. Ask for one of those, or select an integration that renders this one.",
|
|
4964
|
+
"binaryOptionValuePartial": "{generators} do not accept {option} of {requested}, but another selected integration does. The step still starts: send this option only to the integrations that accept it, or drop the ones that do not.",
|
|
4965
|
+
"binaryOptionValueUnverifiable": "A selected integration states the values it accepts and does not accept what this step asks for ({options}), while another states none at all, so this could not be settled. The step still starts, and only some of your integrations will serve it.",
|
|
4956
4966
|
"binaryReferenceImages": "Reference images",
|
|
4957
4967
|
"binaryReferenceImagesPlaceholder": "role{'|'}location{'|'}service, one per line",
|
|
4958
4968
|
"binaryReferenceImagesUnusable": "Not stored as references: {entries}. Each line must be role{'|'}location, where role is style, subject, composition or base.",
|
package/i18n/locales/es.json
CHANGED
|
@@ -1450,6 +1450,7 @@
|
|
|
1450
1450
|
"oauthNotConnected": "no estuvo disponible: nadie ha conectado este tablero con él todavía.",
|
|
1451
1451
|
"oauthTokenFailed": "no estuvo disponible: la conexión dejó de producir un token de acceso.",
|
|
1452
1452
|
"overBudget": "no estuvo disponible: este agente declara más servidores de los que lleva una ejecución.",
|
|
1453
|
+
"consensusPanel": "no estuvo disponible: este paso se ejecutó como un panel de consenso, y un panel no tiene entorno de agente al que conectar un servidor de herramientas.",
|
|
1453
1454
|
"unknown": "no estuvo disponible ({reason})."
|
|
1454
1455
|
},
|
|
1455
1456
|
"remedy": {
|
|
@@ -1460,7 +1461,8 @@
|
|
|
1460
1461
|
"unusableSecret": "Corrige la declaración en el código de la instalación: la credencial de un servidor remoto viaja en una cabecera y la de uno local se inyecta en el proceso del servidor.",
|
|
1461
1462
|
"oauthNotConnected": "Conecta este tablero con él desde la ventana de Infraestructura. Un despliegue sin ENCRYPTION_KEY no tiene dónde guardar una concesión, así que un operador debe configurarla primero.",
|
|
1462
1463
|
"oauthTokenFailed": "Vuelve a conectarlo desde la ventana de Infraestructura, o espera a que pase la caída del proveedor.",
|
|
1463
|
-
"overBudget": "Recorta lo que declara el agente, para que una ejecución pueda llevarlo todo."
|
|
1464
|
+
"overBudget": "Recorta lo que declara el agente, para que una ejecución pueda llevarlo todo.",
|
|
1465
|
+
"consensusPanel": "Desactiva el consenso en este paso si necesita la herramienta, o acepta que el panel juzgue sin ella. No hay que cambiar nada del servidor."
|
|
1464
1466
|
},
|
|
1465
1467
|
"observed": {
|
|
1466
1468
|
"ready": "iniciado",
|
|
@@ -4136,6 +4138,9 @@
|
|
|
4136
4138
|
"monorepoBrowseHint": "Explora el repositorio y selecciona los directorios de los servicios que quieres añadir, de cualquier carpeta. Los agentes que trabajen en un servicio se ejecutarán dentro de su subdirectorio.",
|
|
4137
4139
|
"selectedServices": "Servicios seleccionados",
|
|
4138
4140
|
"noServicesSelected": "Aún no hay servicios seleccionados. Elige directorios arriba.",
|
|
4141
|
+
"frontendLabel": "Aplicación frontend (opcional)",
|
|
4142
|
+
"frontendHint": "Marca uno de los directorios seleccionados como el frontend de los demás. Se añade como aplicación frontend anclada a ese subdirectorio y se enlaza con cada servicio backend añadido junto a él. Después, asigna en el inspector del frontend el nombre de cada variable de entorno con la URL del backend.",
|
|
4143
|
+
"frontendNone": "Ninguno: todas las selecciones son servicios backend",
|
|
4139
4144
|
"addServices": "Añadir {count} servicio | Añadir {count} servicios",
|
|
4140
4145
|
"removeService": "Quitar {directory}",
|
|
4141
4146
|
"addedConfigure": "{title} añadido, configúralo",
|
|
@@ -4149,7 +4154,9 @@
|
|
|
4149
4154
|
"addedDescription": "{title} está en el tablero, configúralo abajo.",
|
|
4150
4155
|
"addFailedTitle": "No se pudo añadir el servicio",
|
|
4151
4156
|
"servicesAddedTitle": "Servicios añadidos",
|
|
4152
|
-
"servicesAddedDescription": "{count} servicio añadido al tablero. | {count} servicios añadidos al tablero."
|
|
4157
|
+
"servicesAddedDescription": "{count} servicio añadido al tablero. | {count} servicios añadidos al tablero.",
|
|
4158
|
+
"frontendLinkedNote": "{directory} se añadió como frontend y se enlazó con los demás. Asigna el nombre de sus variables de entorno con las URL de backend en su inspector.",
|
|
4159
|
+
"frontendWiringFailedNote": "Los ajustes del frontend no se guardaron. Abre el inspector de cada aplicación frontend para definir su subdirectorio y sus servicios backend."
|
|
4153
4160
|
},
|
|
4154
4161
|
"repoType": "Tipo de repositorio",
|
|
4155
4162
|
"repoTypeHint": "Qué es este repositorio: un servicio backend, una aplicación frontend, una biblioteca compartida o un repositorio de documentación (solo documentos/spikes)."
|
|
@@ -4802,6 +4809,9 @@
|
|
|
4802
4809
|
"binaryCapabilityUnknown": "{capability} (una capacidad que esta instalación no define)",
|
|
4803
4810
|
"binaryCapabilityUnsupported": "Ninguna integración seleccionada admite {capabilities}, que las opciones de generación de este paso solicitan. Quita la opción o selecciona una integración que declare la capacidad.",
|
|
4804
4811
|
"binaryCapabilityUnverifiable": "Ninguna integración seleccionada declara admitir {capabilities}, pero una de ellas no declara capacidad alguna, así que no se pudo comprobar. El paso arranca igualmente; confírmalo en la API de la integración.",
|
|
4812
|
+
"binaryOptionValueUnaccepted": "Ninguna integración seleccionada acepta {option} de {requested}. Entre todas aceptan: {accepted}. Pide uno de esos valores o selecciona una integración que genere este.",
|
|
4813
|
+
"binaryOptionValuePartial": "{generators} no acepta {option} de {requested}, pero otra integración seleccionada sí. El paso se inicia igualmente: envía esta opción solo a las integraciones que la aceptan, o quita las que no.",
|
|
4814
|
+
"binaryOptionValueUnverifiable": "Una integración seleccionada indica los valores que acepta y no acepta lo que pide este paso ({options}), mientras que otra no indica ninguno, así que no se pudo comprobar. El paso se inicia igualmente y solo algunas de tus integraciones lo atenderán.",
|
|
4805
4815
|
"binaryReferenceImages": "Imágenes de referencia",
|
|
4806
4816
|
"binaryReferenceImagesPlaceholder": "rol{'|'}ubicación{'|'}servicio, una por línea",
|
|
4807
4817
|
"binaryReferenceImagesUnusable": "No se guardaron como referencia: {entries}. Cada línea debe ser rol{'|'}ubicación, con el rol style, subject, composition o base.",
|