plugin-build-guide-block 1.1.5 → 1.1.7

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 (123) hide show
  1. package/client-v2.d.ts +2 -0
  2. package/client-v2.js +1 -0
  3. package/dist/client/index.js +2 -2
  4. package/dist/client-v2/73.6b8b2eda7d969c69.js +10 -0
  5. package/dist/client-v2/index.js +10 -0
  6. package/dist/externalVersion.js +9 -8
  7. package/dist/node_modules/sanitize-html/index.js +2 -2
  8. package/dist/node_modules/sanitize-html/package.json +1 -1
  9. package/dist/server/actions/build.js +687 -85
  10. package/dist/server/collections/ai-build-guide-spaces.js +20 -0
  11. package/dist/server/plugin.js +21 -19
  12. package/dist/server/tools/search-guides.js +41 -30
  13. package/package.json +7 -3
  14. package/src/client/components/BuildButton.tsx +20 -9
  15. package/src/client-v2/plugin.tsx +24 -0
  16. package/src/server/actions/build.ts +774 -88
  17. package/src/server/collections/ai-build-guide-spaces.ts +77 -57
  18. package/src/server/plugin.ts +170 -163
  19. package/src/server/tools/search-guides.ts +113 -95
  20. package/dist/client/UserGuideBlock.d.ts +0 -2
  21. package/dist/client/UserGuideBlockInitializer.d.ts +0 -2
  22. package/dist/client/UserGuideBlockProvider.d.ts +0 -2
  23. package/dist/client/UserGuideManager.d.ts +0 -2
  24. package/dist/client/components/BuildButton.d.ts +0 -2
  25. package/dist/client/components/LLMServiceSelect.d.ts +0 -2
  26. package/dist/client/components/ModelSelect.d.ts +0 -2
  27. package/dist/client/components/SpaceSelect.d.ts +0 -2
  28. package/dist/client/components/StatusTag.d.ts +0 -2
  29. package/dist/client/locale.d.ts +0 -3
  30. package/dist/client/models/UserGuideBlockModel.d.ts +0 -9
  31. package/dist/client/models/index.d.ts +0 -9
  32. package/dist/client/plugin.d.ts +0 -5
  33. package/dist/client/schemaSettings.d.ts +0 -2
  34. package/dist/client/schemas/spacesSchema.d.ts +0 -437
  35. package/dist/index.d.ts +0 -2
  36. package/dist/locale/namespace.d.ts +0 -6
  37. package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.browser.cjs +0 -34
  38. package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.browser.js +0 -34
  39. package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.cjs +0 -35
  40. package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.d.ts +0 -56
  41. package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.js +0 -35
  42. package/dist/node_modules/sanitize-html/node_modules/nanoid/async/index.native.js +0 -26
  43. package/dist/node_modules/sanitize-html/node_modules/nanoid/async/package.json +0 -12
  44. package/dist/node_modules/sanitize-html/node_modules/nanoid/bin/nanoid.cjs +0 -55
  45. package/dist/node_modules/sanitize-html/node_modules/nanoid/index.browser.cjs +0 -34
  46. package/dist/node_modules/sanitize-html/node_modules/nanoid/index.browser.js +0 -34
  47. package/dist/node_modules/sanitize-html/node_modules/nanoid/index.cjs +0 -45
  48. package/dist/node_modules/sanitize-html/node_modules/nanoid/index.d.cts +0 -91
  49. package/dist/node_modules/sanitize-html/node_modules/nanoid/index.d.ts +0 -91
  50. package/dist/node_modules/sanitize-html/node_modules/nanoid/index.js +0 -45
  51. package/dist/node_modules/sanitize-html/node_modules/nanoid/nanoid.js +0 -1
  52. package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/index.cjs +0 -21
  53. package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/index.d.ts +0 -33
  54. package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/index.js +0 -21
  55. package/dist/node_modules/sanitize-html/node_modules/nanoid/non-secure/package.json +0 -6
  56. package/dist/node_modules/sanitize-html/node_modules/nanoid/package.json +0 -88
  57. package/dist/node_modules/sanitize-html/node_modules/nanoid/url-alphabet/index.cjs +0 -3
  58. package/dist/node_modules/sanitize-html/node_modules/nanoid/url-alphabet/index.js +0 -3
  59. package/dist/node_modules/sanitize-html/node_modules/nanoid/url-alphabet/package.json +0 -6
  60. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/at-rule.d.ts +0 -115
  61. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/at-rule.js +0 -25
  62. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/comment.d.ts +0 -67
  63. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/comment.js +0 -13
  64. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/container.d.ts +0 -452
  65. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/container.js +0 -439
  66. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/css-syntax-error.d.ts +0 -248
  67. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/css-syntax-error.js +0 -100
  68. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/declaration.d.ts +0 -148
  69. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/declaration.js +0 -24
  70. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/document.d.ts +0 -68
  71. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/document.js +0 -33
  72. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/fromJSON.d.ts +0 -9
  73. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/fromJSON.js +0 -54
  74. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/input.d.ts +0 -194
  75. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/input.js +0 -248
  76. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/lazy-result.d.ts +0 -190
  77. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/lazy-result.js +0 -550
  78. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/list.d.ts +0 -57
  79. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/list.js +0 -58
  80. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/map-generator.js +0 -359
  81. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/no-work-result.d.ts +0 -46
  82. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/no-work-result.js +0 -135
  83. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/node.d.ts +0 -536
  84. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/node.js +0 -381
  85. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/parse.d.ts +0 -9
  86. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/parse.js +0 -42
  87. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/parser.js +0 -610
  88. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/postcss.d.mts +0 -72
  89. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/postcss.d.ts +0 -441
  90. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/postcss.js +0 -101
  91. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/previous-map.d.ts +0 -81
  92. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/previous-map.js +0 -142
  93. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/processor.d.ts +0 -115
  94. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/processor.js +0 -67
  95. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/result.d.ts +0 -206
  96. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/result.js +0 -42
  97. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/root.d.ts +0 -86
  98. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/root.js +0 -61
  99. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/rule.d.ts +0 -113
  100. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/rule.js +0 -27
  101. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringifier.d.ts +0 -46
  102. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringifier.js +0 -353
  103. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringify.d.ts +0 -9
  104. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/stringify.js +0 -11
  105. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/symbols.js +0 -5
  106. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/terminal-highlight.js +0 -70
  107. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/tokenize.js +0 -266
  108. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/warn-once.js +0 -13
  109. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/warning.d.ts +0 -147
  110. package/dist/node_modules/sanitize-html/node_modules/postcss/lib/warning.js +0 -37
  111. package/dist/node_modules/sanitize-html/node_modules/postcss/node_modules/.bin/nanoid +0 -15
  112. package/dist/node_modules/sanitize-html/node_modules/postcss/node_modules/.bin/nanoid.cmd +0 -7
  113. package/dist/node_modules/sanitize-html/node_modules/postcss/package.json +0 -88
  114. package/dist/server/actions/build.d.ts +0 -2
  115. package/dist/server/actions/getHtml.d.ts +0 -2
  116. package/dist/server/actions/getMarkdown.d.ts +0 -2
  117. package/dist/server/collections/ai-build-guide-pages.d.ts +0 -2
  118. package/dist/server/collections/ai-build-guide-spaces.d.ts +0 -2
  119. package/dist/server/index.d.ts +0 -2
  120. package/dist/server/plugin.d.ts +0 -16
  121. package/dist/server/tools/index.d.ts +0 -1
  122. package/dist/server/tools/search-guides.d.ts +0 -28
  123. /package/{dist/client/index.d.ts → src/client-v2/index.tsx} +0 -0
