plugin-build-guide-block 1.1.4 → 1.1.6

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.
Files changed (41) hide show
  1. package/dist/client/index.js +2 -2
  2. package/dist/externalVersion.js +7 -7
  3. package/dist/node_modules/sanitize-html/index.js +1 -1
  4. package/dist/node_modules/sanitize-html/package.json +1 -1
  5. package/dist/server/actions/build.js +682 -83
  6. package/dist/server/collections/ai-build-guide-spaces.js +20 -0
  7. package/dist/server/plugin.js +21 -19
  8. package/dist/server/tools/search-guides.js +41 -30
  9. package/package.json +2 -2
  10. package/src/client/components/BuildButton.tsx +20 -9
  11. package/src/server/actions/build.ts +768 -86
  12. package/src/server/collections/ai-build-guide-spaces.ts +77 -57
  13. package/src/server/plugin.ts +170 -163
  14. package/src/server/tools/search-guides.ts +113 -95
  15. package/dist/client/UserGuideBlock.d.ts +0 -2
  16. package/dist/client/UserGuideBlockInitializer.d.ts +0 -2
  17. package/dist/client/UserGuideBlockProvider.d.ts +0 -2
  18. package/dist/client/UserGuideManager.d.ts +0 -2
  19. package/dist/client/components/BuildButton.d.ts +0 -2
  20. package/dist/client/components/LLMServiceSelect.d.ts +0 -2
  21. package/dist/client/components/ModelSelect.d.ts +0 -2
  22. package/dist/client/components/SpaceSelect.d.ts +0 -2
  23. package/dist/client/components/StatusTag.d.ts +0 -2
  24. package/dist/client/index.d.ts +0 -1
  25. package/dist/client/locale.d.ts +0 -3
  26. package/dist/client/models/UserGuideBlockModel.d.ts +0 -9
  27. package/dist/client/models/index.d.ts +0 -9
  28. package/dist/client/plugin.d.ts +0 -5
  29. package/dist/client/schemaSettings.d.ts +0 -2
  30. package/dist/client/schemas/spacesSchema.d.ts +0 -437
  31. package/dist/index.d.ts +0 -2
  32. package/dist/locale/namespace.d.ts +0 -6
  33. package/dist/server/actions/build.d.ts +0 -2
  34. package/dist/server/actions/getHtml.d.ts +0 -2
  35. package/dist/server/actions/getMarkdown.d.ts +0 -2
  36. package/dist/server/collections/ai-build-guide-pages.d.ts +0 -2
  37. package/dist/server/collections/ai-build-guide-spaces.d.ts +0 -2
  38. package/dist/server/index.d.ts +0 -2
  39. package/dist/server/plugin.d.ts +0 -16
  40. package/dist/server/tools/index.d.ts +0 -1
  41. package/dist/server/tools/search-guides.d.ts +0 -28
@@ -1,5 +1,6 @@
1
1
  import { Context, Next } from '@nocobase/actions';
2
2
  import { Repository } from '@nocobase/database';
3
+ import type { Application } from '@nocobase/server';
3
4
  import sanitizeHtml from 'sanitize-html';
4
5
  // @ts-ignore
5
6
  import { PluginAIServer } from '@nocobase/plugin-ai';
@@ -16,12 +17,98 @@ const MAX_SOURCE_CHARS = 90000;
16
17
  const MIN_CHAPTERS = 1;
17
18
  const MAX_CHAPTERS = 12;
18
19
  const DEFAULT_TARGET_CHAPTERS = 5;
20
+ export const WORKER_JOB_BUILD_GUIDE_PROCESS = 'build-guide:process';
21
+
22
+ const BUILD_GUIDE_QUEUE_CHANNEL = 'plugin-build-guide-block.build';
23
+ const BUILD_GUIDE_QUEUE_CONCURRENCY = Math.max(
24
+ 1,
25
+ Number.parseInt(process.env.BUILD_GUIDE_QUEUE_CONCURRENCY || process.env.BUILD_GUIDE_MAX_CONCURRENCY || '1', 10) || 1,
26
+ );
27
+ const BUILD_GUIDE_QUEUE_TIMEOUT_MS = Math.max(
28
+ 60_000,
29
+ Number.parseInt(process.env.BUILD_GUIDE_QUEUE_TIMEOUT_MS || '', 10) || 30 * 60 * 1000,
30
+ );
31
+ const BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS = Math.max(
32
+ 1000,
33
+ Number.parseInt(process.env.BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS || '', 10) || 5000,
34
+ );
35
+ const BUILD_GUIDE_QUEUE_WAKE_CHANNEL = 'plugin-build-guide-block.build.wake';
36
+ const BUILD_GUIDE_QUEUE_REDIS_CONNECTION = 'plugin-build-guide-block.build.queue';
37
+ const BUILD_TRIGGER_LOCK_TTL_MS = 30_000;
38
+ const BUILD_RUN_LOCK_TTL_MS = Math.max(
39
+ 60_000,
40
+ Number.parseInt(process.env.BUILD_GUIDE_RUN_LOCK_TTL_MS || '', 10) || 24 * 60 * 60 * 1000,
41
+ );
42
+ const BUILD_HEARTBEAT_INTERVAL_MS = Math.max(
43
+ 5_000,
44
+ Number.parseInt(process.env.BUILD_GUIDE_HEARTBEAT_MS || '', 10) || 30_000,
45
+ );
46
+ const BUILD_STALE_MS = Math.max(
47
+ BUILD_HEARTBEAT_INTERVAL_MS * 2,
48
+ Number.parseInt(process.env.BUILD_GUIDE_STALE_MS || '', 10) || 120_000,
49
+ );
50
+
51
+ const TEXT_EXTENSIONS = new Set([
52
+ '.txt',
53
+ '.md',
54
+ '.markdown',
55
+ '.csv',
56
+ '.tsv',
57
+ '.json',
58
+ '.xml',
59
+ '.html',
60
+ '.htm',
61
+ '.yaml',
62
+ '.yml',
63
+ '.log',
64
+ '.sql',
65
+ '.js',
66
+ '.jsx',
67
+ '.ts',
68
+ '.tsx',
69
+ '.css',
70
+ '.scss',
71
+ '.less',
72
+ ]);
73
+ const TEXT_MIMETYPES = new Set([
74
+ 'application/json',
75
+ 'application/xml',
76
+ 'application/yaml',
77
+ 'application/x-yaml',
78
+ 'application/javascript',
79
+ 'application/typescript',
80
+ 'image/svg+xml',
81
+ ]);
19
82
 
