plugin-build-guide-block 1.1.5 → 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.
- package/dist/client/index.js +2 -2
- package/dist/externalVersion.js +7 -7
- package/dist/node_modules/sanitize-html/index.js +1 -1
- package/dist/node_modules/sanitize-html/package.json +1 -1
- package/dist/server/actions/build.js +682 -83
- package/dist/server/collections/ai-build-guide-spaces.js +20 -0
- package/dist/server/plugin.js +21 -19
- package/dist/server/tools/search-guides.js +41 -30
- package/package.json +2 -2
- package/src/client/components/BuildButton.tsx +20 -9
- package/src/server/actions/build.ts +768 -86
- package/src/server/collections/ai-build-guide-spaces.ts +77 -57
- package/src/server/plugin.ts +170 -163
- package/src/server/tools/search-guides.ts +113 -95
- package/dist/client/UserGuideBlock.d.ts +0 -2
- package/dist/client/UserGuideBlockInitializer.d.ts +0 -2
- package/dist/client/UserGuideBlockProvider.d.ts +0 -2
- package/dist/client/UserGuideManager.d.ts +0 -2
- package/dist/client/components/BuildButton.d.ts +0 -2
- package/dist/client/components/LLMServiceSelect.d.ts +0 -2
- package/dist/client/components/ModelSelect.d.ts +0 -2
- package/dist/client/components/SpaceSelect.d.ts +0 -2
- package/dist/client/components/StatusTag.d.ts +0 -2
- package/dist/client/index.d.ts +0 -1
- package/dist/client/locale.d.ts +0 -3
- package/dist/client/models/UserGuideBlockModel.d.ts +0 -9
- package/dist/client/models/index.d.ts +0 -9
- package/dist/client/plugin.d.ts +0 -5
- package/dist/client/schemaSettings.d.ts +0 -2
- package/dist/client/schemas/spacesSchema.d.ts +0 -437
- package/dist/index.d.ts +0 -2
- package/dist/locale/namespace.d.ts +0 -6
- package/dist/server/actions/build.d.ts +0 -2
- package/dist/server/actions/getHtml.d.ts +0 -2
- package/dist/server/actions/getMarkdown.d.ts +0 -2
- package/dist/server/collections/ai-build-guide-pages.d.ts +0 -2
- package/dist/server/collections/ai-build-guide-spaces.d.ts +0 -2
- package/dist/server/index.d.ts +0 -2
- package/dist/server/plugin.d.ts +0 -16
- package/dist/server/tools/index.d.ts +0 -1
- 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',
|
|
23
|
-
'
|
|
24
|
-
'
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
626
|
+
spaceId: run.spaceId,
|
|
321
627
|
},
|
|
322
628
|
});
|
|
323
629
|
|
|
324
|
-
await
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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:
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
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:
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
454
|
-
|
|
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
|
-
|
|
458
|
-
const
|
|
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
|
|
462
|
-
|
|
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: '
|
|
465
|
-
buildPhase: '
|
|
466
|
-
buildLog
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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: '
|
|
493
|
-
buildPhase: '
|
|
494
|
-
buildLog:
|
|
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
|
}
|