@@ -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)) {
@@ -98,6 +328,10 @@ function toPlainText(value: unknown) {
98
328
  return JSON.stringify(value);
99
329
  }
100
330
 
331
+ function stripThink(text: string) {
332
+ return text.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, '').trim();
333
+ }
334
+
101
335
  function stripFence(text: string) {
102
336
  return text
103
337
  .replace(/^```(?:json|markdown|md|html)?\s*/i, '')
@@ -168,7 +402,7 @@ function createFallbackPlan(guideTitle: string, targetChapterCount: number): Gui
168
402
 
169
403
  function normalizePlan(rawText: string, guideTitle: string, targetChapterCount: number): GuidePlan {
170
404
  const targetCount = clampChapterCount(targetChapterCount);
171
- const cleanText = stripFence(rawText);
405
+ const cleanText = stripFence(stripThink(rawText));
172
406
  const jsonStart = cleanText.indexOf('{');
173
407
  const jsonEnd = cleanText.lastIndexOf('}');
174
408
  const jsonText = jsonStart >= 0 && jsonEnd > jsonStart ? cleanText.slice(jsonStart, jsonEnd + 1) : cleanText;
@@ -250,7 +484,13 @@ ${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
250
484
  return normalizePlan(toPlainText(response.content), title || 'User guide', targetCount);
251
485
  }
252
486
 
253
- async function buildPageMarkdown(provider: any, space: any, plan: GuidePlan, chapter: GuidePlanItem, documentsText: string) {
487
+ async function buildPageMarkdown(
488
+ provider: any,
489
+ space: any,
490
+ plan: GuidePlan,
491
+ chapter: GuidePlanItem,
492
+ documentsText: string,
493
+ ) {
254
494
  const { title, systemPrompt } = space.get();
255
495
  const messages = [];
256
496
  if (systemPrompt) {
@@ -278,7 +518,7 @@ Source documents:
278
518
  ${documentsText.slice(0, MAX_SOURCE_CHARS)}`),
279
519
  );
