plugin-build-visualization-block 0.1.4 → 0.1.5
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/dist/client/index.js +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +6 -6
- package/dist/server/actions/build.js +22 -37
- package/package.json +1 -1
- package/src/server/actions/build.ts +1278 -1312
|
@@ -1,1318 +1,1284 @@
|
|
|
1
|
-
import { Context, Next } from '@nocobase/actions';
|
|
2
|
-
import type { Repository } from '@nocobase/database';
|
|
3
|
-
import type { Application } from '@nocobase/server';
|
|
4
|
-
import crypto from 'crypto';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
BUILD_TIMEOUT_MS,
|
|
8
|
-
COLLECTION_NAME,
|
|
9
|
-
MAX_COLLECTIONS,
|
|
10
|
-
MAX_REQUIREMENT_CHARS,
|
|
11
|
-
MIN_COLLECTIONS,
|
|
12
|
-
PLUGIN_NAME,
|
|
13
|
-
} from '../../shared/constants';
|
|
14
|
-
import { introspect } from '../pipeline/introspector';
|
|
15
|
-
import { analyze } from '../pipeline/analyzer';
|
|
16
|
-
import { validate } from '../pipeline/validator';
|
|
17
|
-
import { generate } from '../pipeline/generator';
|
|
18
|
-
import { buildFallbackSpec } from '../pipeline/fallback';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The raw, untrusted shape of `ctx.action.params.values` for the build action.
|
|
22
|
-
* Every field is typed as `unknown` because it arrives from the client and must
|
|
23
|
-
* be narrowed/validated before use.
|
|
24
|
-
*/
|
|
25
|
-
interface BuildActionParamsValues {
|
|
26
|
-
requirement?: unknown;
|
|
27
|
-
collections?: unknown;
|
|
28
|
-
dataSource?: unknown;
|
|
29
|
-
llmService?: unknown;
|
|
30
|
-
model?: unknown;
|
|
31
|
-
primaryCollection?: unknown;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The validated build input, produced after {@link build} narrows and checks
|
|
36
|
-
* the raw `ctx.action.params.values`.
|
|
37
|
-
*/
|
|
38
|
-
interface ValidatedBuildInput {
|
|
39
|
-
requirement: string;
|
|
40
|
-
collections: string[];
|
|
41
|
-
dataSource: string;
|
|
42
|
-
primaryCollection: string;
|
|
43
|
-
llmService: string;
|
|
44
|
-
model: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The message handed to the build queue. It is the seam between the `build`
|
|
49
|
-
* action (which creates the record and enqueues) and the worker (which claims
|
|
50
|
-
* the run and executes the pipeline).
|
|
51
|
-
*/
|
|
52
|
-
export interface BuildQueueMessage {
|
|
53
|
-
/** Primary key of the {@link COLLECTION_NAME} record to build. */
|
|
54
|
-
buildId: string;
|
|
55
|
-
/** Current run identity, used by the worker as a stale-run guard. */
|
|
56
|
-
runId: string;
|
|
57
|
-
/** The user that initiated the build, or `null` for anonymous/system runs. */
|
|
58
|
-
userId: string | number | null;
|
|
59
|
-
/** ISO timestamp recording when the run was queued. */
|
|
60
|
-
queuedAt: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The minimal run identity the worker pipeline operates on: a record id plus the
|
|
65
|
-
* run that owns it. Every persisted write is gated on `runId` so a superseded
|
|
66
|
-
* run (after a retry/regenerate) can never clobber a newer run's record.
|
|
67
|
-
*/
|
|
68
|
-
interface BuildRunContext {
|
|
69
|
-
buildId: string;
|
|
70
|
-
runId: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/* --------------------------------------------------------------------------
|
|
74
|
-
* Queue constants — all channel/key/connection names are rescoped to
|
|
75
|
-
* `plugin-build-visualization-block.build*` (Req 10.2/10.3/10.4/10.7).
|
|
76
|
-
* ----------------------------------------------------------------------- */
|
|
77
|
-
|
|
78
|
-
/** The event-queue job name a worker node serves for this plugin's builds. */
|
|
79
|
-
export const WORKER_JOB_BUILD_VISUALIZATION_PROCESS = 'build-visualization:process';
|
|
80
|
-
|
|
81
|
-
/** The in-process event-queue channel builds are published to. */
|
|
82
|
-
const BUILD_VISUALIZATION_QUEUE_CHANNEL = 'plugin-build-visualization-block.build';
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
*
|
|
169
|
-
* `
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
function
|
|
252
|
-
const workerMode = process.env.WORKER_MODE || '';
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
1
|
+
import { Context, Next } from '@nocobase/actions';
|
|
2
|
+
import type { Repository } from '@nocobase/database';
|
|
3
|
+
import type { Application } from '@nocobase/server';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
BUILD_TIMEOUT_MS,
|
|
8
|
+
COLLECTION_NAME,
|
|
9
|
+
MAX_COLLECTIONS,
|
|
10
|
+
MAX_REQUIREMENT_CHARS,
|
|
11
|
+
MIN_COLLECTIONS,
|
|
12
|
+
PLUGIN_NAME,
|
|
13
|
+
} from '../../shared/constants';
|
|
14
|
+
import { introspect } from '../pipeline/introspector';
|
|
15
|
+
import { analyze } from '../pipeline/analyzer';
|
|
16
|
+
import { validate } from '../pipeline/validator';
|
|
17
|
+
import { generate } from '../pipeline/generator';
|
|
18
|
+
import { buildFallbackSpec } from '../pipeline/fallback';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The raw, untrusted shape of `ctx.action.params.values` for the build action.
|
|
22
|
+
* Every field is typed as `unknown` because it arrives from the client and must
|
|
23
|
+
* be narrowed/validated before use.
|
|
24
|
+
*/
|
|
25
|
+
interface BuildActionParamsValues {
|
|
26
|
+
requirement?: unknown;
|
|
27
|
+
collections?: unknown;
|
|
28
|
+
dataSource?: unknown;
|
|
29
|
+
llmService?: unknown;
|
|
30
|
+
model?: unknown;
|
|
31
|
+
primaryCollection?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The validated build input, produced after {@link build} narrows and checks
|
|
36
|
+
* the raw `ctx.action.params.values`.
|
|
37
|
+
*/
|
|
38
|
+
interface ValidatedBuildInput {
|
|
39
|
+
requirement: string;
|
|
40
|
+
collections: string[];
|
|
41
|
+
dataSource: string;
|
|
42
|
+
primaryCollection: string;
|
|
43
|
+
llmService: string;
|
|
44
|
+
model: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The message handed to the build queue. It is the seam between the `build`
|
|
49
|
+
* action (which creates the record and enqueues) and the worker (which claims
|
|
50
|
+
* the run and executes the pipeline).
|
|
51
|
+
*/
|
|
52
|
+
export interface BuildQueueMessage {
|
|
53
|
+
/** Primary key of the {@link COLLECTION_NAME} record to build. */
|
|
54
|
+
buildId: string;
|
|
55
|
+
/** Current run identity, used by the worker as a stale-run guard. */
|
|
56
|
+
runId: string;
|
|
57
|
+
/** The user that initiated the build, or `null` for anonymous/system runs. */
|
|
58
|
+
userId: string | number | null;
|
|
59
|
+
/** ISO timestamp recording when the run was queued. */
|
|
60
|
+
queuedAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The minimal run identity the worker pipeline operates on: a record id plus the
|
|
65
|
+
* run that owns it. Every persisted write is gated on `runId` so a superseded
|
|
66
|
+
* run (after a retry/regenerate) can never clobber a newer run's record.
|
|
67
|
+
*/
|
|
68
|
+
interface BuildRunContext {
|
|
69
|
+
buildId: string;
|
|
70
|
+
runId: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* --------------------------------------------------------------------------
|
|
74
|
+
* Queue constants — all channel/key/connection names are rescoped to
|
|
75
|
+
* `plugin-build-visualization-block.build*` (Req 10.2/10.3/10.4/10.7).
|
|
76
|
+
* ----------------------------------------------------------------------- */
|
|
77
|
+
|
|
78
|
+
/** The event-queue job name a worker node serves for this plugin's builds. */
|
|
79
|
+
export const WORKER_JOB_BUILD_VISUALIZATION_PROCESS = 'build-visualization:process';
|
|
80
|
+
|
|
81
|
+
/** The in-process event-queue channel builds are published to. */
|
|
82
|
+
const BUILD_VISUALIZATION_QUEUE_CHANNEL = 'plugin-build-visualization-block.build';
|
|
83
|
+
const BUILD_VISUALIZATION_WORKER_ALIASES = [
|
|
84
|
+
BUILD_VISUALIZATION_QUEUE_CHANNEL,
|
|
85
|
+
'plugin-build-visualization-block:build:queue',
|
|
86
|
+
];
|
|
87
|
+
/** The pub/sub channel used to wake idle worker pollers when a build arrives. */
|
|
88
|
+
const BUILD_VISUALIZATION_QUEUE_WAKE_CHANNEL = 'plugin-build-visualization-block.build.wake';
|
|
89
|
+
/** The named Redis connection used for the cross-node build queue. */
|
|
90
|
+
const BUILD_VISUALIZATION_QUEUE_REDIS_CONNECTION = 'plugin-build-visualization-block.build.queue';
|
|
91
|
+
|
|
92
|
+
/** How many builds a worker processes concurrently. */
|
|
93
|
+
const BUILD_VISUALIZATION_QUEUE_CONCURRENCY = Math.max(
|
|
94
|
+
1,
|
|
95
|
+
Number.parseInt(
|
|
96
|
+
process.env.BUILD_VISUALIZATION_QUEUE_CONCURRENCY || process.env.BUILD_VISUALIZATION_MAX_CONCURRENCY || '1',
|
|
97
|
+
10,
|
|
98
|
+
) || 1,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The per-message processing timeout. Defaults to the shared
|
|
103
|
+
* {@link BUILD_TIMEOUT_MS} (30 min, Req 10.7) but can be widened (never below
|
|
104
|
+
* it) via `BUILD_VISUALIZATION_QUEUE_TIMEOUT_MS`.
|
|
105
|
+
*/
|
|
106
|
+
const BUILD_VISUALIZATION_QUEUE_TIMEOUT_MS = Math.max(
|
|
107
|
+
BUILD_TIMEOUT_MS,
|
|
108
|
+
Number.parseInt(process.env.BUILD_VISUALIZATION_QUEUE_TIMEOUT_MS || '', 10) || BUILD_TIMEOUT_MS,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/** How often the worker re-polls the DB for queued builds (Redis-free path). */
|
|
112
|
+
const BUILD_VISUALIZATION_QUEUE_POLL_INTERVAL_MS = Math.max(
|
|
113
|
+
1000,
|
|
114
|
+
Number.parseInt(process.env.BUILD_VISUALIZATION_QUEUE_POLL_INTERVAL_MS || '', 10) || 5000,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/** TTL for the per-record run lock that serializes a single record's claim. */
|
|
118
|
+
const BUILD_RUN_LOCK_TTL_MS = Math.max(
|
|
119
|
+
60_000,
|
|
120
|
+
Number.parseInt(process.env.BUILD_VISUALIZATION_RUN_LOCK_TTL_MS || '', 10) || 24 * 60 * 60 * 1000,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/** How often a claimed run refreshes its heartbeat while building. */
|
|
124
|
+
const BUILD_HEARTBEAT_INTERVAL_MS = Math.max(
|
|
125
|
+
5_000,
|
|
126
|
+
Number.parseInt(process.env.BUILD_VISUALIZATION_HEARTBEAT_MS || '', 10) || 30_000,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
/** A build whose heartbeat is older than this is considered stale (Req 10.7). */
|
|
130
|
+
const BUILD_STALE_MS = Math.max(
|
|
131
|
+
BUILD_HEARTBEAT_INTERVAL_MS * 2,
|
|
132
|
+
Number.parseInt(process.env.BUILD_VISUALIZATION_STALE_MS || '', 10) || 120_000,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
/* --------------------------------------------------------------------------
|
|
136
|
+
* Narrow structural interfaces for the untyped app subsystems the queue uses.
|
|
137
|
+
* These keep our own domain logic typed while reading through the minimal
|
|
138
|
+
* surface each subsystem exposes (rather than the reference plugin's `any`).
|
|
139
|
+
* ----------------------------------------------------------------------- */
|
|
140
|
+
|
|
141
|
+
/** The minimal Redis connection surface the queue relies on. */
|
|
142
|
+
interface RedisLikeConnection {
|
|
143
|
+
sendCommand(args: string[]): Promise<unknown>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The minimal Redis connection manager surface (resolved off the app). */
|
|
147
|
+
interface RedisConnectionManagerLike {
|
|
148
|
+
getConnectionSync(name: string, options?: { connectionString?: string }): Promise<RedisLikeConnection>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The pub/sub wake handler signature. */
|
|
152
|
+
type WakeHandler = (message?: unknown) => void | Promise<void>;
|
|
153
|
+
|
|
154
|
+
/** The minimal pub/sub manager surface (resolved off the app). */
|
|
155
|
+
interface PubSubManagerLike {
|
|
156
|
+
publish(channel: string, message: unknown, options?: { skipSelf?: boolean }): Promise<void> | void;
|
|
157
|
+
subscribe(channel: string, handler: WakeHandler): void | Promise<void>;
|
|
158
|
+
unsubscribe(channel: string, handler: WakeHandler): void | Promise<void>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The in-memory event-queue adapter surface used to clear stale local messages. */
|
|
162
|
+
interface EventQueueAdapterLike {
|
|
163
|
+
queues?: Map<string, unknown[]>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The minimal sequelize-model surface used for atomic, run-guarded updates.
|
|
168
|
+
* `app.db.getModel(...)` returns a richer model; we narrow to just the
|
|
169
|
+
* conditional `update` so the stale-run guard stays typed.
|
|
170
|
+
*/
|
|
171
|
+
interface UpdatableModel {
|
|
172
|
+
update(values: Record<string, unknown>, options: { where: Record<string, unknown> }): Promise<[number, ...unknown[]]>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolve the optional pub/sub manager without leaking `any` into call sites. */
|
|
176
|
+
function getPubSubManager(app: Application): PubSubManagerLike | undefined {
|
|
177
|
+
return (app as unknown as { pubSubManager?: PubSubManagerLike }).pubSubManager;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Resolve the optional Redis connection manager. */
|
|
181
|
+
function getRedisConnectionManager(app: Application): RedisConnectionManagerLike | undefined {
|
|
182
|
+
return (app as unknown as { redisConnectionManager?: RedisConnectionManagerLike }).redisConnectionManager;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Access the event-queue adapter internals (for local memory-queue cleanup). */
|
|
186
|
+
function getEventQueueInternals(app: Application): {
|
|
187
|
+
adapter?: EventQueueAdapterLike;
|
|
188
|
+
getFullChannel?(channel: string): string;
|
|
189
|
+
} {
|
|
190
|
+
return app.eventQueue as unknown as {
|
|
191
|
+
adapter?: EventQueueAdapterLike;
|
|
192
|
+
getFullChannel?(channel: string): string;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Read the app's node identity (name/instanceId) for the worker-id string. */
|
|
197
|
+
function getAppIdentity(app: Application): { name?: string; instanceId?: string } {
|
|
198
|
+
return app as unknown as { name?: string; instanceId?: string };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The build-record model, narrowed to the conditional-update surface. */
|
|
202
|
+
function getBuildModel(app: Application): UpdatableModel {
|
|
203
|
+
return app.db.getModel(COLLECTION_NAME) as unknown as UpdatableModel;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* --------------------------------------------------------------------------
|
|
207
|
+
* Queue processor state scoped per app to avoid cross-app/test leakage.
|
|
208
|
+
* ----------------------------------------------------------------------- */
|
|
209
|
+
|
|
210
|
+
interface BuildQueueState {
|
|
211
|
+
timer: NodeJS.Timeout | null;
|
|
212
|
+
kickTimer: NodeJS.Timeout | null;
|
|
213
|
+
processing: boolean;
|
|
214
|
+
wakeHandler: WakeHandler | null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const buildQueueStates = new WeakMap<Application, BuildQueueState>();
|
|
218
|
+
|
|
219
|
+
function getBuildQueueState(app: Application): BuildQueueState {
|
|
220
|
+
let state = buildQueueStates.get(app);
|
|
221
|
+
if (!state) {
|
|
222
|
+
state = { timer: null, kickTimer: null, processing: false, wakeHandler: null };
|
|
223
|
+
buildQueueStates.set(app, state);
|
|
224
|
+
}
|
|
225
|
+
return state;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Raised when a persisted write targets a run that is no longer current (the
|
|
230
|
+
* record's `buildRunId` changed out from under us after a retry/regenerate).
|
|
231
|
+
*/
|
|
232
|
+
class StaleBuildRunError extends Error {
|
|
233
|
+
constructor(buildId: string, runId: string) {
|
|
234
|
+
super(`Build run ${runId} for record ${buildId} is no longer current`);
|
|
235
|
+
this.name = 'StaleBuildRunError';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* --------------------------------------------------------------------------
|
|
240
|
+
* Worker-mode detection + identity.
|
|
241
|
+
* ----------------------------------------------------------------------- */
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether this node should process builds. Explicit WORKER_MODE queues take
|
|
245
|
+
* precedence; legacy generic worker/task modes still process these jobs.
|
|
246
|
+
*/
|
|
247
|
+
function isBuildVisualizationWorker(app: Application): boolean {
|
|
248
|
+
return app.serving(WORKER_JOB_BUILD_VISUALIZATION_PROCESS) || workerModeServesBuildVisualization();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function workerModeServesBuildVisualization(): boolean {
|
|
252
|
+
const workerMode = process.env.WORKER_MODE || '';
|
|
253
|
+
const workerModes = workerMode
|
|
254
|
+
.split(',')
|
|
255
|
+
.map((mode) => mode.trim())
|
|
256
|
+
.filter(Boolean);
|
|
257
|
+
|
|
258
|
+
return workerModes.some((mode) => {
|
|
259
|
+
if (mode === '*' || mode === 'worker' || mode === 'task' || mode === WORKER_JOB_BUILD_VISUALIZATION_PROCESS) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return BUILD_VISUALIZATION_WORKER_ALIASES.some((alias) => mode === alias || mode.endsWith(`:${alias}`));
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A stable identifier for the worker that claims a run. */
|
|
267
|
+
function getBuildWorkerId(app: Application): string {
|
|
268
|
+
const identity = getAppIdentity(app);
|
|
269
|
+
return [
|
|
270
|
+
process.env.HOSTNAME || process.env.COMPUTERNAME || 'worker',
|
|
271
|
+
identity.name || 'app',
|
|
272
|
+
identity.instanceId || '0',
|
|
273
|
+
process.pid,
|
|
274
|
+
].join(':');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* --------------------------------------------------------------------------
|
|
278
|
+
* Run-guarded persistence helpers.
|
|
279
|
+
* ----------------------------------------------------------------------- */
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Apply `values` to the record identified by `run.buildId` only while it is
|
|
283
|
+
* still owned by `run.runId`. When no row matches and the write is not
|
|
284
|
+
* `optional`, the run has been superseded → {@link StaleBuildRunError}.
|
|
285
|
+
*/
|
|
286
|
+
async function updateRecordForRun(
|
|
287
|
+
app: Application,
|
|
288
|
+
run: BuildRunContext,
|
|
289
|
+
values: Record<string, unknown>,
|
|
290
|
+
optional = false,
|
|
291
|
+
): Promise<boolean> {
|
|
292
|
+
const model = getBuildModel(app);
|
|
293
|
+
const [affected] = await model.update(values, {
|
|
294
|
+
where: {
|
|
295
|
+
id: run.buildId,
|
|
296
|
+
buildRunId: run.runId,
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
if (!affected && !optional) {
|
|
300
|
+
throw new StaleBuildRunError(run.buildId, run.runId);
|
|
301
|
+
}
|
|
302
|
+
return affected > 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Atomically claim a queued run for this worker: transition `queued` →
|
|
307
|
+
* `analyzing` while stamping `buildStartedAt`/heartbeat/worker. The `queued`
|
|
308
|
+
* predicate in the `where` clause guarantees exactly one worker wins the claim.
|
|
309
|
+
*/
|
|
310
|
+
async function claimBuildRun(app: Application, run: BuildRunContext, workerId: string): Promise<boolean> {
|
|
311
|
+
const now = new Date();
|
|
312
|
+
const model = getBuildModel(app);
|
|
313
|
+
const [affected] = await model.update(
|
|
314
|
+
{
|
|
315
|
+
buildPhase: 'analyzing',
|
|
316
|
+
buildStartedAt: now,
|
|
317
|
+
buildHeartbeatAt: now,
|
|
318
|
+
buildWorkerId: workerId,
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
where: {
|
|
322
|
+
id: run.buildId,
|
|
323
|
+
status: 'building',
|
|
324
|
+
buildPhase: 'queued',
|
|
325
|
+
buildRunId: run.runId,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
return affected > 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Start a heartbeat that refreshes `buildHeartbeatAt` on an interval while a run
|
|
334
|
+
* is processing. Returns a stop function. Heartbeat writes are `optional` so a
|
|
335
|
+
* superseded run simply stops updating rather than throwing.
|
|
336
|
+
*/
|
|
337
|
+
function startBuildHeartbeat(app: Application, run: BuildRunContext): () => void {
|
|
338
|
+
const timer = setInterval(() => {
|
|
339
|
+
updateRecordForRun(app, run, { buildHeartbeatAt: new Date() }, true).catch((error) => {
|
|
340
|
+
app.log?.warn?.(`[plugin-build-visualization-block] Failed to update heartbeat for build ${run.runId}`, error);
|
|
341
|
+
});
|
|
342
|
+
}, BUILD_HEARTBEAT_INTERVAL_MS);
|
|
343
|
+
|
|
344
|
+
return () => clearInterval(timer);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* --------------------------------------------------------------------------
|
|
348
|
+
* Redis-with-DB-poll queue.
|
|
349
|
+
* ----------------------------------------------------------------------- */
|
|
350
|
+
|
|
351
|
+
/** The Redis list key holding queued build messages for this app. */
|
|
352
|
+
function getBuildQueueRedisKey(app: Application): string {
|
|
353
|
+
const appName = getAppIdentity(app).name || process.env.APP_NAME || 'main';
|
|
354
|
+
return `${appName}:plugin-build-visualization-block:build:queue`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Resolve the shared Redis connection, or `undefined` when unavailable. */
|
|
358
|
+
async function getBuildQueueRedis(app: Application): Promise<RedisLikeConnection | undefined> {
|
|
359
|
+
const manager = getRedisConnectionManager(app);
|
|
360
|
+
if (!manager?.getConnectionSync) {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const connectionString = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
|
|
365
|
+
return await manager.getConnectionSync(
|
|
366
|
+
BUILD_VISUALIZATION_QUEUE_REDIS_CONNECTION,
|
|
367
|
+
connectionString ? { connectionString } : undefined,
|
|
368
|
+
);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
app.log?.debug?.(
|
|
371
|
+
`[plugin-build-visualization-block] Redis queue unavailable; DB polling fallback active: ${
|
|
372
|
+
error instanceof Error ? error.message : String(error)
|
|
373
|
+
}`,
|
|
374
|
+
);
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Push a build message onto the Redis queue. Returns false when Redis is down. */
|
|
380
|
+
async function enqueueBuildToRedis(app: Application, message: BuildQueueMessage): Promise<boolean> {
|
|
381
|
+
const redis = await getBuildQueueRedis(app);
|
|
382
|
+
if (!redis) return false;
|
|
383
|
+
try {
|
|
384
|
+
await redis.sendCommand(['RPUSH', getBuildQueueRedisKey(app), JSON.stringify(message)]);
|
|
385
|
+
app.log?.debug?.(
|
|
386
|
+
`[plugin-build-visualization-block] Enqueued build ${message.runId} for record "${message.buildId}" to Redis`,
|
|
387
|
+
);
|
|
388
|
+
return true;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
app.log?.warn?.(
|
|
391
|
+
`[plugin-build-visualization-block] Failed to enqueue build to Redis; DB polling fallback active`,
|
|
392
|
+
error,
|
|
393
|
+
);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Wake idle worker pollers so a freshly queued build is picked up promptly. */
|
|
399
|
+
async function publishBuildQueueWake(app: Application, message?: BuildQueueMessage): Promise<void> {
|
|
400
|
+
try {
|
|
401
|
+
await getPubSubManager(app)?.publish?.(
|
|
402
|
+
BUILD_VISUALIZATION_QUEUE_WAKE_CHANNEL,
|
|
403
|
+
{ buildId: message?.buildId, runId: message?.runId },
|
|
404
|
+
{ skipSelf: !isBuildVisualizationWorker(app) },
|
|
405
|
+
);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
app.log?.debug?.(
|
|
408
|
+
`[plugin-build-visualization-block] Wake publish skipped: ${
|
|
409
|
+
error instanceof Error ? error.message : String(error)
|
|
410
|
+
}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Pop up to `count` messages off the Redis queue. */
|
|
416
|
+
async function drainRedisBuildQueue(app: Application, count: number): Promise<BuildQueueMessage[]> {
|
|
417
|
+
const redis = await getBuildQueueRedis(app);
|
|
418
|
+
if (!redis) return [];
|
|
419
|
+
|
|
420
|
+
const key = getBuildQueueRedisKey(app);
|
|
421
|
+
const messages: BuildQueueMessage[] = [];
|
|
422
|
+
for (let i = 0; i < count; i += 1) {
|
|
423
|
+
const raw = await redis.sendCommand(['LPOP', key]);
|
|
424
|
+
if (!raw) break;
|
|
425
|
+
try {
|
|
426
|
+
messages.push(JSON.parse(String(raw)) as BuildQueueMessage);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
app.log?.warn?.(
|
|
429
|
+
`[plugin-build-visualization-block] Dropped invalid Redis build message: ${
|
|
430
|
+
error instanceof Error ? error.message : String(error)
|
|
431
|
+
}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return messages;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Clear any stale in-memory queue messages on a non-worker node so queued DB
|
|
440
|
+
* builds are only ever processed by real worker pollers.
|
|
441
|
+
*/
|
|
440
442
|
function clearLocalBuildMemoryQueue(app: Application): void {
|
|
441
443
|
const eventQueue = getEventQueueInternals(app);
|
|
442
444
|
const fullChannel = eventQueue.getFullChannel?.(BUILD_VISUALIZATION_QUEUE_CHANNEL);
|
|
443
445
|
const { adapter } = eventQueue;
|
|
444
446
|
const queue = fullChannel ? adapter?.queues?.get?.(fullChannel) : undefined;
|
|
445
447
|
if (!queue?.length) return;
|
|
446
|
-
|
|
447
|
-
adapter?.queues?.set?.(fullChannel as string, []);
|
|
448
|
-
app.log?.warn?.(
|
|
449
|
-
`[plugin-build-visualization-block] Cleared ${queue.length} stale local memory message(s) on non-worker node; queued DB builds will be picked up by workers`,
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/* --------------------------------------------------------------------------
|
|
454
|
-
* Queue processor lifecycle + ticks.
|
|
455
|
-
* ----------------------------------------------------------------------- */
|
|
456
|
-
|
|
457
|
-
/** Start the periodic DB poller + wake subscription on worker nodes. */
|
|
458
|
-
export function startBuildQueueProcessor(app: Application): void {
|
|
459
|
-
const state = getBuildQueueState(app);
|
|
460
|
-
if (!isBuildVisualizationWorker(app)) {
|
|
461
|
-
app.log?.debug?.(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (state.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
// Phase →
|
|
692
|
-
await updateRecordForRun(app, run, {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
app.log?.
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
if (
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
return
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
ctx.body = {
|
|
1286
|
-
id: record.get('id'),
|
|
1287
|
-
status: 'building',
|
|
1288
|
-
buildPhase: 'queued',
|
|
1289
|
-
};
|
|
1290
|
-
await next();
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/**
|
|
1294
|
-
* `aiVisualizationBuilds:getResult` — the client's polling endpoint. Returns
|
|
1295
|
-
* the current phase/status plus the generated outputs for a single build
|
|
1296
|
-
* (Req 9.4). Responds 404 when the record does not exist.
|
|
1297
|
-
*/
|
|
1298
|
-
export async function getResult(ctx: Context, next: Next): Promise<void> {
|
|
1299
|
-
const filterByTk = getFilterByTk(ctx);
|
|
1300
|
-
const repository = ctx.db.getRepository(COLLECTION_NAME) as Repository;
|
|
1301
|
-
const record = await repository.findById(filterByTk);
|
|
1302
|
-
if (!record) {
|
|
1303
|
-
ctx.throw(404, t(ctx, 'Build record not found'));
|
|
1304
|
-
}
|
|
1305
|
-
assertBuildRecordAccess(ctx, record);
|
|
1306
|
-
|
|
1307
|
-
ctx.body = {
|
|
1308
|
-
id: record.get('id'),
|
|
1309
|
-
status: record.get('status'),
|
|
1310
|
-
buildPhase: record.get('buildPhase'),
|
|
1311
|
-
blockSchema: record.get('blockSchema'),
|
|
1312
|
-
blockSpec: record.get('blockSpec'),
|
|
1313
|
-
adjustments: record.get('adjustments'),
|
|
1314
|
-
usedFallback: record.get('usedFallback'),
|
|
1315
|
-
errorMessage: record.get('errorMessage'),
|
|
1316
|
-
};
|
|
1317
|
-
await next();
|
|
1318
|
-
}
|
|
448
|
+
|
|
449
|
+
adapter?.queues?.set?.(fullChannel as string, []);
|
|
450
|
+
app.log?.warn?.(
|
|
451
|
+
`[plugin-build-visualization-block] Cleared ${queue.length} stale local memory message(s) on non-worker node; queued DB builds will be picked up by workers`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* --------------------------------------------------------------------------
|
|
456
|
+
* Queue processor lifecycle + ticks.
|
|
457
|
+
* ----------------------------------------------------------------------- */
|
|
458
|
+
|
|
459
|
+
/** Start the periodic DB poller + wake subscription on worker nodes. */
|
|
460
|
+
export function startBuildQueueProcessor(app: Application): void {
|
|
461
|
+
const state = getBuildQueueState(app);
|
|
462
|
+
if (!isBuildVisualizationWorker(app)) {
|
|
463
|
+
app.log?.debug?.('[plugin-build-visualization-block] Build queue processor disabled on non-worker node');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (state.timer) return;
|
|
467
|
+
|
|
468
|
+
state.wakeHandler = async () => {
|
|
469
|
+
scheduleBuildQueueTick(app, 0);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const subscribe = getPubSubManager(app)?.subscribe?.(BUILD_VISUALIZATION_QUEUE_WAKE_CHANNEL, state.wakeHandler);
|
|
473
|
+
if (subscribe instanceof Promise) {
|
|
474
|
+
subscribe.catch((error: unknown) => {
|
|
475
|
+
app.log?.debug?.(
|
|
476
|
+
`[plugin-build-visualization-block] Wake subscribe skipped: ${
|
|
477
|
+
error instanceof Error ? error.message : String(error)
|
|
478
|
+
}`,
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
state.timer = setInterval(() => scheduleBuildQueueTick(app, 0), BUILD_VISUALIZATION_QUEUE_POLL_INTERVAL_MS);
|
|
484
|
+
state.timer.unref?.();
|
|
485
|
+
scheduleBuildQueueTick(app, 1000);
|
|
486
|
+
app.log?.info?.(
|
|
487
|
+
`[plugin-build-visualization-block] Build queue processor started (interval ${BUILD_VISUALIZATION_QUEUE_POLL_INTERVAL_MS}ms)`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Stop the poller, cancel pending ticks, and unsubscribe the wake handler. */
|
|
492
|
+
function stopBuildVisualizationQueueProcessor(app: Application): void {
|
|
493
|
+
const state = getBuildQueueState(app);
|
|
494
|
+
if (state.timer) {
|
|
495
|
+
clearInterval(state.timer);
|
|
496
|
+
state.timer = null;
|
|
497
|
+
}
|
|
498
|
+
if (state.kickTimer) {
|
|
499
|
+
clearTimeout(state.kickTimer);
|
|
500
|
+
state.kickTimer = null;
|
|
501
|
+
}
|
|
502
|
+
if (state.wakeHandler) {
|
|
503
|
+
const unsubscribe = getPubSubManager(app)?.unsubscribe?.(BUILD_VISUALIZATION_QUEUE_WAKE_CHANNEL, state.wakeHandler);
|
|
504
|
+
if (unsubscribe instanceof Promise) {
|
|
505
|
+
unsubscribe.catch(() => undefined);
|
|
506
|
+
}
|
|
507
|
+
state.wakeHandler = null;
|
|
508
|
+
}
|
|
509
|
+
state.processing = false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Debounce a queue tick so bursts of wakes coalesce into one drain pass. */
|
|
513
|
+
function scheduleBuildQueueTick(app: Application, delayMs: number): void {
|
|
514
|
+
const state = getBuildQueueState(app);
|
|
515
|
+
if (state.kickTimer) return;
|
|
516
|
+
state.kickTimer = setTimeout(() => {
|
|
517
|
+
state.kickTimer = null;
|
|
518
|
+
runBuildQueueTick(app).catch((error) => {
|
|
519
|
+
app.log?.error?.('[plugin-build-visualization-block] Build queue tick failed', error);
|
|
520
|
+
});
|
|
521
|
+
}, delayMs);
|
|
522
|
+
state.kickTimer.unref?.();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* One drain pass: take from Redis first, then top up from the DB so builds are
|
|
527
|
+
* never stranded when Redis is unavailable. Re-entrancy is guarded so only one
|
|
528
|
+
* tick runs at a time per node.
|
|
529
|
+
*/
|
|
530
|
+
async function runBuildQueueTick(app: Application): Promise<void> {
|
|
531
|
+
const state = getBuildQueueState(app);
|
|
532
|
+
if (state.processing || !isBuildVisualizationWorker(app)) return;
|
|
533
|
+
|
|
534
|
+
state.processing = true;
|
|
535
|
+
try {
|
|
536
|
+
// Reap builds that have exceeded the 30-min processing timeout before
|
|
537
|
+
// draining the queue, so each poll also fails stuck runs (Req 10.6).
|
|
538
|
+
await failTimedOutBuilds(app);
|
|
539
|
+
|
|
540
|
+
const redisMessages = await drainRedisBuildQueue(app, BUILD_VISUALIZATION_QUEUE_CONCURRENCY);
|
|
541
|
+
await processBuildQueueMessages(app, redisMessages);
|
|
542
|
+
|
|
543
|
+
const remaining = Math.max(1, BUILD_VISUALIZATION_QUEUE_CONCURRENCY - redisMessages.length);
|
|
544
|
+
await processQueuedBuildsFromDb(app, remaining);
|
|
545
|
+
} finally {
|
|
546
|
+
state.processing = false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Build a queue message from a persisted, still-queued record. */
|
|
551
|
+
function createBuildQueueMessageFromRecord(record: { get(key: string): unknown }): BuildQueueMessage | null {
|
|
552
|
+
const runId = record.get('buildRunId');
|
|
553
|
+
if (!runId) return null;
|
|
554
|
+
const queuedAtRaw = record.get('buildQueuedAt');
|
|
555
|
+
const createdById = record.get('createdById');
|
|
556
|
+
return {
|
|
557
|
+
buildId: String(record.get('id')),
|
|
558
|
+
runId: String(runId),
|
|
559
|
+
userId: typeof createdById === 'number' || typeof createdById === 'string' ? createdById : null,
|
|
560
|
+
queuedAt: queuedAtRaw ? new Date(queuedAtRaw as string).toISOString() : new Date().toISOString(),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** DB-poll fallback: pick the oldest queued builds and process them. */
|
|
565
|
+
async function processQueuedBuildsFromDb(app: Application, count: number): Promise<void> {
|
|
566
|
+
const repository = app.db.getRepository(COLLECTION_NAME) as Repository;
|
|
567
|
+
const records = await repository.find({
|
|
568
|
+
filter: {
|
|
569
|
+
status: 'building',
|
|
570
|
+
buildPhase: 'queued',
|
|
571
|
+
},
|
|
572
|
+
sort: ['buildQueuedAt'],
|
|
573
|
+
limit: count,
|
|
574
|
+
});
|
|
575
|
+
const messages = records
|
|
576
|
+
.map((record) => createBuildQueueMessageFromRecord(record))
|
|
577
|
+
.filter((message): message is BuildQueueMessage => message !== null);
|
|
578
|
+
await processBuildQueueMessages(app, messages);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Process a batch of queue messages concurrently. */
|
|
582
|
+
async function processBuildQueueMessages(app: Application, messages: BuildQueueMessage[]): Promise<void> {
|
|
583
|
+
if (!messages.length) return;
|
|
584
|
+
await Promise.all(messages.map((message) => processQueuedBuild(app, message)));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* --------------------------------------------------------------------------
|
|
588
|
+
* The worker pipeline.
|
|
589
|
+
* ----------------------------------------------------------------------- */
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Execute the full generation pipeline for a single claimed run:
|
|
593
|
+
*
|
|
594
|
+
* 1. Load the record and guard `buildRunId === run.runId` (stale otherwise).
|
|
595
|
+
* 2. Phase → `analyzing`; introspect the selected collections (Req 10.2).
|
|
596
|
+
* 3. Analyze (LLM + clean/parse, fallback on failure); persist any analyzer
|
|
597
|
+
* error to `errorMessage`/`buildLog` (Req 10.4 partial).
|
|
598
|
+
* 4. Validate the spec against the live schema; when the validator signals a
|
|
599
|
+
* fallback, generate from the grounded fallback spec instead.
|
|
600
|
+
* 5. Phase → `generating`; generate the Formily block schema. A hard generator
|
|
601
|
+
* error throws → the run is marked `failed` by the caller (Req 10.4).
|
|
602
|
+
* 6. Phase → `completed` with the stored outputs (Req 10.3).
|
|
603
|
+
*
|
|
604
|
+
* The function never writes a partial success: it only flips to `completed`
|
|
605
|
+
* after a schema is produced. Errors propagate to {@link processQueuedBuild},
|
|
606
|
+
* which marks the record `failed` (StaleBuildRunError is swallowed there).
|
|
607
|
+
*/
|
|
608
|
+
async function runBuild(app: Application, db: Application['db'], run: BuildRunContext): Promise<void> {
|
|
609
|
+
const repository = db.getRepository(COLLECTION_NAME) as Repository;
|
|
610
|
+
const record = await repository.findById(run.buildId);
|
|
611
|
+
|
|
612
|
+
if (!record) {
|
|
613
|
+
throw new Error('Build record not found');
|
|
614
|
+
}
|
|
615
|
+
if (record.get('buildRunId') !== run.runId) {
|
|
616
|
+
throw new StaleBuildRunError(run.buildId, run.runId);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const requirement = String(record.get('requirement') ?? '');
|
|
620
|
+
const dataSource = String(record.get('dataSource') ?? 'main');
|
|
621
|
+
const llmService = record.get('llmService') as string | undefined;
|
|
622
|
+
const model = record.get('model') as string | undefined;
|
|
623
|
+
const rawCollections = record.get('collections');
|
|
624
|
+
const collections = Array.isArray(rawCollections)
|
|
625
|
+
? rawCollections.filter((name): name is string => typeof name === 'string')
|
|
626
|
+
: [];
|
|
627
|
+
|
|
628
|
+
if (!llmService || !model) {
|
|
629
|
+
throw new Error('LLM service or model is missing in the build configuration');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Phase → analyzing (Req 10.2). Reset any prior outputs for a clean run.
|
|
633
|
+
await updateRecordForRun(app, run, {
|
|
634
|
+
status: 'building',
|
|
635
|
+
buildPhase: 'analyzing',
|
|
636
|
+
buildLog: 'Analyzing collections and requirement',
|
|
637
|
+
errorMessage: null,
|
|
638
|
+
blockSpec: null,
|
|
639
|
+
blockSchema: null,
|
|
640
|
+
adjustments: null,
|
|
641
|
+
usedFallback: false,
|
|
642
|
+
buildHeartbeatAt: new Date(),
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const summary = await introspect(app, { dataSource, collections });
|
|
646
|
+
|
|
647
|
+
const analysis = await analyze(app, { requirement, summary, llmService, model });
|
|
648
|
+
let usedFallback = analysis.usedFallback;
|
|
649
|
+
|
|
650
|
+
// Persist the analyzer error (parse/shape/timeout/transport) as a partial
|
|
651
|
+
// diagnostic even though the run continues with the fallback spec (Req 10.4).
|
|
652
|
+
if (analysis.error) {
|
|
653
|
+
await updateRecordForRun(
|
|
654
|
+
app,
|
|
655
|
+
run,
|
|
656
|
+
{
|
|
657
|
+
errorMessage: analysis.error,
|
|
658
|
+
buildLog: `Analyzer fell back: ${analysis.error}`,
|
|
659
|
+
usedFallback: true,
|
|
660
|
+
},
|
|
661
|
+
true,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const validation = validate(analysis.spec, summary);
|
|
666
|
+
usedFallback = usedFallback || validation.usedFallback;
|
|
667
|
+
|
|
668
|
+
// When the validator gives up (unmet required role / schema unavailable) we
|
|
669
|
+
// generate from the grounded fallback spec, which is guaranteed to validate
|
|
670
|
+
// and produce an insertable schema. Otherwise generate from the validated
|
|
671
|
+
// spec and let the generator fall back on its own if unproducible.
|
|
672
|
+
const specForGeneration = validation.usedFallback ? buildFallbackSpec(summary) : validation.spec;
|
|
673
|
+
|
|
674
|
+
// Phase → generating (Req 10.2).
|
|
675
|
+
await updateRecordForRun(app, run, {
|
|
676
|
+
buildPhase: 'generating',
|
|
677
|
+
buildLog: 'Generating block schema',
|
|
678
|
+
buildHeartbeatAt: new Date(),
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
const result = generate(specForGeneration, summary);
|
|
682
|
+
if (!result.ok) {
|
|
683
|
+
// Hard generator failure after validation (Req 10.4 / 7.6): no partial
|
|
684
|
+
// schema is emitted; surface the failed node and mark the run failed.
|
|
685
|
+
throw new Error(
|
|
686
|
+
result.failedNode
|
|
687
|
+
? `Schema generation failed at "${result.failedNode}": ${result.error}`
|
|
688
|
+
: `Schema generation failed: ${result.error}`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
usedFallback = usedFallback || result.usedFallback;
|
|
692
|
+
|
|
693
|
+
// Phase → completed with the stored outputs (Req 10.3).
|
|
694
|
+
await updateRecordForRun(app, run, {
|
|
695
|
+
status: 'completed',
|
|
696
|
+
buildPhase: 'completed',
|
|
697
|
+
blockSpec: specForGeneration,
|
|
698
|
+
blockSchema: result.schema,
|
|
699
|
+
adjustments: validation.adjustments,
|
|
700
|
+
usedFallback,
|
|
701
|
+
buildLog: usedFallback ? 'Build completed using the fallback specification' : 'Build completed successfully',
|
|
702
|
+
errorMessage: null,
|
|
703
|
+
buildHeartbeatAt: new Date(),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Serialize claim + processing of a single record across the cluster. */
|
|
708
|
+
async function withBuildRunLock<T>(app: Application, buildId: string, fn: () => Promise<T>): Promise<T> {
|
|
709
|
+
return app.lockManager.runExclusive(`build-visualization:run:${buildId}`, fn, BUILD_RUN_LOCK_TTL_MS);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Claim and process a single queued build message. Holds the per-record lock,
|
|
714
|
+
* claims the run (skipping already-claimed/stale ones), runs the pipeline under
|
|
715
|
+
* a heartbeat, and marks the record `failed` on a real error. A
|
|
716
|
+
* {@link StaleBuildRunError} is benign (a newer run owns the record) and is
|
|
717
|
+
* logged without failing the record.
|
|
718
|
+
*
|
|
719
|
+
* Exported so integration tests (task 7.5) can drive a single queued record
|
|
720
|
+
* through the full worker pipeline deterministically without standing up the
|
|
721
|
+
* Redis/event-queue poller. Production callers reach it through the queue
|
|
722
|
+
* (`registerBuildQueue`) rather than calling it directly.
|
|
723
|
+
*/
|
|
724
|
+
export async function processQueuedBuild(app: Application, message: BuildQueueMessage): Promise<void> {
|
|
725
|
+
const buildId = message?.buildId;
|
|
726
|
+
const runId = message?.runId;
|
|
727
|
+
if (!buildId || !runId) {
|
|
728
|
+
app.log?.warn?.('[plugin-build-visualization-block] Build queue message missing buildId or runId');
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
await withBuildRunLock(app, buildId, async () => {
|
|
733
|
+
const run: BuildRunContext = { buildId, runId };
|
|
734
|
+
const workerId = getBuildWorkerId(app);
|
|
735
|
+
const claimed = await claimBuildRun(app, run, workerId);
|
|
736
|
+
if (!claimed) {
|
|
737
|
+
app.log?.info?.(
|
|
738
|
+
`[plugin-build-visualization-block] Build ${runId} for record "${buildId}" was already claimed or stale`,
|
|
739
|
+
);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const repository = app.db.getRepository(COLLECTION_NAME) as Repository;
|
|
744
|
+
const record = await repository.findById(buildId);
|
|
745
|
+
if (!record) {
|
|
746
|
+
app.log?.warn?.(`[plugin-build-visualization-block] Build record "${buildId}" not found; skipping queued build`);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (record.get('status') !== 'building') {
|
|
750
|
+
app.log?.info?.(
|
|
751
|
+
`[plugin-build-visualization-block] Build record "${buildId}" is ${record.get(
|
|
752
|
+
'status',
|
|
753
|
+
)}; skipping queued build`,
|
|
754
|
+
);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const stopHeartbeat = startBuildHeartbeat(app, run);
|
|
759
|
+
try {
|
|
760
|
+
await runBuild(app, app.db, run);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (error instanceof StaleBuildRunError) {
|
|
763
|
+
app.log?.info?.(`[plugin-build-visualization-block] ${error.message}`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
app.log?.error?.('[plugin-build-visualization-block] Build worker error', error);
|
|
767
|
+
await markBuildError(app, buildId, runId, error);
|
|
768
|
+
} finally {
|
|
769
|
+
stopHeartbeat();
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Mark a build `failed` with an error description (Req 10.4). When `runId` is
|
|
776
|
+
* known the write is run-guarded; otherwise it targets the record directly.
|
|
777
|
+
*/
|
|
778
|
+
async function markBuildError(
|
|
779
|
+
app: Application,
|
|
780
|
+
buildId: string,
|
|
781
|
+
runId: string | undefined,
|
|
782
|
+
error: unknown,
|
|
783
|
+
): Promise<void> {
|
|
784
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
785
|
+
if (runId) {
|
|
786
|
+
await updateRecordForRun(
|
|
787
|
+
app,
|
|
788
|
+
{ buildId, runId },
|
|
789
|
+
{
|
|
790
|
+
status: 'error',
|
|
791
|
+
buildPhase: 'failed',
|
|
792
|
+
errorMessage,
|
|
793
|
+
buildLog: errorMessage,
|
|
794
|
+
buildHeartbeatAt: new Date(),
|
|
795
|
+
},
|
|
796
|
+
true,
|
|
797
|
+
);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
await (app.db.getRepository(COLLECTION_NAME) as Repository).update({
|
|
802
|
+
filterByTk: buildId,
|
|
803
|
+
values: {
|
|
804
|
+
status: 'error',
|
|
805
|
+
buildPhase: 'failed',
|
|
806
|
+
errorMessage,
|
|
807
|
+
buildLog: errorMessage,
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/* --------------------------------------------------------------------------
|
|
813
|
+
* Public queue API (wired by plugin.ts in task 7.4).
|
|
814
|
+
* ----------------------------------------------------------------------- */
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Enqueue a build run for asynchronous processing.
|
|
818
|
+
*
|
|
819
|
+
* Order of preference (mirrors the reference plugin):
|
|
820
|
+
* 1. Redis enqueue + wake — cross-node durable queue.
|
|
821
|
+
* 2. On a worker node without Redis, publish to the in-process event queue
|
|
822
|
+
* with the 30-min processing timeout (Req 10.7).
|
|
823
|
+
* 3. Otherwise leave the record `queued`; a worker's DB poller will pick it up.
|
|
824
|
+
*
|
|
825
|
+
* On a failure to enqueue, the record is marked `failed` and the error rethrown.
|
|
826
|
+
*/
|
|
827
|
+
export async function enqueueBuild(app: Application, message: BuildQueueMessage): Promise<void> {
|
|
828
|
+
try {
|
|
829
|
+
const queuedInRedis = await enqueueBuildToRedis(app, message);
|
|
830
|
+
if (queuedInRedis) {
|
|
831
|
+
await publishBuildQueueWake(app, message);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
await publishBuildQueueWake(app, message);
|
|
836
|
+
|
|
837
|
+
if (isBuildVisualizationWorker(app)) {
|
|
838
|
+
await app.eventQueue.publish(BUILD_VISUALIZATION_QUEUE_CHANNEL, message, {
|
|
839
|
+
timeout: BUILD_VISUALIZATION_QUEUE_TIMEOUT_MS,
|
|
840
|
+
maxRetries: 0,
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
app.log?.warn?.(
|
|
846
|
+
`[plugin-build-visualization-block] Redis queue is unavailable; build ${message.runId} for record "${message.buildId}" will remain queued until a worker DB poller picks it up`,
|
|
847
|
+
);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
await markBuildError(app, message.buildId, message.runId, error);
|
|
850
|
+
throw error;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Register the build queue: subscribe the event-queue channel, clear stale
|
|
856
|
+
* local memory messages on non-worker nodes, and start the DB poller. Called
|
|
857
|
+
* from the plugin's `afterStart` (task 7.4).
|
|
858
|
+
*/
|
|
859
|
+
export function registerBuildQueue(app: Application): void {
|
|
860
|
+
app.eventQueue.subscribe(BUILD_VISUALIZATION_QUEUE_CHANNEL, {
|
|
861
|
+
concurrency: BUILD_VISUALIZATION_QUEUE_CONCURRENCY,
|
|
862
|
+
idle: () => isBuildVisualizationWorker(app),
|
|
863
|
+
process: async (message: BuildQueueMessage) => {
|
|
864
|
+
await processQueuedBuild(app, message);
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
if (!isBuildVisualizationWorker(app)) {
|
|
868
|
+
app.on('afterStart', () => clearLocalBuildMemoryQueue(app));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Unregister the build queue: unsubscribe the channel and stop the poller.
|
|
874
|
+
* Called from the plugin's `beforeStop`/`beforeDestroy` (task 7.4).
|
|
875
|
+
*/
|
|
876
|
+
export function unregisterBuildQueue(app: Application): void {
|
|
877
|
+
app.eventQueue.unsubscribe(BUILD_VISUALIZATION_QUEUE_CHANNEL);
|
|
878
|
+
stopBuildVisualizationQueueProcessor(app);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Re-queue builds left in an in-progress phase by a worker that did not finish
|
|
883
|
+
* (Req 10.7). Finds `building` records whose heartbeat is stale/absent, resets
|
|
884
|
+
* them to `queued` with a fresh `buildRunId`, and re-enqueues them. Called on
|
|
885
|
+
* `afterStart` (task 7.4).
|
|
886
|
+
*/
|
|
887
|
+
export async function recoverInterruptedBuilds(app: Application): Promise<void> {
|
|
888
|
+
const repository = app.db.getRepository(COLLECTION_NAME) as Repository;
|
|
889
|
+
const model = getBuildModel(app);
|
|
890
|
+
const staleBefore = new Date(Date.now() - BUILD_STALE_MS);
|
|
891
|
+
const records = await repository.find({
|
|
892
|
+
filter: {
|
|
893
|
+
status: 'building',
|
|
894
|
+
$or: [{ buildHeartbeatAt: null }, { buildHeartbeatAt: { $lt: staleBefore } }],
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
let requeued = 0;
|
|
899
|
+
for (const record of records) {
|
|
900
|
+
const buildId = String(record.get('id'));
|
|
901
|
+
const previousRunId = record.get('buildRunId') || null;
|
|
902
|
+
const runId = String(previousRunId || crypto.randomUUID());
|
|
903
|
+
const [affected] = await model.update(
|
|
904
|
+
{
|
|
905
|
+
buildPhase: 'queued',
|
|
906
|
+
buildLog: 'Build re-queued after worker restart',
|
|
907
|
+
buildRunId: runId,
|
|
908
|
+
buildQueuedAt: new Date(),
|
|
909
|
+
buildStartedAt: null,
|
|
910
|
+
buildHeartbeatAt: null,
|
|
911
|
+
buildWorkerId: null,
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
where: {
|
|
915
|
+
id: buildId,
|
|
916
|
+
status: 'building',
|
|
917
|
+
buildRunId: previousRunId,
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
if (!affected) {
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
await enqueueBuild(app, {
|
|
927
|
+
buildId,
|
|
928
|
+
runId,
|
|
929
|
+
userId: null,
|
|
930
|
+
queuedAt: new Date().toISOString(),
|
|
931
|
+
});
|
|
932
|
+
requeued += 1;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (requeued) {
|
|
936
|
+
app.log?.info?.(`[plugin-build-visualization-block] Re-queued ${requeued} interrupted build(s)`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Fail builds that have exceeded the 30-min processing timeout (Req 10.6).
|
|
942
|
+
*
|
|
943
|
+
* A build is timed out when it is still `building`, its effective start
|
|
944
|
+
* (`buildStartedAt`, or `buildQueuedAt` when a worker never claimed it) is
|
|
945
|
+
* older than {@link BUILD_TIMEOUT_MS}, and its `buildHeartbeatAt` is stale or
|
|
946
|
+
* absent (so an actively-heartbeating run is never reaped). Matching records
|
|
947
|
+
* are marked `status:'error'`, `buildPhase:'failed'` with a timeout reason,
|
|
948
|
+
* leaving them in a `failed` state the user can {@link retry}.
|
|
949
|
+
*
|
|
950
|
+
* Each write is run-guarded on the record's `buildRunId` so a concurrent claim
|
|
951
|
+
* (a worker that picked the build up between read and update) is never
|
|
952
|
+
* clobbered. Called at the start of every queue tick on worker nodes.
|
|
953
|
+
*/
|
|
954
|
+
export async function failTimedOutBuilds(app: Application): Promise<void> {
|
|
955
|
+
const repository = app.db.getRepository(COLLECTION_NAME) as Repository;
|
|
956
|
+
const model = getBuildModel(app);
|
|
957
|
+
const now = Date.now();
|
|
958
|
+
const timeoutBefore = new Date(now - BUILD_TIMEOUT_MS);
|
|
959
|
+
const staleBefore = new Date(now - BUILD_STALE_MS);
|
|
960
|
+
|
|
961
|
+
const records = await repository.find({
|
|
962
|
+
filter: {
|
|
963
|
+
status: 'building',
|
|
964
|
+
$or: [
|
|
965
|
+
{ buildStartedAt: { $lt: timeoutBefore } },
|
|
966
|
+
{ $and: [{ buildStartedAt: null }, { buildQueuedAt: { $lt: timeoutBefore } }] },
|
|
967
|
+
],
|
|
968
|
+
},
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
let failed = 0;
|
|
972
|
+
for (const record of records) {
|
|
973
|
+
const heartbeatRaw = record.get('buildHeartbeatAt');
|
|
974
|
+
const heartbeatStale = !heartbeatRaw || new Date(heartbeatRaw as string).getTime() < staleBefore.getTime();
|
|
975
|
+
if (!heartbeatStale) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const buildId = String(record.get('id'));
|
|
980
|
+
const previousRunId = record.get('buildRunId') ?? null;
|
|
981
|
+
const [affected] = await model.update(
|
|
982
|
+
{
|
|
983
|
+
status: 'error',
|
|
984
|
+
buildPhase: 'failed',
|
|
985
|
+
errorMessage: 'Build timed out',
|
|
986
|
+
buildLog: 'Build timed out',
|
|
987
|
+
buildHeartbeatAt: new Date(),
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
where: {
|
|
991
|
+
id: buildId,
|
|
992
|
+
status: 'building',
|
|
993
|
+
buildRunId: previousRunId,
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
if (affected) {
|
|
999
|
+
failed += 1;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (failed) {
|
|
1004
|
+
app.log?.info?.(`[plugin-build-visualization-block] Failed ${failed} timed-out build(s)`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/* --------------------------------------------------------------------------
|
|
1009
|
+
* The `build` action (input validation + record creation + enqueue).
|
|
1010
|
+
* ----------------------------------------------------------------------- */
|
|
1011
|
+
|
|
1012
|
+
/** Read the current role names off the request context. */
|
|
1013
|
+
function getCurrentRoles(ctx: Context): string[] {
|
|
1014
|
+
const roles = (ctx.state as { currentRoles?: unknown })?.currentRoles;
|
|
1015
|
+
return Array.isArray(roles) ? roles.filter((role): role is string => typeof role === 'string') : [];
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function t(ctx: Context, key: string, options?: Record<string, unknown>): string {
|
|
1019
|
+
return ctx.t(key, { ns: PLUGIN_NAME, ...options });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function readStringArray(value: unknown): string[] {
|
|
1023
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function getRecordCollections(record: { get(key: string): unknown }): string[] {
|
|
1027
|
+
return readStringArray(record.get('collections'));
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function getRecordCreatedById(record: { get(key: string): unknown }): string | number | null {
|
|
1031
|
+
const direct = record.get('createdById');
|
|
1032
|
+
if (typeof direct === 'string' || typeof direct === 'number') {
|
|
1033
|
+
return direct;
|
|
1034
|
+
}
|
|
1035
|
+
const createdBy = record.get('createdBy');
|
|
1036
|
+
if (createdBy && typeof createdBy === 'object') {
|
|
1037
|
+
const id = (createdBy as { id?: unknown }).id;
|
|
1038
|
+
if (typeof id === 'string' || typeof id === 'number') {
|
|
1039
|
+
return id;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function getCurrentUserId(ctx: Context): string | number | null {
|
|
1046
|
+
const id = ctx.auth?.user?.id;
|
|
1047
|
+
return typeof id === 'string' || typeof id === 'number' ? id : null;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function sameId(left: string | number | null, right: string | number | null): boolean {
|
|
1051
|
+
return left !== null && right !== null && String(left) === String(right);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function assertBuildRecordAccess(ctx: Context, record: { get(key: string): unknown }): void {
|
|
1055
|
+
const roles = getCurrentRoles(ctx);
|
|
1056
|
+
if (roles.includes('root')) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (sameId(getRecordCreatedById(record), getCurrentUserId(ctx))) {
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const collections = getRecordCollections(record);
|
|
1063
|
+
if (collections.length === 0) {
|
|
1064
|
+
ctx.throw(403, t(ctx, 'You do not have permission to read this build'));
|
|
1065
|
+
}
|
|
1066
|
+
assertCollectionPermissions(ctx, collections);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** Narrow an unknown value to a non-empty trimmed string, or `undefined`. */
|
|
1070
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
1071
|
+
if (typeof value !== 'string') {
|
|
1072
|
+
return undefined;
|
|
1073
|
+
}
|
|
1074
|
+
const trimmed = value.trim();
|
|
1075
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Validate the raw action params and return a typed, normalized build input.
|
|
1080
|
+
* Validation failures raise `ctx.throw(...)`, which terminates the request.
|
|
1081
|
+
*/
|
|
1082
|
+
function validateInput(ctx: Context, values: BuildActionParamsValues): ValidatedBuildInput {
|
|
1083
|
+
// Req 1.4 / 1.6 — collections must be a non-empty string array within bounds.
|
|
1084
|
+
if (!Array.isArray(values.collections)) {
|
|
1085
|
+
ctx.throw(400, t(ctx, 'At least one collection is required'));
|
|
1086
|
+
}
|
|
1087
|
+
const collections = values.collections.filter(
|
|
1088
|
+
(item): item is string => typeof item === 'string' && item.trim().length > 0,
|
|
1089
|
+
);
|
|
1090
|
+
if (collections.length < MIN_COLLECTIONS) {
|
|
1091
|
+
ctx.throw(400, t(ctx, 'At least one collection is required'));
|
|
1092
|
+
}
|
|
1093
|
+
if (collections.length > MAX_COLLECTIONS) {
|
|
1094
|
+
ctx.throw(400, t(ctx, 'A maximum of {{max}} collections can be selected', { max: MAX_COLLECTIONS }));
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Req 3.2 / 3.3 — requirement must be a non-empty, length-bounded string.
|
|
1098
|
+
const requirement = asNonEmptyString(values.requirement);
|
|
1099
|
+
if (!requirement) {
|
|
1100
|
+
ctx.throw(400, t(ctx, 'A requirement is required'));
|
|
1101
|
+
}
|
|
1102
|
+
if (requirement.length > MAX_REQUIREMENT_CHARS) {
|
|
1103
|
+
ctx.throw(400, t(ctx, 'The requirement must be {{max}} characters or fewer', { max: MAX_REQUIREMENT_CHARS }));
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Req 4.3 — both an LLM service and a model must be selected.
|
|
1107
|
+
const llmService = asNonEmptyString(values.llmService);
|
|
1108
|
+
const model = asNonEmptyString(values.model);
|
|
1109
|
+
if (!llmService || !model) {
|
|
1110
|
+
ctx.throw(400, t(ctx, 'An LLM service and model are required'));
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const dataSource = asNonEmptyString(values.dataSource) ?? 'main';
|
|
1114
|
+
const primaryCollection = asNonEmptyString(values.primaryCollection) ?? collections[0];
|
|
1115
|
+
|
|
1116
|
+
return { requirement, collections, dataSource, primaryCollection, llmService, model };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Enforce per-collection `list` permission for the current role(s).
|
|
1121
|
+
* Denies the request with 403 when any selected collection is not listable.
|
|
1122
|
+
* Req 13.2 / 13.4.
|
|
1123
|
+
*/
|
|
1124
|
+
function assertCollectionPermissions(ctx: Context, collections: string[]): void {
|
|
1125
|
+
const roles = getCurrentRoles(ctx);
|
|
1126
|
+
// The built-in `root` role bypasses ACL checks (consistent with other plugins).
|
|
1127
|
+
if (roles.includes('root')) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const app = ctx.app as Application;
|
|
1131
|
+
for (const collection of collections) {
|
|
1132
|
+
const allowed = app.acl.can({ roles, resource: collection, action: 'list' });
|
|
1133
|
+
if (!allowed) {
|
|
1134
|
+
ctx.throw(403, t(ctx, 'You do not have permission to read collection "{{collection}}"', { collection }));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* `aiVisualizationBuilds:build` — validate the request, create a build record
|
|
1141
|
+
* in the `queued` phase, hand it to the build queue, and return the record id
|
|
1142
|
+
* immediately so the UI never blocks (Req 10.1).
|
|
1143
|
+
*/
|
|
1144
|
+
export async function build(ctx: Context, next: Next): Promise<void> {
|
|
1145
|
+
const params = ctx.action.params as { values?: BuildActionParamsValues };
|
|
1146
|
+
const values = params.values ?? {};
|
|
1147
|
+
|
|
1148
|
+
const input = validateInput(ctx, values);
|
|
1149
|
+
assertCollectionPermissions(ctx, input.collections);
|
|
1150
|
+
|
|
1151
|
+
const runId = crypto.randomUUID();
|
|
1152
|
+
const userId = ctx.auth?.user?.id ?? null;
|
|
1153
|
+
const repository = ctx.db.getRepository(COLLECTION_NAME) as Repository;
|
|
1154
|
+
|
|
1155
|
+
const record = await repository.create({
|
|
1156
|
+
values: {
|
|
1157
|
+
requirement: input.requirement,
|
|
1158
|
+
collections: input.collections,
|
|
1159
|
+
dataSource: input.dataSource,
|
|
1160
|
+
primaryCollection: input.primaryCollection,
|
|
1161
|
+
llmService: input.llmService,
|
|
1162
|
+
model: input.model,
|
|
1163
|
+
status: 'building',
|
|
1164
|
+
buildPhase: 'queued',
|
|
1165
|
+
buildRunId: runId,
|
|
1166
|
+
buildQueuedAt: new Date(),
|
|
1167
|
+
createdById: userId,
|
|
1168
|
+
},
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
const buildId = String(record.get('id'));
|
|
1172
|
+
|
|
1173
|
+
await enqueueBuild(ctx.app as Application, {
|
|
1174
|
+
buildId,
|
|
1175
|
+
runId,
|
|
1176
|
+
userId,
|
|
1177
|
+
queuedAt: new Date().toISOString(),
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
ctx.body = {
|
|
1181
|
+
id: record.get('id'),
|
|
1182
|
+
status: 'building',
|
|
1183
|
+
buildPhase: 'queued',
|
|
1184
|
+
};
|
|
1185
|
+
await next();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/* --------------------------------------------------------------------------
|
|
1189
|
+
* The `retry` and `getResult` actions.
|
|
1190
|
+
* ----------------------------------------------------------------------- */
|
|
1191
|
+
|
|
1192
|
+
/** Narrow `ctx.action.params.filterByTk` to a usable record id, or throw 400. */
|
|
1193
|
+
function getFilterByTk(ctx: Context): string | number {
|
|
1194
|
+
const filterByTk = (ctx.action.params as { filterByTk?: unknown }).filterByTk;
|
|
1195
|
+
if (typeof filterByTk === 'number') {
|
|
1196
|
+
return filterByTk;
|
|
1197
|
+
}
|
|
1198
|
+
if (typeof filterByTk === 'string' && filterByTk.trim().length > 0) {
|
|
1199
|
+
return filterByTk;
|
|
1200
|
+
}
|
|
1201
|
+
ctx.throw(400, t(ctx, 'A build id is required'));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* `aiVisualizationBuilds:retry` — re-queue an existing build with the SAME
|
|
1206
|
+
* inputs (requirement/collections/dataSource/llmService/model are left intact)
|
|
1207
|
+
* and a fresh `buildRunId`. The new run identity supersedes any in-flight run
|
|
1208
|
+
* via the stale-run guard. Prior outputs and worker bookkeeping are cleared so
|
|
1209
|
+
* the record starts a clean `queued` cycle (Req 12.3). Returns the record id.
|
|
1210
|
+
*/
|
|
1211
|
+
export async function retry(ctx: Context, next: Next): Promise<void> {
|
|
1212
|
+
const filterByTk = getFilterByTk(ctx);
|
|
1213
|
+
const repository = ctx.db.getRepository(COLLECTION_NAME) as Repository;
|
|
1214
|
+
const record = await repository.findById(filterByTk);
|
|
1215
|
+
if (!record) {
|
|
1216
|
+
ctx.throw(404, t(ctx, 'Build record not found'));
|
|
1217
|
+
}
|
|
1218
|
+
assertBuildRecordAccess(ctx, record);
|
|
1219
|
+
|
|
1220
|
+
const runId = crypto.randomUUID();
|
|
1221
|
+
const userId = ctx.auth?.user?.id ?? null;
|
|
1222
|
+
|
|
1223
|
+
await repository.update({
|
|
1224
|
+
filterByTk,
|
|
1225
|
+
values: {
|
|
1226
|
+
status: 'building',
|
|
1227
|
+
buildPhase: 'queued',
|
|
1228
|
+
buildRunId: runId,
|
|
1229
|
+
buildQueuedAt: new Date(),
|
|
1230
|
+
buildLog: 'Build re-queued via retry',
|
|
1231
|
+
errorMessage: null,
|
|
1232
|
+
blockSchema: null,
|
|
1233
|
+
blockSpec: null,
|
|
1234
|
+
adjustments: null,
|
|
1235
|
+
usedFallback: false,
|
|
1236
|
+
buildStartedAt: null,
|
|
1237
|
+
buildHeartbeatAt: null,
|
|
1238
|
+
buildWorkerId: null,
|
|
1239
|
+
},
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
const buildId = String(record.get('id'));
|
|
1243
|
+
|
|
1244
|
+
await enqueueBuild(ctx.app as Application, {
|
|
1245
|
+
buildId,
|
|
1246
|
+
runId,
|
|
1247
|
+
userId,
|
|
1248
|
+
queuedAt: new Date().toISOString(),
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
ctx.body = {
|
|
1252
|
+
id: record.get('id'),
|
|
1253
|
+
status: 'building',
|
|
1254
|
+
buildPhase: 'queued',
|
|
1255
|
+
};
|
|
1256
|
+
await next();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* `aiVisualizationBuilds:getResult` — the client's polling endpoint. Returns
|
|
1261
|
+
* the current phase/status plus the generated outputs for a single build
|
|
1262
|
+
* (Req 9.4). Responds 404 when the record does not exist.
|
|
1263
|
+
*/
|
|
1264
|
+
export async function getResult(ctx: Context, next: Next): Promise<void> {
|
|
1265
|
+
const filterByTk = getFilterByTk(ctx);
|
|
1266
|
+
const repository = ctx.db.getRepository(COLLECTION_NAME) as Repository;
|
|
1267
|
+
const record = await repository.findById(filterByTk);
|
|
1268
|
+
if (!record) {
|
|
1269
|
+
ctx.throw(404, t(ctx, 'Build record not found'));
|
|
1270
|
+
}
|
|
1271
|
+
assertBuildRecordAccess(ctx, record);
|
|
1272
|
+
|
|
1273
|
+
ctx.body = {
|
|
1274
|
+
id: record.get('id'),
|
|
1275
|
+
status: record.get('status'),
|
|
1276
|
+
buildPhase: record.get('buildPhase'),
|
|
1277
|
+
blockSchema: record.get('blockSchema'),
|
|
1278
|
+
blockSpec: record.get('blockSpec'),
|
|
1279
|
+
adjustments: record.get('adjustments'),
|
|
1280
|
+
usedFallback: record.get('usedFallback'),
|
|
1281
|
+
errorMessage: record.get('errorMessage'),
|
|
1282
|
+
};
|
|
1283
|
+
await next();
|
|
1284
|
+
}
|