20
83
  const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
21
84
  allowedTags: [
22
- 'div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
23
- 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
24
- 'a', 'img', 'span', 'strong', 'em', 'code', 'pre', 'blockquote', 'br', 'hr',
85
+ 'div',
86
+ 'p',
87
+ 'h1',
88
+ 'h2',
89
+ 'h3',
90
+ 'h4',
91
+ 'h5',
92
+ 'h6',
93
+ 'ul',
94
+ 'ol',
95
+ 'li',
96
+ 'table',
97
+ 'thead',
98
+ 'tbody',
99
+ 'tr',
100
+ 'td',
101
+ 'th',
102
+ 'a',
103
+ 'img',
104
+ 'span',
105
+ 'strong',
106
+ 'em',
107
+ 'code',
108
+ 'pre',
109
+ 'blockquote',
110
+ 'br',
111
+ 'hr',
25
112
  ],
26
113
  allowedAttributes: {
27
114
  a: ['href', 'target'],
@@ -49,13 +136,115 @@ type GuidePlan = {
49
136
  chapters: GuidePlanItem[];
50
137
  };
51
138
 
139
+ type BuildGuideQueueMessage = {
140
+ spaceId: string;
141
+ runId: string;
142
+ userId?: number | string | null;
143
+ queuedAt?: string;
144
+ };
145
+
146
+ type BuildRunContext = {
147
+ spaceId: string;
148
+ runId: string;
149
+ };
150
+
151
+ let buildQueueTimer: NodeJS.Timeout | null = null;
152
+ let buildQueueKickTimer: NodeJS.Timeout | null = null;
153
+ let buildQueueProcessing = false;
154
+ let buildQueueWakeHandler: ((message?: any) => Promise<void>) | null = null;
155
+
156
+ class StaleBuildRunError extends Error {
157
+ constructor(spaceId: string, runId: string) {
158
+ super(`Build run ${runId} for space ${spaceId} is no longer current`);
159
+ this.name = 'StaleBuildRunError';
160
+ }
161
+ }
162
+
52
163
  function clampChapterCount(value: unknown) {
53
164
  const count = Number(value);
54
165
  if (!Number.isFinite(count)) return DEFAULT_TARGET_CHAPTERS;
55
166
  return Math.max(MIN_CHAPTERS, Math.min(MAX_CHAPTERS, Math.round(count)));
56
167
  }
57
168
 
58
- async function fetchFileContent(app: any, file: any): Promise<string> {
169
+ function resolveExtname(file: any) {
170
+ const explicit = file?.extname;
171
+ if (typeof explicit === 'string' && explicit) return explicit.toLowerCase();
172
+ const name = file?.filename || file?.name || '';
173
+ const index = String(name).lastIndexOf('.');
174
+ return index >= 0 ? String(name).slice(index).toLowerCase() : '';
175
+ }
176
+
177
+ function isTextDocument(file: any) {
178
+ const mimetype = String(file?.mimetype || '').toLowerCase();
179
+ if (mimetype.startsWith('text/')) return true;
180
+ if (TEXT_MIMETYPES.has(mimetype)) return true;
181
+ return TEXT_EXTENSIONS.has(resolveExtname(file));
182
+ }
183
+
184
+ function createParserContext(app: any) {
185
+ const headers: Record<string, string> = { 'x-timezone': '+00:00', 'x-locale': 'en-US' };
186
+ return {
187
+ app,
188
+ db: app.db,
189
+ log: app.log || app.logger || console,
190
+ logger: app.logger || app.log || console,
191
+ state: {},
192
+ auth: {},
193
+ req: { headers },
194
+ request: { headers },
195
+ get(name: string) {
196
+ return headers[String(name).toLowerCase()] || '';
197
+ },
198
+ getCurrentLocale() {
199
+ return 'en-US';
200
+ },
201
+ t(key: string) {
202
+ return key;
203
+ },
204
+ i18n: {
205
+ t(key: string) {
206
+ return key;
207
+ },
208
+ },
209
+ };
210
+ }
211
+
212
+ function extractParsedText(value: any): string {
213
+ if (!value) return '';
214
+ if (typeof value === 'string') return value;
215
+ if (Array.isArray(value)) {
216
+ return value.map(extractParsedText).filter(Boolean).join('\n');
217
+ }
218
+ if (typeof value === 'object') {
219
+ if (typeof value.text === 'string') return value.text;
220
+ if (typeof value.content === 'string') return value.content;
221
+ if (value.content) return extractParsedText(value.content);
222
+ if (value.message) return extractParsedText(value.message);
223
+ }
224
+ return '';
225
+ }
226
+
227
+ function getDocumentParserPlugin(app: any) {
228
+ return app.pm?.get?.('@nocobase/plugin-document-parser') || app.pm?.get?.('plugin-document-parser') || null;
229
+ }
230
+
231
+ function unsupportedDocumentMessage(file: any) {
232
+ const filename = file?.filename || file?.name || file?.id || 'document';
233
+ const type = file?.mimetype || resolveExtname(file) || 'unknown type';
234
+ return `[Unsupported document type: ${filename} (${type}). Install or enable plugin-document-parser/MarkItDown to extract this file.]`;
235
+ }
236
+
237
+ async function fetchTextFileContent(app: any, file: any): Promise<string> {
238
+ if (!isTextDocument(file)) {
239
+ return unsupportedDocumentMessage(file);
240
+ }
241
+
242
+ const docParserPlugin = getDocumentParserPlugin(app);
243
+ if (docParserPlugin?.fetchFileBuffer) {
244
+ const { buffer } = await docParserPlugin.fetchFileBuffer(createParserContext(app), file);
245
+ return buffer.toString('utf8');
246
+ }
247
+
59
248
  const fileManager = app.pm.get('file-manager') as PluginFileManagerServer;
60
249
  if (!fileManager) return '';
61
250
  const url = await fileManager.getFileURL(file);
@@ -78,6 +267,47 @@ async function fetchFileContent(app: any, file: any): Promise<string> {
78
267
  }
79
268
  }
80
269
 
270
+ async function parseWithDocumentParser(app: any, file: any): Promise<string> {
271
+ const docParserPlugin = getDocumentParserPlugin(app);
272
+ if (!docParserPlugin) return '';
273
+
274
+ const parserCtx = createParserContext(app);
275
+ const defaultParser = async () => ({
276
+ placement: 'contentBlocks',
277
+ content: {
278
+ type: 'text',
279
+ text: await fetchTextFileContent(app, file),
280
+ },
281
+ });
282
+
283
+ try {
284
+ if (docParserPlugin.parseRouter?.route) {
285
+ const result = await docParserPlugin.parseRouter.route(parserCtx, file, defaultParser);
286
+ const text = extractParsedText(result?.content);
287
+ if (text && !text.startsWith('[Unsupported document type:')) {
288
+ return text;
289
+ }
290
+ }
291
+
292
+ if (docParserPlugin.internalParserRegistry?.parse) {
293
+ const result = await docParserPlugin.internalParserRegistry.parse(file, parserCtx);
294
+ if (result?.handled && result?.text?.trim()) {
295
+ return result.text;
296
+ }
297
+ }
298
+ } catch (err) {
299
+ app.log?.warn?.(`[plugin-build-guide-block] Document parser failed for ${file?.filename || file?.id}`, err);
300
+ }
301
+
302
+ return '';
303
+ }
304
+
305
+ async function fetchFileContent(app: any, file: any): Promise<string> {
306
+ const parsedText = await parseWithDocumentParser(app, file);
307
+ if (parsedText) return parsedText;
308
+ return fetchTextFileContent(app, file);
309
+ }
310
+
81
311
  function toPlainText(value: unknown) {
82
312
  if (typeof value === 'string') return value;
83
313
  if (Array.isArray(value)) {
@@ -250,7 +480,13 @@ ${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
250
480
  return normalizePlan(toPlainText(response.content), title || 'User guide', targetCount);
251
481
  }
252
482
 
253
- async function buildPageMarkdown(provider: any, space: any, plan: GuidePlan, chapter: GuidePlanItem, documentsText: string) {
483
+ async function buildPageMarkdown(
484
+ provider: any,
485
+ space: any,
486
+ plan: GuidePlan,
487
+ chapter: GuidePlanItem,
488
+ documentsText: string,
489
+ ) {
254
490
  const { title, systemPrompt } = space.get();
255
491
  const messages = [];
256
492
  if (systemPrompt) {
@@ -301,15 +537,85 @@ async function readDocuments(app: any, space: any) {
301
537
  return texts.join('\n');
302
538
  }
303
539
 
304
- async function runBuild(app: any, db: any, filterByTk: string) {
540
+ function getSpaceModel(app: any) {
541
+ return app.db.getModel('aiBuildGuideSpaces');
542
+ }
543
+
544
+ async function updateSpaceForRun(app: any, run: BuildRunContext, values: Record<string, any>, optional = false) {
545
+ const SpaceModel = getSpaceModel(app);
546
+ const [affected] = await SpaceModel.update(values, {
547
+ where: {
548
+ id: run.spaceId,
549
+ buildRunId: run.runId,
550
+ },
551
+ });
552
+ if (!affected && !optional) {
553
+ throw new StaleBuildRunError(run.spaceId, run.runId);
554
+ }
555
+ return affected > 0;
556
+ }
557
+
558
+ async function claimBuildRun(app: any, run: BuildRunContext, workerId: string) {
559
+ const now = new Date();
560
+ const SpaceModel = getSpaceModel(app);
561
+ const [affected] = await SpaceModel.update(
562
+ {
563
+ buildPhase: 'running',
564
+ buildStartedAt: now,
565
+ buildHeartbeatAt: now,
566
+ buildWorkerId: workerId,
567
+ },
568
+ {
569
+ where: {
570
+ id: run.spaceId,
571
+ status: 'building',
572
+ buildPhase: 'queued',
573
+ buildRunId: run.runId,
574
+ },
575
+ },
576
+ );
577
+ return affected > 0;
578
+ }
579
+
580
+ function getBuildWorkerId(app: any) {
581
+ return [
582
+ process.env.HOSTNAME || process.env.COMPUTERNAME || 'worker',
583
+ app.name || 'app',
584
+ app.instanceId || '0',
585
+ process.pid,
586
+ ].join(':');
587
+ }
588
+
589
+ function startBuildHeartbeat(app: any, run: BuildRunContext) {
590
+ const timer = setInterval(() => {
591
+ updateSpaceForRun(
592
+ app,
593
+ run,
594
+ {
595
+ buildHeartbeatAt: new Date(),
596
+ },
597
+ true,
598
+ ).catch((error) => {
599
+ app.log?.warn?.(`[plugin-build-guide-block] Failed to update heartbeat for build ${run.runId}`, error);
600
+ });
601
+ }, BUILD_HEARTBEAT_INTERVAL_MS);
602
+
603
+ return () => clearInterval(timer);
604
+ }
605
+
606
+ async function runBuild(app: any, db: any, run: BuildRunContext) {
305
607
  const spaceRepo = db.getRepository('aiBuildGuideSpaces') as Repository;
306
608
  const pageRepo = db.getRepository('aiBuildGuidePages') as Repository;
307
- const space = await spaceRepo.findById(filterByTk);
609
+ const space = await spaceRepo.findById(run.spaceId);
308
610
 
309
611
  if (!space) {
310
612
  throw new Error('Space not found');
311
613
  }
312
614
 
615
+ if (space.get('buildRunId') !== run.runId) {
616
+ throw new StaleBuildRunError(run.spaceId, run.runId);
617
+ }
618
+
313
619
  const { llmService, model } = space.get();
314
620
  if (!llmService || !model) {
315
621
  throw new Error('LLM Service or model is missing in space configuration');
@@ -317,51 +623,42 @@ async function runBuild(app: any, db: any, filterByTk: string) {
317
623
 
318
624
  await pageRepo.destroy({
319
625
  filter: {
320
- spaceId: filterByTk,
626
+ spaceId: run.spaceId,
321
627
  },
322
628
  });
323
629
 
324
- await spaceRepo.update({
325
- filterByTk,
326
- values: {
327
- buildPhase: 'reading',
328
- buildLog: 'Reading source documents',
329
- generatedHtml: null,
330
- generatedMarkdown: null,
331
- planJson: null,
332
- pageCount: 0,
333
- },
630
+ await updateSpaceForRun(app, run, {
631
+ buildPhase: 'reading',
632
+ buildLog: 'Reading source documents',
633
+ generatedHtml: null,
634
+ generatedMarkdown: null,
635
+ planJson: null,
636
+ pageCount: 0,
334
637
  });
335
638
 
336
639
  const documentsText = await readDocuments(app, space);
337
640
  const sourceHash = crypto.createHash('sha256').update(documentsText).digest('hex');
338
641
  const provider = await getLLMProvider(app, llmService, model);
339
642
 
340
- await spaceRepo.update({
341
- filterByTk,
342
- values: {
343
- buildPhase: 'planning',
344
- buildLog: 'Creating guide breakdown plan',
345
- sourceHash,
346
- },
643
+ await updateSpaceForRun(app, run, {
644
+ buildPhase: 'planning',
645
+ buildLog: 'Creating guide breakdown plan',
646
+ sourceHash,
347
647
  });
348
648
 
349
649
  const plan = await buildPlan(provider, space, documentsText);
350
- await spaceRepo.update({
351
- filterByTk,
352
- values: {
353
- planJson: plan,
354
- pageCount: plan.chapters.length,
355
- buildPhase: 'building_pages',
356
- buildLog: `Plan created with ${plan.chapters.length} chapters`,
357
- },
650
+ await updateSpaceForRun(app, run, {
651
+ planJson: plan,
652
+ pageCount: plan.chapters.length,
653
+ buildPhase: 'building_pages',
654
+ buildLog: `Plan created with ${plan.chapters.length} chapters`,
358
655
  });
359
656
 
360
657
  const pageRecords = [];
361
658
  for (const [index, chapter] of plan.chapters.entries()) {
362
659
  const page = await pageRepo.create({
363
660
  values: {
364
- spaceId: filterByTk,
661
+ spaceId: run.spaceId,
365
662
  sort: index + 1,
366
663
  title: chapter.title,
367
664
  slug: slugify(chapter.title, `chapter-${index + 1}`),
@@ -383,12 +680,9 @@ async function runBuild(app: any, db: any, filterByTk: string) {
383
680
  buildLog: 'Building chapter with LLM',
384
681
  },
385
682
  });
386
- await spaceRepo.update({
387
- filterByTk,
388
- values: {
389
- buildPhase: 'building_pages',
390
- buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
391
- },
683
+ await updateSpaceForRun(app, run, {
684
+ buildPhase: 'building_pages',
685
+ buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
392
686
  });
393
687
 
394
688
  try {
@@ -417,7 +711,7 @@ async function runBuild(app: any, db: any, filterByTk: string) {
417
711
 
418
712
  const completedPages = await pageRepo.find({
419
713
  filter: {
420
- spaceId: filterByTk,
714
+ spaceId: run.spaceId,
421
715
  status: 'completed',
422
716
  },
423
717
  sort: ['sort'],
@@ -428,74 +722,462 @@ async function runBuild(app: any, db: any, filterByTk: string) {
428
722
  .join('\n\n---\n\n');
429
723
  const combinedHtml = await markdownToCleanHtml(combinedMarkdown);
430
724
 
431
- await spaceRepo.update({
432
- filterByTk,
433
- values: {
434
- status: 'completed',
435
- buildPhase: 'completed',
436
- buildLog: `Built ${completedPages.length} chapters successfully`,
437
- generatedMarkdown: combinedMarkdown,
438
- generatedHtml: combinedHtml,
439
- },
725
+ await updateSpaceForRun(app, run, {
726
+ status: 'completed',
727
+ buildPhase: 'completed',
728
+ buildLog: `Built ${completedPages.length} chapters successfully`,
729
+ generatedMarkdown: combinedMarkdown,
730
+ generatedHtml: combinedHtml,
731
+ buildHeartbeatAt: new Date(),
440
732
  });
441
733
  }
442
734
 
443
- export async function build(ctx: Context, next: Next) {
444
- const { filterByTk } = ctx.action.params;
445
- const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
735
+ function isBuildGuideWorker(app: Application) {
736
+ const workerMode = process.env.WORKER_MODE || '';
737
+ return (
738
+ app.serving(WORKER_JOB_BUILD_GUIDE_PROCESS) ||
739
+ workerMode === 'worker' ||
740
+ workerMode === 'task' ||
741
+ process.env.APP_ROLE === 'worker'
742
+ );
743
+ }
446
744
 
447
- const space = await repository.findById(filterByTk);
745
+ function clearLocalBuildMemoryQueue(app: Application) {
746
+ const eventQueue = (app as any).eventQueue;
747
+ const adapter = eventQueue?.adapter;
748
+ const fullChannel = eventQueue?.getFullChannel?.(BUILD_GUIDE_QUEUE_CHANNEL);
749
+ const queue = fullChannel ? adapter?.queues?.get?.(fullChannel) : null;
750
+ if (!queue?.length) return;
448
751
 
449
- if (!space) {
450
- ctx.throw(404, 'Space not found');
752
+ adapter.queues.set(fullChannel, []);
753
+ app.log?.warn?.(
754
+ `[plugin-build-guide-block] Cleared ${queue.length} stale local memory message(s) on non-worker node; queued DB builds will be picked up by workers`,
755
+ );
756
+ }
757
+
758
+ function getBuildQueueRedisKey(app: Application): string {
759
+ const appName = (app as any).name || process.env.APP_NAME || 'main';
760
+ return `${appName}:plugin-build-guide-block:build:queue`;
761
+ }
762
+
763
+ async function getBuildQueueRedis(app: Application): Promise<any | null> {
764
+ const manager = (app as any).redisConnectionManager;
765
+ if (!manager?.getConnectionSync) {
766
+ return null;
451
767
  }
452
768
 
453
- if (space.get('status') === 'building') {
454
- ctx.throw(409, 'A build is already in progress for this space');
769
+ try {
770
+ const connectionString = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
771
+ return await manager.getConnectionSync(
772
+ BUILD_GUIDE_QUEUE_REDIS_CONNECTION,
773
+ connectionString ? { connectionString } : undefined,
774
+ );
775
+ } catch (error: any) {
776
+ app.log?.debug?.(
777
+ `[plugin-build-guide-block] Redis queue unavailable; DB polling fallback active: ${error?.message || error}`,
778
+ );
779
+ return null;
455
780
  }
781
+ }
456
782
 
457
- const app = ctx.app;
458
- const db = ctx.db;
783
+ async function enqueueBuildToRedis(app: Application, message: BuildGuideQueueMessage): Promise<boolean> {
784
+ const redis = await getBuildQueueRedis(app);
785
+ if (!redis) return false;
459
786
 
460
787
  try {
461
- await repository.update({
462
- filterByTk,
788
+ await redis.sendCommand(['RPUSH', getBuildQueueRedisKey(app), JSON.stringify(message)]);
789
+ app.log?.debug?.(
790
+ `[plugin-build-guide-block] Enqueued build ${message.runId} for space "${message.spaceId}" to Redis`,
791
+ );
792
+ return true;
793
+ } catch (error: any) {
794
+ app.log?.warn?.(`[plugin-build-guide-block] Failed to enqueue build to Redis; DB polling fallback active`, error);
795
+ return false;
796
+ }
797
+ }
798
+
799
+ async function publishBuildQueueWake(app: Application, message?: BuildGuideQueueMessage) {
800
+ try {
801
+ await (app as any).pubSubManager?.publish?.(
802
+ BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
803
+ { spaceId: message?.spaceId, runId: message?.runId },
804
+ { skipSelf: !isBuildGuideWorker(app) },
805
+ );
806
+ } catch (error: any) {
807
+ app.log?.debug?.(`[plugin-build-guide-block] Wake publish skipped: ${error?.message || error}`);
808
+ }
809
+ }
810
+
811
+ function startBuildGuideQueueProcessor(app: Application) {
812
+ if (!isBuildGuideWorker(app)) {
813
+ app.log?.debug?.('[plugin-build-guide-block] Build queue processor disabled on non-worker node');
814
+ return;
815
+ }
816
+ if (buildQueueTimer) return;
817
+
818
+ buildQueueWakeHandler = async () => {
819
+ scheduleBuildQueueTick(app, 0);
820
+ };
821
+
822
+ const subscribe = (app as any).pubSubManager?.subscribe?.(BUILD_GUIDE_QUEUE_WAKE_CHANNEL, buildQueueWakeHandler);
823
+ if (subscribe?.catch) {
824
+ subscribe.catch((error: any) => {
825
+ app.log?.debug?.(`[plugin-build-guide-block] Wake subscribe skipped: ${error?.message || error}`);
826
+ });
827
+ }
828
+
829
+ buildQueueTimer = setInterval(() => scheduleBuildQueueTick(app, 0), BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS);
830
+ (buildQueueTimer as any).unref?.();
831
+ scheduleBuildQueueTick(app, 1000);
832
+ app.log?.info?.(
833
+ `[plugin-build-guide-block] Build queue processor started (interval ${BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS}ms)`,
834
+ );
835
+ }
836
+
837
+ function stopBuildGuideQueueProcessor(app: Application) {
838
+ if (buildQueueTimer) {
839
+ clearInterval(buildQueueTimer);
840
+ buildQueueTimer = null;
841
+ }
842
+ if (buildQueueKickTimer) {
843
+ clearTimeout(buildQueueKickTimer);
844
+ buildQueueKickTimer = null;
845
+ }
846
+ if (buildQueueWakeHandler) {
847
+ const unsubscribe = (app as any).pubSubManager?.unsubscribe?.(
848
+ BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
849
+ buildQueueWakeHandler,
850
+ );
851
+ if (unsubscribe?.catch) {
852
+ unsubscribe.catch(() => undefined);
853
+ }
854
+ buildQueueWakeHandler = null;
855
+ }
856
+ buildQueueProcessing = false;
857
+ }
858
+
859
+ function scheduleBuildQueueTick(app: Application, delayMs: number) {
860
+ if (buildQueueKickTimer) return;
861
+ buildQueueKickTimer = setTimeout(() => {
862
+ buildQueueKickTimer = null;
863
+ runBuildQueueTick(app).catch((error) => {
864
+ app.log?.error?.('[plugin-build-guide-block] Build queue tick failed', error);
865
+ });
866
+ }, delayMs);
867
+ (buildQueueKickTimer as any).unref?.();
868
+ }
869
+
870
+ async function runBuildQueueTick(app: Application) {
871
+ if (buildQueueProcessing || !isBuildGuideWorker(app)) return;
872
+
873
+ buildQueueProcessing = true;
874
+ try {
875
+ const redisMessages = await drainRedisBuildQueue(app, BUILD_GUIDE_QUEUE_CONCURRENCY);
876
+ await processBuildQueueMessages(app, redisMessages);
877
+
878
+ const remaining = Math.max(1, BUILD_GUIDE_QUEUE_CONCURRENCY - redisMessages.length);
879
+ await processQueuedBuildsFromDb(app, remaining);
880
+ } finally {
881
+ buildQueueProcessing = false;
882
+ }
883
+ }
884
+
885
+ async function drainRedisBuildQueue(app: Application, count: number): Promise<BuildGuideQueueMessage[]> {
886
+ const redis = await getBuildQueueRedis(app);
887
+ if (!redis) return [];
888
+
889
+ const key = getBuildQueueRedisKey(app);
890
+ const messages: BuildGuideQueueMessage[] = [];
891
+ for (let i = 0; i < count; i += 1) {
892
+ const raw = await redis.sendCommand(['LPOP', key]);
893
+ if (!raw) break;
894
+ try {
895
+ messages.push(JSON.parse(String(raw)));
896
+ } catch (error: any) {
897
+ app.log?.warn?.(`[plugin-build-guide-block] Dropped invalid Redis build message: ${error?.message || error}`);
898
+ }
899
+ }
900
+ return messages;
901
+ }
902
+
903
+ function createBuildQueueMessageFromSpace(space: any): BuildGuideQueueMessage | null {
904
+ const runId = space.get('buildRunId');
905
+ if (!runId) return null;
906
+ return {
907
+ spaceId: String(space.get('id')),
908
+ runId: String(runId),
909
+ queuedAt: space.get('buildQueuedAt') ? new Date(space.get('buildQueuedAt')).toISOString() : undefined,
910
+ };
911
+ }
912
+
913
+ async function processQueuedBuildsFromDb(app: Application, count: number) {
914
+ const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
915
+ const spaces = await spaceRepo.find({
916
+ filter: {
917
+ status: 'building',
918
+ buildPhase: 'queued',
919
+ },
920
+ sort: ['buildQueuedAt'],
921
+ limit: count,
922
+ });
923
+ const messages = spaces.map(createBuildQueueMessageFromSpace).filter(Boolean) as BuildGuideQueueMessage[];
924
+ await processBuildQueueMessages(app, messages);
925
+ }
926
+
927
+ async function processBuildQueueMessages(app: Application, messages: BuildGuideQueueMessage[]) {
928
+ if (!messages.length) return;
929
+ await Promise.all(messages.map((message) => processQueuedBuild(app, message)));
930
+ }
931
+
932
+ async function markBuildError(app: Application, spaceId: string, runId: string | undefined, error: any) {
933
+ const buildLog = error?.message || String(error);
934
+ let updated = false;
935
+ if (runId) {
936
+ updated = await updateSpaceForRun(
937
+ app,
938
+ { spaceId, runId },
939
+ {
940
+ status: 'error',
941
+ buildPhase: 'error',
942
+ buildLog,
943
+ buildHeartbeatAt: new Date(),
944
+ },
945
+ true,
946
+ );
947
+ } else {
948
+ await app.db.getRepository('aiBuildGuideSpaces').update({
949
+ filterByTk: spaceId,
463
950
  values: {
464
- status: 'building',
465
- buildPhase: 'queued',
466
- buildLog: 'Build queued',
951
+ status: 'error',
952
+ buildPhase: 'error',
953
+ buildLog,
467
954
  },
468
955
  });
956
+ updated = true;
957
+ }
958
+
959
+ if (!updated) {
960
+ return;
961
+ }
962
+
963
+ await app.db.getRepository('aiBuildGuidePages').update({
964
+ filter: {
965
+ spaceId,
966
+ status: 'building',
967
+ },
968
+ values: {
969
+ status: 'error',
970
+ buildLog,
971
+ },
972
+ });
973
+ }
974
+
975
+ async function enqueueBuild(app: Application, message: BuildGuideQueueMessage) {
976
+ try {
977
+ const queuedInRedis = await enqueueBuildToRedis(app, message);
978
+ if (queuedInRedis) {
979
+ await publishBuildQueueWake(app, message);
980
+ return;
981
+ }
982
+
983
+ await publishBuildQueueWake(app, message);
984
+
985
+ if (isBuildGuideWorker(app)) {
986
+ await app.eventQueue.publish(BUILD_GUIDE_QUEUE_CHANNEL, message, {
987
+ timeout: BUILD_GUIDE_QUEUE_TIMEOUT_MS,
988
+ maxRetries: 0,
989
+ });
990
+ return;
991
+ }
992
+
993
+ app.log?.warn?.(
994
+ `[plugin-build-guide-block] Redis queue is unavailable; build ${message.runId} for space "${message.spaceId}" will remain queued until a worker DB poller picks it up`,
995
+ );
996
+ } catch (error) {
997
+ await markBuildError(app, message.spaceId, message.runId, error);
998
+ throw error;
999
+ }
1000
+ }
1001
+
1002
+ async function processQueuedBuild(app: Application, message: BuildGuideQueueMessage) {
1003
+ const spaceId = message?.spaceId;
1004
+ const runId = message?.runId;
1005
+ if (!spaceId || !runId) {
1006
+ app.log?.warn?.('[plugin-build-guide-block] Build queue message missing spaceId or runId');
1007
+ return;
1008
+ }
1009
+
1010
+ await withBuildRunLock(app, spaceId, async () => {
1011
+ const run = { spaceId, runId };
1012
+ const workerId = getBuildWorkerId(app);
1013
+ const claimed = await claimBuildRun(app, run, workerId);
1014
+ if (!claimed) {
1015
+ app.log?.info?.(`[plugin-build-guide-block] Build ${runId} for space "${spaceId}" was already claimed or stale`);
1016
+ return;
1017
+ }
1018
+
1019
+ const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
1020
+ const space = await spaceRepo.findById(spaceId);
1021
+ if (!space) {
1022
+ app.log?.warn?.(`[plugin-build-guide-block] Build space "${spaceId}" not found; skipping queued build`);
1023
+ return;
1024
+ }
1025
+
1026
+ if (space.get('status') !== 'building') {
1027
+ app.log?.info?.(
1028
+ `[plugin-build-guide-block] Build space "${spaceId}" is ${space.get('status')}; skipping queued build`,
1029
+ );
1030
+ return;
1031
+ }
469
1032
 
470
- runBuild(app, db, filterByTk).catch(async (error) => {
471
- app.log.error('Build Guide Background Error', error);
472
- try {
473
- await repository.update({
474
- filterByTk,
475
- values: {
476
- status: 'error',
477
- buildPhase: 'error',
478
- buildLog: error.message || String(error),
479
- },
480
- });
481
- } catch (updateErr) {
482
- app.log.error('Failed to persist build error status', updateErr);
1033
+ const stopHeartbeat = startBuildHeartbeat(app, run);
1034
+ try {
1035
+ await runBuild(app, app.db, run);
1036
+ } catch (error) {
1037
+ if (error instanceof StaleBuildRunError) {
1038
+ app.log?.info?.(`[plugin-build-guide-block] ${error.message}`);
1039
+ return;
483
1040
  }
1041
+ app.log?.error?.('Build Guide Worker Error', error);
1042
+ await markBuildError(app, spaceId, runId, error);
1043
+ } finally {
1044
+ stopHeartbeat();
1045
+ }
1046
+ });
1047
+ }
1048
+
1049
+ export function registerBuildGuideQueue(app: Application) {
1050
+ app.eventQueue.subscribe(BUILD_GUIDE_QUEUE_CHANNEL, {
1051
+ concurrency: BUILD_GUIDE_QUEUE_CONCURRENCY,
1052
+ idle: () => isBuildGuideWorker(app),
1053
+ process: async (message: BuildGuideQueueMessage) => {
1054
+ await processQueuedBuild(app, message);
1055
+ },
1056
+ });
1057
+ if (!isBuildGuideWorker(app)) {
1058
+ app.on('afterStart', () => clearLocalBuildMemoryQueue(app));
1059
+ }
1060
+ startBuildGuideQueueProcessor(app);
1061
+ }
1062
+
1063
+ export function unregisterBuildGuideQueue(app: Application) {
1064
+ app.eventQueue.unsubscribe(BUILD_GUIDE_QUEUE_CHANNEL);
1065
+ stopBuildGuideQueueProcessor(app);
1066
+ }
1067
+
1068
+ async function withBuildTriggerLock<T>(app: Application, spaceId: string, fn: () => Promise<T>) {
1069
+ return app.lockManager.runExclusive(`build-guide:trigger:${spaceId}`, fn, BUILD_TRIGGER_LOCK_TTL_MS);
1070
+ }
1071
+
1072
+ async function withBuildRunLock<T>(app: Application, spaceId: string, fn: () => Promise<T>) {
1073
+ return app.lockManager.runExclusive(`build-guide:run:${spaceId}`, fn, BUILD_RUN_LOCK_TTL_MS);
1074
+ }
1075
+
1076
+ export async function recoverInterruptedBuilds(app: Application) {
1077
+ const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
1078
+ const pageRepo = app.db.getRepository('aiBuildGuidePages') as Repository;
1079
+ const staleBefore = new Date(Date.now() - BUILD_STALE_MS);
1080
+ const spaces = await spaceRepo.find({
1081
+ filter: {
1082
+ status: 'building',
1083
+ $or: [{ buildHeartbeatAt: null }, { buildHeartbeatAt: { $lt: staleBefore } }],
1084
+ },
1085
+ });
1086
+
1087
+ for (const space of spaces) {
1088
+ const spaceId = String(space.get('id'));
1089
+ const runId = String(space.get('buildRunId') || crypto.randomUUID());
1090
+ const SpaceModel = getSpaceModel(app);
1091
+ const [affected] = await SpaceModel.update(
1092
+ {
1093
+ buildPhase: 'queued',
1094
+ buildLog: 'Build re-queued after worker restart',
1095
+ buildRunId: runId,
1096
+ buildQueuedAt: new Date(),
1097
+ buildStartedAt: null,
1098
+ buildHeartbeatAt: null,
1099
+ buildWorkerId: null,
1100
+ },
1101
+ {
1102
+ where: {
1103
+ id: spaceId,
1104
+ status: 'building',
1105
+ buildRunId: space.get('buildRunId') || null,
1106
+ },
1107
+ },
1108
+ );
1109
+
1110
+ if (!affected) {
1111
+ continue;
1112
+ }
1113
+
1114
+ await pageRepo.update({
1115
+ filter: {
1116
+ spaceId,
1117
+ status: 'building',
1118
+ },
1119
+ values: {
1120
+ status: 'pending',
1121
+ buildLog: 'Build re-queued after worker restart',
1122
+ },
1123
+ });
1124
+ await enqueueBuild(app, {
1125
+ spaceId,
1126
+ runId,
1127
+ queuedAt: new Date().toISOString(),
484
1128
  });
1129
+ }
1130
+
1131
+ if (spaces.length) {
1132
+ app.log?.info?.(`[plugin-build-guide-block] Re-queued ${spaces.length} interrupted build(s)`);
1133
+ }
1134
+ }
1135
+
1136
+ export async function build(ctx: Context, next: Next) {
1137
+ const { filterByTk } = ctx.action.params;
1138
+ if (!filterByTk) {
1139
+ ctx.throw(400, 'Space id is required');
1140
+ }
1141
+
1142
+ const app = ctx.app as Application;
1143
+ const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
1144
+
1145
+ const body = await withBuildTriggerLock(app, String(filterByTk), async () => {
1146
+ const runId = crypto.randomUUID();
1147
+ const space = await repository.findById(filterByTk);
1148
+
1149
+ if (!space) {
1150
+ ctx.throw(404, 'Space not found');
1151
+ }
1152
+
1153
+ if (space.get('status') === 'building') {
1154
+ ctx.throw(409, 'A build is already in progress for this space');
1155
+ }
485
1156
 
486
- ctx.body = { status: 'building' };
487
- } catch (error: any) {
488
- app.log.error('Build Guide Error', error);
489
1157
  await repository.update({
490
1158
  filterByTk,
491
1159
  values: {
492
- status: 'error',
493
- buildPhase: 'error',
494
- buildLog: error.message || String(error),
1160
+ status: 'building',
1161
+ buildPhase: 'queued',
1162
+ buildLog: 'Build queued',
1163
+ buildRunId: runId,
1164
+ buildQueuedAt: new Date(),
1165
+ buildStartedAt: null,
1166
+ buildHeartbeatAt: null,
1167
+ buildWorkerId: null,
495
1168
  },
496
1169
  });
497
- ctx.throw(500, error.message || 'Error occurred during build');
498
- }
499
1170
 
1171
+ await enqueueBuild(app, {
1172
+ spaceId: String(filterByTk),
1173
+ runId,
1174
+ userId: (ctx as any).state?.currentUser?.id ?? null,
1175
+ queuedAt: new Date().toISOString(),
1176
+ });
1177
+
1178
+ return { status: 'building' };
1179
+ });
1180
+
1181
+ ctx.body = body;
500
1182
  await next();
501
1183
  }