280
520
  const response = await provider.chatModel.invoke(messages);
281
- return stripFence(toPlainText(response.content));
521
+ return stripFence(stripThink(toPlainText(response.content)));
282
522
  }
283
523
 
284
524
  async function markdownToCleanHtml(markdown: string) {
@@ -301,15 +541,85 @@ async function readDocuments(app: any, space: any) {
301
541
  return texts.join('\n');
302
542
  }
303
543
 
304
- async function runBuild(app: any, db: any, filterByTk: string) {
544
+ function getSpaceModel(app: any) {
545
+ return app.db.getModel('aiBuildGuideSpaces');
546
+ }
547
+
548
+ async function updateSpaceForRun(app: any, run: BuildRunContext, values: Record<string, any>, optional = false) {
549
+ const SpaceModel = getSpaceModel(app);
550
+ const [affected] = await SpaceModel.update(values, {
551
+ where: {
552
+ id: run.spaceId,
553
+ buildRunId: run.runId,
554
+ },
555
+ });
556
+ if (!affected && !optional) {
557
+ throw new StaleBuildRunError(run.spaceId, run.runId);
558
+ }
559
+ return affected > 0;
560
+ }
561
+
562
+ async function claimBuildRun(app: any, run: BuildRunContext, workerId: string) {
563
+ const now = new Date();
564
+ const SpaceModel = getSpaceModel(app);
565
+ const [affected] = await SpaceModel.update(
566
+ {
567
+ buildPhase: 'running',
568
+ buildStartedAt: now,
569
+ buildHeartbeatAt: now,
570
+ buildWorkerId: workerId,
571
+ },
572
+ {
573
+ where: {
574
+ id: run.spaceId,
575
+ status: 'building',
576
+ buildPhase: 'queued',
577
+ buildRunId: run.runId,
578
+ },
579
+ },
580
+ );
581
+ return affected > 0;
582
+ }
583
+
584
+ function getBuildWorkerId(app: any) {
585
+ return [
586
+ process.env.HOSTNAME || process.env.COMPUTERNAME || 'worker',
587
+ app.name || 'app',
588
+ app.instanceId || '0',
589
+ process.pid,
590
+ ].join(':');
591
+ }
592
+
593
+ function startBuildHeartbeat(app: any, run: BuildRunContext) {
594
+ const timer = setInterval(() => {
595
+ updateSpaceForRun(
596
+ app,
597
+ run,
598
+ {
599
+ buildHeartbeatAt: new Date(),
600
+ },
601
+ true,
602
+ ).catch((error) => {
603
+ app.log?.warn?.(`[plugin-build-guide-block] Failed to update heartbeat for build ${run.runId}`, error);
604
+ });
605
+ }, BUILD_HEARTBEAT_INTERVAL_MS);
606
+
607
+ return () => clearInterval(timer);
608
+ }
609
+
610
+ async function runBuild(app: any, db: any, run: BuildRunContext) {
305
611
  const spaceRepo = db.getRepository('aiBuildGuideSpaces') as Repository;
306
612
  const pageRepo = db.getRepository('aiBuildGuidePages') as Repository;
307
- const space = await spaceRepo.findById(filterByTk);
613
+ const space = await spaceRepo.findById(run.spaceId);
308
614
 
309
615
  if (!space) {
310
616
  throw new Error('Space not found');
311
617
  }
312
618
 
619
+ if (space.get('buildRunId') !== run.runId) {
620
+ throw new StaleBuildRunError(run.spaceId, run.runId);
621
+ }
622
+
313
623
  const { llmService, model } = space.get();
314
624
  if (!llmService || !model) {
315
625
  throw new Error('LLM Service or model is missing in space configuration');
@@ -317,51 +627,42 @@ async function runBuild(app: any, db: any, filterByTk: string) {
317
627
 
318
628
  await pageRepo.destroy({
319
629
  filter: {
320
- spaceId: filterByTk,
630
+ spaceId: run.spaceId,
321
631
  },
322
632
  });
323
633
 
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
- },
634
+ await updateSpaceForRun(app, run, {
635
+ buildPhase: 'reading',
636
+ buildLog: 'Reading source documents',
637
+ generatedHtml: null,
638
+ generatedMarkdown: null,
639
+ planJson: null,
640
+ pageCount: 0,
334
641
  });
335
642
 
336
643
  const documentsText = await readDocuments(app, space);
337
644
  const sourceHash = crypto.createHash('sha256').update(documentsText).digest('hex');
338
645
  const provider = await getLLMProvider(app, llmService, model);
339
646
 
340
- await spaceRepo.update({
341
- filterByTk,
342
- values: {
343
- buildPhase: 'planning',
344
- buildLog: 'Creating guide breakdown plan',
345
- sourceHash,
346
- },
647
+ await updateSpaceForRun(app, run, {
648
+ buildPhase: 'planning',
649
+ buildLog: 'Creating guide breakdown plan',
650
+ sourceHash,
347
651
  });
348
652
 
349
653
  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
- },
654
+ await updateSpaceForRun(app, run, {
655
+ planJson: plan,
656
+ pageCount: plan.chapters.length,
657
+ buildPhase: 'building_pages',
658
+ buildLog: `Plan created with ${plan.chapters.length} chapters`,
358
659
  });
359
660
 
360
661
  const pageRecords = [];
361
662
  for (const [index, chapter] of plan.chapters.entries()) {
362
663
  const page = await pageRepo.create({
363
664
  values: {
364
- spaceId: filterByTk,
665
+ spaceId: run.spaceId,
365
666
  sort: index + 1,
366
667
  title: chapter.title,
367
668
  slug: slugify(chapter.title, `chapter-${index + 1}`),
@@ -383,12 +684,9 @@ async function runBuild(app: any, db: any, filterByTk: string) {
383
684
  buildLog: 'Building chapter with LLM',
384
685
  },
385
686
  });
386
- await spaceRepo.update({
387
- filterByTk,
388
- values: {
389
- buildPhase: 'building_pages',
390
- buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
391
- },
687
+ await updateSpaceForRun(app, run, {
688
+ buildPhase: 'building_pages',
689
+ buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`,
392
690
  });
393
691
 
394
692
  try {
@@ -417,7 +715,7 @@ async function runBuild(app: any, db: any, filterByTk: string) {
417
715
 
418
716
  const completedPages = await pageRepo.find({
419
717
  filter: {
420
- spaceId: filterByTk,
718
+ spaceId: run.spaceId,
421
719
  status: 'completed',
422
720
  },
423
721
  sort: ['sort'],
@@ -428,74 +726,462 @@ async function runBuild(app: any, db: any, filterByTk: string) {
428
726
  .join('\n\n---\n\n');
429
727
  const combinedHtml = await markdownToCleanHtml(combinedMarkdown);
430
728
 
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
- },
729
+ await updateSpaceForRun(app, run, {
730
+ status: 'completed',
731
+ buildPhase: 'completed',
732
+ buildLog: `Built ${completedPages.length} chapters successfully`,
733
+ generatedMarkdown: combinedMarkdown,
734
+ generatedHtml: combinedHtml,
735
+ buildHeartbeatAt: new Date(),
440
736
  });
441
737
  }
442
738
 
443
- export async function build(ctx: Context, next: Next) {
444
- const { filterByTk } = ctx.action.params;
445
- const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
739
+ function isBuildGuideWorker(app: Application) {
740
+ const workerMode = process.env.WORKER_MODE || '';
741
+ return (
742
+ app.serving(WORKER_JOB_BUILD_GUIDE_PROCESS) ||
743
+ workerMode === 'worker' ||
744
+ workerMode === 'task' ||
745
+ process.env.APP_ROLE === 'worker'
746
+ );
747
+ }
446
748
 
447
- const space = await repository.findById(filterByTk);
749
+ function clearLocalBuildMemoryQueue(app: Application) {
750
+ const eventQueue = (app as any).eventQueue;
751
+ const adapter = eventQueue?.adapter;
752
+ const fullChannel = eventQueue?.getFullChannel?.(BUILD_GUIDE_QUEUE_CHANNEL);
753
+ const queue = fullChannel ? adapter?.queues?.get?.(fullChannel) : null;
754
+ if (!queue?.length) return;
448
755
 
449
- if (!space) {
450
- ctx.throw(404, 'Space not found');
756
+ adapter.queues.set(fullChannel, []);
757
+ app.log?.warn?.(
758
+ `[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`,
759
+ );
760
+ }
761
+
762
+ function getBuildQueueRedisKey(app: Application): string {
763
+ const appName = (app as any).name || process.env.APP_NAME || 'main';
764
+ return `${appName}:plugin-build-guide-block:build:queue`;
765
+ }
766
+
767
+ async function getBuildQueueRedis(app: Application): Promise<any | null> {
768
+ const manager = (app as any).redisConnectionManager;
769
+ if (!manager?.getConnectionSync) {
770
+ return null;
451
771
  }
452
772
 
453
- if (space.get('status') === 'building') {
454
- ctx.throw(409, 'A build is already in progress for this space');
773
+ try {
774
+ const connectionString = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
775
+ return await manager.getConnectionSync(
776
+ BUILD_GUIDE_QUEUE_REDIS_CONNECTION,
777
+ connectionString ? { connectionString } : undefined,
778
+ );
779
+ } catch (error: any) {
780
+ app.log?.debug?.(
781
+ `[plugin-build-guide-block] Redis queue unavailable; DB polling fallback active: ${error?.message || error}`,
782
+ );
783
+ return null;
455
784
  }
785
+ }
456
786
 
457
- const app = ctx.app;
458
- const db = ctx.db;
787
+ async function enqueueBuildToRedis(app: Application, message: BuildGuideQueueMessage): Promise<boolean> {
788
+ const redis = await getBuildQueueRedis(app);
789
+ if (!redis) return false;
459
790
 
460
791
  try {
461
- await repository.update({
462
- filterByTk,
792
+ await redis.sendCommand(['RPUSH', getBuildQueueRedisKey(app), JSON.stringify(message)]);
793
+ app.log?.debug?.(
794
+ `[plugin-build-guide-block] Enqueued build ${message.runId} for space "${message.spaceId}" to Redis`,
795
+ );
796
+ return true;
797
+ } catch (error: any) {
798
+ app.log?.warn?.(`[plugin-build-guide-block] Failed to enqueue build to Redis; DB polling fallback active`, error);
799
+ return false;
800
+ }
801
+ }
802
+
803
+ async function publishBuildQueueWake(app: Application, message?: BuildGuideQueueMessage) {
804
+ try {
805
+ await (app as any).pubSubManager?.publish?.(
806
+ BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
807
+ { spaceId: message?.spaceId, runId: message?.runId },
808
+ { skipSelf: !isBuildGuideWorker(app) },
809
+ );
810
+ } catch (error: any) {
811
+ app.log?.debug?.(`[plugin-build-guide-block] Wake publish skipped: ${error?.message || error}`);
812
+ }
813
+ }
814
+
815
+ function startBuildGuideQueueProcessor(app: Application) {
816
+ if (!isBuildGuideWorker(app)) {
817
+ app.log?.debug?.('[plugin-build-guide-block] Build queue processor disabled on non-worker node');
818
+ return;
819
+ }
820
+ if (buildQueueTimer) return;
821
+
822
+ buildQueueWakeHandler = async () => {
823
+ scheduleBuildQueueTick(app, 0);
824
+ };
825
+
826
+ const subscribe = (app as any).pubSubManager?.subscribe?.(BUILD_GUIDE_QUEUE_WAKE_CHANNEL, buildQueueWakeHandler);
827
+ if (subscribe?.catch) {
828
+ subscribe.catch((error: any) => {
829
+ app.log?.debug?.(`[plugin-build-guide-block] Wake subscribe skipped: ${error?.message || error}`);
830
+ });
831
+ }
832
+
833
+ buildQueueTimer = setInterval(() => scheduleBuildQueueTick(app, 0), BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS);
834
+ (buildQueueTimer as any).unref?.();
835
+ scheduleBuildQueueTick(app, 1000);
836
+ app.log?.info?.(
837
+ `[plugin-build-guide-block] Build queue processor started (interval ${BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS}ms)`,
838
+ );
839
+ }
840
+
841
+ function stopBuildGuideQueueProcessor(app: Application) {
842
+ if (buildQueueTimer) {
843
+ clearInterval(buildQueueTimer);
844
+ buildQueueTimer = null;
845
+ }
846
+ if (buildQueueKickTimer) {
847
+ clearTimeout(buildQueueKickTimer);
848
+ buildQueueKickTimer = null;
849
+ }
850
+ if (buildQueueWakeHandler) {
851
+ const unsubscribe = (app as any).pubSubManager?.unsubscribe?.(
852
+ BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
853
+ buildQueueWakeHandler,
854
+ );
855
+ if (unsubscribe?.catch) {
856
+ unsubscribe.catch(() => undefined);
857
+ }
858
+ buildQueueWakeHandler = null;
859
+ }
860
+ buildQueueProcessing = false;
861
+ }
862
+
863
+ function scheduleBuildQueueTick(app: Application, delayMs: number) {
864
+ if (buildQueueKickTimer) return;
865
+ buildQueueKickTimer = setTimeout(() => {
866
+ buildQueueKickTimer = null;
867
+ runBuildQueueTick(app).catch((error) => {
868
+ app.log?.error?.('[plugin-build-guide-block] Build queue tick failed', error);
869
+ });
870
+ }, delayMs);
871
+ (buildQueueKickTimer as any).unref?.();
872
+ }
873
+
874
+ async function runBuildQueueTick(app: Application) {
875
+ if (buildQueueProcessing || !isBuildGuideWorker(app)) return;
876
+
877
+ buildQueueProcessing = true;
878
+ try {
879
+ const redisMessages = await drainRedisBuildQueue(app, BUILD_GUIDE_QUEUE_CONCURRENCY);
880
+ await processBuildQueueMessages(app, redisMessages);
881
+
882
+ const remaining = Math.max(1, BUILD_GUIDE_QUEUE_CONCURRENCY - redisMessages.length);
883
+ await processQueuedBuildsFromDb(app, remaining);
884
+ } finally {
885
+ buildQueueProcessing = false;
886
+ }
887
+ }
888
+
889
+ async function drainRedisBuildQueue(app: Application, count: number): Promise<BuildGuideQueueMessage[]> {
890
+ const redis = await getBuildQueueRedis(app);
891
+ if (!redis) return [];
892
+
893
+ const key = getBuildQueueRedisKey(app);
894
+ const messages: BuildGuideQueueMessage[] = [];
895
+ for (let i = 0; i < count; i += 1) {
896
+ const raw = await redis.sendCommand(['LPOP', key]);
897
+ if (!raw) break;
898
+ try {
899
+ messages.push(JSON.parse(String(raw)));
900
+ } catch (error: any) {
901
+ app.log?.warn?.(`[plugin-build-guide-block] Dropped invalid Redis build message: ${error?.message || error}`);
902
+ }
903
+ }
904
+ return messages;
905
+ }
906
+
907
+ function createBuildQueueMessageFromSpace(space: any): BuildGuideQueueMessage | null {
908
+ const runId = space.get('buildRunId');
909
+ if (!runId) return null;
910
+ return {
911
+ spaceId: String(space.get('id')),
912
+ runId: String(runId),
913
+ queuedAt: space.get('buildQueuedAt') ? new Date(space.get('buildQueuedAt')).toISOString() : undefined,
914
+ };
915
+ }
916
+
917
+ async function processQueuedBuildsFromDb(app: Application, count: number) {
918
+ const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
919
+ const spaces = await spaceRepo.find({
920
+ filter: {
921
+ status: 'building',
922
+ buildPhase: 'queued',
923
+ },
924
+ sort: ['buildQueuedAt'],
925
+ limit: count,
926
+ });
927
+ const messages = spaces.map(createBuildQueueMessageFromSpace).filter(Boolean) as BuildGuideQueueMessage[];
928
+ await processBuildQueueMessages(app, messages);
929
+ }
930
+
931
+ async function processBuildQueueMessages(app: Application, messages: BuildGuideQueueMessage[]) {
932
+ if (!messages.length) return;
933
+ await Promise.all(messages.map((message) => processQueuedBuild(app, message)));
934
+ }
935
+
936
+ async function markBuildError(app: Application, spaceId: string, runId: string | undefined, error: any) {
937
+ const buildLog = error?.message || String(error);
938
+ let updated = false;
939
+ if (runId) {
940
+ updated = await updateSpaceForRun(
941
+ app,
942
+ { spaceId, runId },
943
+ {
944
+ status: 'error',
945
+ buildPhase: 'error',
946
+ buildLog,
947
+ buildHeartbeatAt: new Date(),
948
+ },
949
+ true,
950
+ );
951
+ } else {
952
+ await app.db.getRepository('aiBuildGuideSpaces').update({
953
+ filterByTk: spaceId,
463
954
  values: {
464
- status: 'building',
465
- buildPhase: 'queued',
466
- buildLog: 'Build queued',
955
+ status: 'error',
956
+ buildPhase: 'error',
957
+ buildLog,
467
958
  },
468
959
  });
960
+ updated = true;
961
+ }
962
+
963
+ if (!updated) {
964
+ return;
965
+ }
469
966
 
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);
967
+ await app.db.getRepository('aiBuildGuidePages').update({
968
+ filter: {
969
+ spaceId,
970
+ status: 'building',
971
+ },
972
+ values: {
973
+ status: 'error',
974
+ buildLog,
975
+ },
976
+ });
977
+ }
978
+
979
+ async function enqueueBuild(app: Application, message: BuildGuideQueueMessage) {
980
+ try {
981
+ const queuedInRedis = await enqueueBuildToRedis(app, message);
982
+ if (queuedInRedis) {
983
+ await publishBuildQueueWake(app, message);
984
+ return;
985
+ }
986
+
987
+ await publishBuildQueueWake(app, message);
988
+
989
+ if (isBuildGuideWorker(app)) {
990
+ await app.eventQueue.publish(BUILD_GUIDE_QUEUE_CHANNEL, message, {
991
+ timeout: BUILD_GUIDE_QUEUE_TIMEOUT_MS,
992
+ maxRetries: 0,
993
+ });
994
+ return;
995
+ }
996
+
997
+ app.log?.warn?.(
998
+ `[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`,
999
+ );
1000
+ } catch (error) {
1001
+ await markBuildError(app, message.spaceId, message.runId, error);
1002
+ throw error;
1003
+ }
1004
+ }
1005
+
1006
+ async function processQueuedBuild(app: Application, message: BuildGuideQueueMessage) {
1007
+ const spaceId = message?.spaceId;
1008
+ const runId = message?.runId;
1009
+ if (!spaceId || !runId) {
1010
+ app.log?.warn?.('[plugin-build-guide-block] Build queue message missing spaceId or runId');
1011
+ return;
1012
+ }
1013
+
1014
+ await withBuildRunLock(app, spaceId, async () => {
1015
+ const run = { spaceId, runId };
1016
+ const workerId = getBuildWorkerId(app);
1017
+ const claimed = await claimBuildRun(app, run, workerId);
1018
+ if (!claimed) {
1019
+ app.log?.info?.(`[plugin-build-guide-block] Build ${runId} for space "${spaceId}" was already claimed or stale`);
1020
+ return;
1021
+ }
1022
+
1023
+ const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
1024
+ const space = await spaceRepo.findById(spaceId);
1025
+ if (!space) {
1026
+ app.log?.warn?.(`[plugin-build-guide-block] Build space "${spaceId}" not found; skipping queued build`);
1027
+ return;
1028
+ }
1029
+
1030
+ if (space.get('status') !== 'building') {
1031
+ app.log?.info?.(
1032
+ `[plugin-build-guide-block] Build space "${spaceId}" is ${space.get('status')}; skipping queued build`,
1033
+ );
1034
+ return;
1035
+ }
1036
+
1037
+ const stopHeartbeat = startBuildHeartbeat(app, run);
1038
+ try {
1039
+ await runBuild(app, app.db, run);
1040
+ } catch (error) {
1041
+ if (error instanceof StaleBuildRunError) {
1042
+ app.log?.info?.(`[plugin-build-guide-block] ${error.message}`);
1043
+ return;
483
1044
  }
1045
+ app.log?.error?.('Build Guide Worker Error', error);
1046
+ await markBuildError(app, spaceId, runId, error);
1047
+ } finally {
1048
+ stopHeartbeat();
1049
+ }
1050
+ });
1051
+ }
1052
+
1053
+ export function registerBuildGuideQueue(app: Application) {
1054
+ app.eventQueue.subscribe(BUILD_GUIDE_QUEUE_CHANNEL, {
1055
+ concurrency: BUILD_GUIDE_QUEUE_CONCURRENCY,
1056
+ idle: () => isBuildGuideWorker(app),
1057
+ process: async (message: BuildGuideQueueMessage) => {
1058
+ await processQueuedBuild(app, message);
1059
+ },
1060
+ });
1061
+ if (!isBuildGuideWorker(app)) {
1062
+ app.on('afterStart', () => clearLocalBuildMemoryQueue(app));
1063
+ }
1064
+ startBuildGuideQueueProcessor(app);
1065
+ }
1066
+
1067
+ export function unregisterBuildGuideQueue(app: Application) {
1068
+ app.eventQueue.unsubscribe(BUILD_GUIDE_QUEUE_CHANNEL);
1069
+ stopBuildGuideQueueProcessor(app);
1070
+ }
1071
+
1072
+ async function withBuildTriggerLock<T>(app: Application, spaceId: string, fn: () => Promise<T>) {
1073
+ return app.lockManager.runExclusive(`build-guide:trigger:${spaceId}`, fn, BUILD_TRIGGER_LOCK_TTL_MS);
1074
+ }
1075
+
1076
+ async function withBuildRunLock<T>(app: Application, spaceId: string, fn: () => Promise<T>) {
1077
+ return app.lockManager.runExclusive(`build-guide:run:${spaceId}`, fn, BUILD_RUN_LOCK_TTL_MS);
1078
+ }
1079
+
1080
+ export async function recoverInterruptedBuilds(app: Application) {
1081
+ const spaceRepo = app.db.getRepository('aiBuildGuideSpaces') as Repository;
1082
+ const pageRepo = app.db.getRepository('aiBuildGuidePages') as Repository;
1083
+ const staleBefore = new Date(Date.now() - BUILD_STALE_MS);
1084
+ const spaces = await spaceRepo.find({
1085
+ filter: {
1086
+ status: 'building',
1087
+ $or: [{ buildHeartbeatAt: null }, { buildHeartbeatAt: { $lt: staleBefore } }],
1088
+ },
1089
+ });
1090
+
1091
+ for (const space of spaces) {
1092
+ const spaceId = String(space.get('id'));
1093
+ const runId = String(space.get('buildRunId') || crypto.randomUUID());
1094
+ const SpaceModel = getSpaceModel(app);
1095
+ const [affected] = await SpaceModel.update(
1096
+ {
1097
+ buildPhase: 'queued',
1098
+ buildLog: 'Build re-queued after worker restart',
1099
+ buildRunId: runId,
1100
+ buildQueuedAt: new Date(),
1101
+ buildStartedAt: null,
1102
+ buildHeartbeatAt: null,
1103
+ buildWorkerId: null,
1104
+ },
1105
+ {
1106
+ where: {
1107
+ id: spaceId,
1108
+ status: 'building',
1109
+ buildRunId: space.get('buildRunId') || null,
1110
+ },
1111
+ },
1112
+ );
1113
+
1114
+ if (!affected) {
1115
+ continue;
1116
+ }
1117
+
1118
+ await pageRepo.update({
1119
+ filter: {
1120
+ spaceId,
1121
+ status: 'building',
1122
+ },
1123
+ values: {
1124
+ status: 'pending',
1125
+ buildLog: 'Build re-queued after worker restart',
1126
+ },
1127
+ });
1128
+ await enqueueBuild(app, {
1129
+ spaceId,
1130
+ runId,
1131
+ queuedAt: new Date().toISOString(),
484
1132
  });
1133
+ }
1134
+
1135
+ if (spaces.length) {
1136
+ app.log?.info?.(`[plugin-build-guide-block] Re-queued ${spaces.length} interrupted build(s)`);
1137
+ }
1138
+ }
1139
+
1140
+ export async function build(ctx: Context, next: Next) {
1141
+ const { filterByTk } = ctx.action.params;
1142
+ if (!filterByTk) {
1143
+ ctx.throw(400, 'Space id is required');
1144
+ }
1145
+
1146
+ const app = ctx.app as Application;
1147
+ const repository = ctx.db.getRepository('aiBuildGuideSpaces') as Repository;
1148
+
1149
+ const body = await withBuildTriggerLock(app, String(filterByTk), async () => {
1150
+ const runId = crypto.randomUUID();
1151
+ const space = await repository.findById(filterByTk);
1152
+
1153
+ if (!space) {
1154
+ ctx.throw(404, 'Space not found');
1155
+ }
1156
+
1157
+ if (space.get('status') === 'building') {
1158
+ ctx.throw(409, 'A build is already in progress for this space');
1159
+ }
485
1160
 
486
- ctx.body = { status: 'building' };
487
- } catch (error: any) {
488
- app.log.error('Build Guide Error', error);
489
1161
  await repository.update({
490
1162
  filterByTk,
491
1163
  values: {
492
- status: 'error',
493
- buildPhase: 'error',
494
- buildLog: error.message || String(error),
1164
+ status: 'building',
1165
+ buildPhase: 'queued',
1166
+ buildLog: 'Build queued',
1167
+ buildRunId: runId,
1168
+ buildQueuedAt: new Date(),
1169
+ buildStartedAt: null,
1170
+ buildHeartbeatAt: null,
1171
+ buildWorkerId: null,
495
1172
  },
496
1173
  });
497
- ctx.throw(500, error.message || 'Error occurred during build');
498
- }
499
1174
 
1175
+ await enqueueBuild(app, {
1176
+ spaceId: String(filterByTk),
1177
+ runId,
1178
+ userId: (ctx as any).state?.currentUser?.id ?? null,
1179
+ queuedAt: new Date().toISOString(),
1180
+ });
1181
+
1182
+ return { status: 'building' };
1183
+ });
1184
+
1185
+ ctx.body = body;
500
1186
  await next();
501
1187
  }