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.
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
@@ -36,7 +36,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
36
36
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
37
37
  var build_exports = {};
38
38
  __export(build_exports, {
39
- build: () => build
39
+ WORKER_JOB_BUILD_GUIDE_PROCESS: () => WORKER_JOB_BUILD_GUIDE_PROCESS,
40
+ build: () => build,
41
+ recoverInterruptedBuilds: () => recoverInterruptedBuilds,
42
+ registerBuildGuideQueue: () => registerBuildGuideQueue,
43
+ unregisterBuildGuideQueue: () => unregisterBuildGuideQueue
40
44
  });
41
45
  module.exports = __toCommonJS(build_exports);
42
46
  var import_sanitize_html = __toESM(require("sanitize-html"));
@@ -50,6 +54,66 @@ const MAX_SOURCE_CHARS = 9e4;
50
54
  const MIN_CHAPTERS = 1;
51
55
  const MAX_CHAPTERS = 12;
52
56
  const DEFAULT_TARGET_CHAPTERS = 5;
57
+ const WORKER_JOB_BUILD_GUIDE_PROCESS = "build-guide:process";
58
+ const BUILD_GUIDE_QUEUE_CHANNEL = "plugin-build-guide-block.build";
59
+ const BUILD_GUIDE_QUEUE_CONCURRENCY = Math.max(
60
+ 1,
61
+ Number.parseInt(process.env.BUILD_GUIDE_QUEUE_CONCURRENCY || process.env.BUILD_GUIDE_MAX_CONCURRENCY || "1", 10) || 1
62
+ );
63
+ const BUILD_GUIDE_QUEUE_TIMEOUT_MS = Math.max(
64
+ 6e4,
65
+ Number.parseInt(process.env.BUILD_GUIDE_QUEUE_TIMEOUT_MS || "", 10) || 30 * 60 * 1e3
66
+ );
67
+ const BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS = Math.max(
68
+ 1e3,
69
+ Number.parseInt(process.env.BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS || "", 10) || 5e3
70
+ );
71
+ const BUILD_GUIDE_QUEUE_WAKE_CHANNEL = "plugin-build-guide-block.build.wake";
72
+ const BUILD_GUIDE_QUEUE_REDIS_CONNECTION = "plugin-build-guide-block.build.queue";
73
+ const BUILD_TRIGGER_LOCK_TTL_MS = 3e4;
74
+ const BUILD_RUN_LOCK_TTL_MS = Math.max(
75
+ 6e4,
76
+ Number.parseInt(process.env.BUILD_GUIDE_RUN_LOCK_TTL_MS || "", 10) || 24 * 60 * 60 * 1e3
77
+ );
78
+ const BUILD_HEARTBEAT_INTERVAL_MS = Math.max(
79
+ 5e3,
80
+ Number.parseInt(process.env.BUILD_GUIDE_HEARTBEAT_MS || "", 10) || 3e4
81
+ );
82
+ const BUILD_STALE_MS = Math.max(
83
+ BUILD_HEARTBEAT_INTERVAL_MS * 2,
84
+ Number.parseInt(process.env.BUILD_GUIDE_STALE_MS || "", 10) || 12e4
85
+ );
86
+ const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
87
+ ".txt",
88
+ ".md",
89
+ ".markdown",
90
+ ".csv",
91
+ ".tsv",
92
+ ".json",
93
+ ".xml",
94
+ ".html",
95
+ ".htm",
96
+ ".yaml",
97
+ ".yml",
98
+ ".log",
99
+ ".sql",
100
+ ".js",
101
+ ".jsx",
102
+ ".ts",
103
+ ".tsx",
104
+ ".css",
105
+ ".scss",
106
+ ".less"
107
+ ]);
108
+ const TEXT_MIMETYPES = /* @__PURE__ */ new Set([
109
+ "application/json",
110
+ "application/xml",
111
+ "application/yaml",
112
+ "application/x-yaml",
113
+ "application/javascript",
114
+ "application/typescript",
115
+ "image/svg+xml"
116
+ ]);
53
117
  const SANITIZE_OPTIONS = {
54
118
  allowedTags: [
55
119
  "div",
@@ -94,12 +158,93 @@ const SANITIZE_OPTIONS = {
94
158
  }
95
159
  }
96
160
  };
161
+ let buildQueueTimer = null;
162
+ let buildQueueKickTimer = null;
163
+ let buildQueueProcessing = false;
164
+ let buildQueueWakeHandler = null;
165
+ class StaleBuildRunError extends Error {
166
+ constructor(spaceId, runId) {
167
+ super(`Build run ${runId} for space ${spaceId} is no longer current`);
168
+ this.name = "StaleBuildRunError";
169
+ }
170
+ }
97
171
  function clampChapterCount(value) {
98
172
  const count = Number(value);
99
173
  if (!Number.isFinite(count)) return DEFAULT_TARGET_CHAPTERS;
100
174
  return Math.max(MIN_CHAPTERS, Math.min(MAX_CHAPTERS, Math.round(count)));
101
175
  }
102
- async function fetchFileContent(app, file) {
176
+ function resolveExtname(file) {
177
+ const explicit = file == null ? void 0 : file.extname;
178
+ if (typeof explicit === "string" && explicit) return explicit.toLowerCase();
179
+ const name = (file == null ? void 0 : file.filename) || (file == null ? void 0 : file.name) || "";
180
+ const index = String(name).lastIndexOf(".");
181
+ return index >= 0 ? String(name).slice(index).toLowerCase() : "";
182
+ }
183
+ function isTextDocument(file) {
184
+ const mimetype = String((file == null ? void 0 : file.mimetype) || "").toLowerCase();
185
+ if (mimetype.startsWith("text/")) return true;
186
+ if (TEXT_MIMETYPES.has(mimetype)) return true;
187
+ return TEXT_EXTENSIONS.has(resolveExtname(file));
188
+ }
189
+ function createParserContext(app) {
190
+ const headers = { "x-timezone": "+00:00", "x-locale": "en-US" };
191
+ return {
192
+ app,
193
+ db: app.db,
194
+ log: app.log || app.logger || console,
195
+ logger: app.logger || app.log || console,
196
+ state: {},
197
+ auth: {},
198
+ req: { headers },
199
+ request: { headers },
200
+ get(name) {
201
+ return headers[String(name).toLowerCase()] || "";
202
+ },
203
+ getCurrentLocale() {
204
+ return "en-US";
205
+ },
206
+ t(key) {
207
+ return key;
208
+ },
209
+ i18n: {
210
+ t(key) {
211
+ return key;
212
+ }
213
+ }
214
+ };
215
+ }
216
+ function extractParsedText(value) {
217
+ if (!value) return "";
218
+ if (typeof value === "string") return value;
219
+ if (Array.isArray(value)) {
220
+ return value.map(extractParsedText).filter(Boolean).join("\n");
221
+ }
222
+ if (typeof value === "object") {
223
+ if (typeof value.text === "string") return value.text;
224
+ if (typeof value.content === "string") return value.content;
225
+ if (value.content) return extractParsedText(value.content);
226
+ if (value.message) return extractParsedText(value.message);
227
+ }
228
+ return "";
229
+ }
230
+ function getDocumentParserPlugin(app) {
231
+ var _a, _b, _c, _d;
232
+ return ((_b = (_a = app.pm) == null ? void 0 : _a.get) == null ? void 0 : _b.call(_a, "@nocobase/plugin-document-parser")) || ((_d = (_c = app.pm) == null ? void 0 : _c.get) == null ? void 0 : _d.call(_c, "plugin-document-parser")) || null;
233
+ }
234
+ function unsupportedDocumentMessage(file) {
235
+ const filename = (file == null ? void 0 : file.filename) || (file == null ? void 0 : file.name) || (file == null ? void 0 : file.id) || "document";
236
+ const type = (file == null ? void 0 : file.mimetype) || resolveExtname(file) || "unknown type";
237
+ return `[Unsupported document type: ${filename} (${type}). Install or enable plugin-document-parser/MarkItDown to extract this file.]`;
238
+ }
239
+ async function fetchTextFileContent(app, file) {
240
+ if (!isTextDocument(file)) {
241
+ return unsupportedDocumentMessage(file);
242
+ }
243
+ const docParserPlugin = getDocumentParserPlugin(app);
244
+ if (docParserPlugin == null ? void 0 : docParserPlugin.fetchFileBuffer) {
245
+ const { buffer } = await docParserPlugin.fetchFileBuffer(createParserContext(app), file);
246
+ return buffer.toString("utf8");
247
+ }
103
248
  const fileManager = app.pm.get("file-manager");
104
249
  if (!fileManager) return "";
105
250
  const url = await fileManager.getFileURL(file);
@@ -119,6 +264,42 @@ async function fetchFileContent(app, file) {
119
264
  return `[Failed to read document: ${file.filename}]`;
120
265
  }
121
266
  }
267
+ async function parseWithDocumentParser(app, file) {
268
+ var _a, _b, _c, _d, _e;
269
+ const docParserPlugin = getDocumentParserPlugin(app);
270
+ if (!docParserPlugin) return "";
271
+ const parserCtx = createParserContext(app);
272
+ const defaultParser = async () => ({
273
+ placement: "contentBlocks",
274
+ content: {
275
+ type: "text",
276
+ text: await fetchTextFileContent(app, file)
277
+ }
278
+ });
279
+ try {
280
+ if ((_a = docParserPlugin.parseRouter) == null ? void 0 : _a.route) {
281
+ const result = await docParserPlugin.parseRouter.route(parserCtx, file, defaultParser);
282
+ const text = extractParsedText(result == null ? void 0 : result.content);
283
+ if (text && !text.startsWith("[Unsupported document type:")) {
284
+ return text;
285
+ }
286
+ }
287
+ if ((_b = docParserPlugin.internalParserRegistry) == null ? void 0 : _b.parse) {
288
+ const result = await docParserPlugin.internalParserRegistry.parse(file, parserCtx);
289
+ if ((result == null ? void 0 : result.handled) && ((_c = result == null ? void 0 : result.text) == null ? void 0 : _c.trim())) {
290
+ return result.text;
291
+ }
292
+ }
293
+ } catch (err) {
294
+ (_e = (_d = app.log) == null ? void 0 : _d.warn) == null ? void 0 : _e.call(_d, `[plugin-build-guide-block] Document parser failed for ${(file == null ? void 0 : file.filename) || (file == null ? void 0 : file.id)}`, err);
295
+ }
296
+ return "";
297
+ }
298
+ async function fetchFileContent(app, file) {
299
+ const parsedText = await parseWithDocumentParser(app, file);
300
+ if (parsedText) return parsedText;
301
+ return fetchTextFileContent(app, file);
302
+ }
122
303
  function toPlainText(value) {
123
304
  if (typeof value === "string") return value;
124
305
  if (Array.isArray(value)) {
@@ -319,59 +500,114 @@ ${content}
319
500
  );
320
501
  return texts.join("\n");
321
502
  }
322
- async function runBuild(app, db, filterByTk) {
503
+ function getSpaceModel(app) {
504
+ return app.db.getModel("aiBuildGuideSpaces");
505
+ }
506
+ async function updateSpaceForRun(app, run, values, optional = false) {
507
+ const SpaceModel = getSpaceModel(app);
508
+ const [affected] = await SpaceModel.update(values, {
509
+ where: {
510
+ id: run.spaceId,
511
+ buildRunId: run.runId
512
+ }
513
+ });
514
+ if (!affected && !optional) {
515
+ throw new StaleBuildRunError(run.spaceId, run.runId);
516
+ }
517
+ return affected > 0;
518
+ }
519
+ async function claimBuildRun(app, run, workerId) {
520
+ const now = /* @__PURE__ */ new Date();
521
+ const SpaceModel = getSpaceModel(app);
522
+ const [affected] = await SpaceModel.update(
523
+ {
524
+ buildPhase: "running",
525
+ buildStartedAt: now,
526
+ buildHeartbeatAt: now,
527
+ buildWorkerId: workerId
528
+ },
529
+ {
530
+ where: {
531
+ id: run.spaceId,
532
+ status: "building",
533
+ buildPhase: "queued",
534
+ buildRunId: run.runId
535
+ }
536
+ }
537
+ );
538
+ return affected > 0;
539
+ }
540
+ function getBuildWorkerId(app) {
541
+ return [
542
+ process.env.HOSTNAME || process.env.COMPUTERNAME || "worker",
543
+ app.name || "app",
544
+ app.instanceId || "0",
545
+ process.pid
546
+ ].join(":");
547
+ }
548
+ function startBuildHeartbeat(app, run) {
549
+ const timer = setInterval(() => {
550
+ updateSpaceForRun(
551
+ app,
552
+ run,
553
+ {
554
+ buildHeartbeatAt: /* @__PURE__ */ new Date()
555
+ },
556
+ true
557
+ ).catch((error) => {
558
+ var _a, _b;
559
+ (_b = (_a = app.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(_a, `[plugin-build-guide-block] Failed to update heartbeat for build ${run.runId}`, error);
560
+ });
561
+ }, BUILD_HEARTBEAT_INTERVAL_MS);
562
+ return () => clearInterval(timer);
563
+ }
564
+ async function runBuild(app, db, run) {
323
565
  const spaceRepo = db.getRepository("aiBuildGuideSpaces");
324
566
  const pageRepo = db.getRepository("aiBuildGuidePages");
325
- const space = await spaceRepo.findById(filterByTk);
567
+ const space = await spaceRepo.findById(run.spaceId);
326
568
  if (!space) {
327
569
  throw new Error("Space not found");
328
570
  }
571
+ if (space.get("buildRunId") !== run.runId) {
572
+ throw new StaleBuildRunError(run.spaceId, run.runId);
573
+ }
329
574
  const { llmService, model } = space.get();
330
575
  if (!llmService || !model) {
331
576
  throw new Error("LLM Service or model is missing in space configuration");
332
577
  }
333
578
  await pageRepo.destroy({
334
579
  filter: {
335
- spaceId: filterByTk
580
+ spaceId: run.spaceId
336
581
  }
337
582
  });
338
- await spaceRepo.update({
339
- filterByTk,
340
- values: {
341
- buildPhase: "reading",
342
- buildLog: "Reading source documents",
343
- generatedHtml: null,
344
- generatedMarkdown: null,
345
- planJson: null,
346
- pageCount: 0
347
- }
583
+ await updateSpaceForRun(app, run, {
584
+ buildPhase: "reading",
585
+ buildLog: "Reading source documents",
586
+ generatedHtml: null,
587
+ generatedMarkdown: null,
588
+ planJson: null,
589
+ pageCount: 0
348
590
  });
349
591
  const documentsText = await readDocuments(app, space);
350
592
  const sourceHash = import_crypto.default.createHash("sha256").update(documentsText).digest("hex");
351
593
  const provider = await getLLMProvider(app, llmService, model);
352
- await spaceRepo.update({
353
- filterByTk,
354
- values: {
355
- buildPhase: "planning",
356
- buildLog: "Creating guide breakdown plan",
357
- sourceHash
358
- }
594
+ await updateSpaceForRun(app, run, {
595
+ buildPhase: "planning",
596
+ buildLog: "Creating guide breakdown plan",
597
+ sourceHash
359
598
  });
360
599
  const plan = await buildPlan(provider, space, documentsText);
361
- await spaceRepo.update({
362
- filterByTk,
363
- values: {
364
- planJson: plan,
365
- pageCount: plan.chapters.length,
366
- buildPhase: "building_pages",
367
- buildLog: `Plan created with ${plan.chapters.length} chapters`
368
- }
600
+ await updateSpaceForRun(app, run, {
601
+ planJson: plan,
602
+ pageCount: plan.chapters.length,
603
+ buildPhase: "building_pages",
604
+ buildLog: `Plan created with ${plan.chapters.length} chapters`
369
605
  });
370
606
  const pageRecords = [];
371
607
  for (const [index, chapter] of plan.chapters.entries()) {
372
608
  const page = await pageRepo.create({
373
609
  values: {
374
- spaceId: filterByTk,
610
+ spaceId: run.spaceId,
375
611
  sort: index + 1,
376
612
  title: chapter.title,
377
613
  slug: slugify(chapter.title, `chapter-${index + 1}`),
@@ -392,12 +628,9 @@ async function runBuild(app, db, filterByTk) {
392
628
  buildLog: "Building chapter with LLM"
393
629
  }
394
630
  });
395
- await spaceRepo.update({
396
- filterByTk,
397
- values: {
398
- buildPhase: "building_pages",
399
- buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`
400
- }
631
+ await updateSpaceForRun(app, run, {
632
+ buildPhase: "building_pages",
633
+ buildLog: `Building chapter ${index + 1}/${pageRecords.length}: ${chapter.title}`
401
634
  });
402
635
  try {
403
636
  const markdown = await buildPageMarkdown(provider, space, plan, chapter, documentsText);
@@ -424,76 +657,442 @@ async function runBuild(app, db, filterByTk) {
424
657
  }
425
658
  const completedPages = await pageRepo.find({
426
659
  filter: {
427
- spaceId: filterByTk,
660
+ spaceId: run.spaceId,
428
661
  status: "completed"
429
662
  },
430
663
  sort: ["sort"]
431
664
  });
432
665
  const combinedMarkdown = completedPages.map((page) => page.get("generatedMarkdown")).filter(Boolean).join("\n\n---\n\n");
433
666
  const combinedHtml = await markdownToCleanHtml(combinedMarkdown);
434
- await spaceRepo.update({
435
- filterByTk,
667
+ await updateSpaceForRun(app, run, {
668
+ status: "completed",
669
+ buildPhase: "completed",
670
+ buildLog: `Built ${completedPages.length} chapters successfully`,
671
+ generatedMarkdown: combinedMarkdown,
672
+ generatedHtml: combinedHtml,
673
+ buildHeartbeatAt: /* @__PURE__ */ new Date()
674
+ });
675
+ }
676
+ function isBuildGuideWorker(app) {
677
+ const workerMode = process.env.WORKER_MODE || "";
678
+ return app.serving(WORKER_JOB_BUILD_GUIDE_PROCESS) || workerMode === "worker" || workerMode === "task" || process.env.APP_ROLE === "worker";
679
+ }
680
+ function clearLocalBuildMemoryQueue(app) {
681
+ var _a, _b, _c, _d, _e;
682
+ const eventQueue = app.eventQueue;
683
+ const adapter = eventQueue == null ? void 0 : eventQueue.adapter;
684
+ const fullChannel = (_a = eventQueue == null ? void 0 : eventQueue.getFullChannel) == null ? void 0 : _a.call(eventQueue, BUILD_GUIDE_QUEUE_CHANNEL);
685
+ const queue = fullChannel ? (_c = (_b = adapter == null ? void 0 : adapter.queues) == null ? void 0 : _b.get) == null ? void 0 : _c.call(_b, fullChannel) : null;
686
+ if (!(queue == null ? void 0 : queue.length)) return;
687
+ adapter.queues.set(fullChannel, []);
688
+ (_e = (_d = app.log) == null ? void 0 : _d.warn) == null ? void 0 : _e.call(
689
+ _d,
690
+ `[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`
691
+ );
692
+ }
693
+ function getBuildQueueRedisKey(app) {
694
+ const appName = app.name || process.env.APP_NAME || "main";
695
+ return `${appName}:plugin-build-guide-block:build:queue`;
696
+ }
697
+ async function getBuildQueueRedis(app) {
698
+ var _a, _b;
699
+ const manager = app.redisConnectionManager;
700
+ if (!(manager == null ? void 0 : manager.getConnectionSync)) {
701
+ return null;
702
+ }
703
+ try {
704
+ const connectionString = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
705
+ return await manager.getConnectionSync(
706
+ BUILD_GUIDE_QUEUE_REDIS_CONNECTION,
707
+ connectionString ? { connectionString } : void 0
708
+ );
709
+ } catch (error) {
710
+ (_b = (_a = app.log) == null ? void 0 : _a.debug) == null ? void 0 : _b.call(
711
+ _a,
712
+ `[plugin-build-guide-block] Redis queue unavailable; DB polling fallback active: ${(error == null ? void 0 : error.message) || error}`
713
+ );
714
+ return null;
715
+ }
716
+ }
717
+ async function enqueueBuildToRedis(app, message) {
718
+ var _a, _b, _c, _d;
719
+ const redis = await getBuildQueueRedis(app);
720
+ if (!redis) return false;
721
+ try {
722
+ await redis.sendCommand(["RPUSH", getBuildQueueRedisKey(app), JSON.stringify(message)]);
723
+ (_b = (_a = app.log) == null ? void 0 : _a.debug) == null ? void 0 : _b.call(
724
+ _a,
725
+ `[plugin-build-guide-block] Enqueued build ${message.runId} for space "${message.spaceId}" to Redis`
726
+ );
727
+ return true;
728
+ } catch (error) {
729
+ (_d = (_c = app.log) == null ? void 0 : _c.warn) == null ? void 0 : _d.call(_c, `[plugin-build-guide-block] Failed to enqueue build to Redis; DB polling fallback active`, error);
730
+ return false;
731
+ }
732
+ }
733
+ async function publishBuildQueueWake(app, message) {
734
+ var _a, _b, _c, _d;
735
+ try {
736
+ await ((_b = (_a = app.pubSubManager) == null ? void 0 : _a.publish) == null ? void 0 : _b.call(
737
+ _a,
738
+ BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
739
+ { spaceId: message == null ? void 0 : message.spaceId, runId: message == null ? void 0 : message.runId },
740
+ { skipSelf: !isBuildGuideWorker(app) }
741
+ ));
742
+ } catch (error) {
743
+ (_d = (_c = app.log) == null ? void 0 : _c.debug) == null ? void 0 : _d.call(_c, `[plugin-build-guide-block] Wake publish skipped: ${(error == null ? void 0 : error.message) || error}`);
744
+ }
745
+ }
746
+ function startBuildGuideQueueProcessor(app) {
747
+ var _a, _b, _c, _d, _e, _f, _g;
748
+ if (!isBuildGuideWorker(app)) {
749
+ (_b = (_a = app.log) == null ? void 0 : _a.debug) == null ? void 0 : _b.call(_a, "[plugin-build-guide-block] Build queue processor disabled on non-worker node");
750
+ return;
751
+ }
752
+ if (buildQueueTimer) return;
753
+ buildQueueWakeHandler = async () => {
754
+ scheduleBuildQueueTick(app, 0);
755
+ };
756
+ const subscribe = (_d = (_c = app.pubSubManager) == null ? void 0 : _c.subscribe) == null ? void 0 : _d.call(_c, BUILD_GUIDE_QUEUE_WAKE_CHANNEL, buildQueueWakeHandler);
757
+ if (subscribe == null ? void 0 : subscribe.catch) {
758
+ subscribe.catch((error) => {
759
+ var _a2, _b2;
760
+ (_b2 = (_a2 = app.log) == null ? void 0 : _a2.debug) == null ? void 0 : _b2.call(_a2, `[plugin-build-guide-block] Wake subscribe skipped: ${(error == null ? void 0 : error.message) || error}`);
761
+ });
762
+ }
763
+ buildQueueTimer = setInterval(() => scheduleBuildQueueTick(app, 0), BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS);
764
+ (_e = buildQueueTimer.unref) == null ? void 0 : _e.call(buildQueueTimer);
765
+ scheduleBuildQueueTick(app, 1e3);
766
+ (_g = (_f = app.log) == null ? void 0 : _f.info) == null ? void 0 : _g.call(
767
+ _f,
768
+ `[plugin-build-guide-block] Build queue processor started (interval ${BUILD_GUIDE_QUEUE_POLL_INTERVAL_MS}ms)`
769
+ );
770
+ }
771
+ function stopBuildGuideQueueProcessor(app) {
772
+ var _a, _b;
773
+ if (buildQueueTimer) {
774
+ clearInterval(buildQueueTimer);
775
+ buildQueueTimer = null;
776
+ }
777
+ if (buildQueueKickTimer) {
778
+ clearTimeout(buildQueueKickTimer);
779
+ buildQueueKickTimer = null;
780
+ }
781
+ if (buildQueueWakeHandler) {
782
+ const unsubscribe = (_b = (_a = app.pubSubManager) == null ? void 0 : _a.unsubscribe) == null ? void 0 : _b.call(
783
+ _a,
784
+ BUILD_GUIDE_QUEUE_WAKE_CHANNEL,
785
+ buildQueueWakeHandler
786
+ );
787
+ if (unsubscribe == null ? void 0 : unsubscribe.catch) {
788
+ unsubscribe.catch(() => void 0);
789
+ }
790
+ buildQueueWakeHandler = null;
791
+ }
792
+ buildQueueProcessing = false;
793
+ }
794
+ function scheduleBuildQueueTick(app, delayMs) {
795
+ var _a;
796
+ if (buildQueueKickTimer) return;
797
+ buildQueueKickTimer = setTimeout(() => {
798
+ buildQueueKickTimer = null;
799
+ runBuildQueueTick(app).catch((error) => {
800
+ var _a2, _b;
801
+ (_b = (_a2 = app.log) == null ? void 0 : _a2.error) == null ? void 0 : _b.call(_a2, "[plugin-build-guide-block] Build queue tick failed", error);
802
+ });
803
+ }, delayMs);
804
+ (_a = buildQueueKickTimer.unref) == null ? void 0 : _a.call(buildQueueKickTimer);
805
+ }
806
+ async function runBuildQueueTick(app) {
807
+ if (buildQueueProcessing || !isBuildGuideWorker(app)) return;
808
+ buildQueueProcessing = true;
809
+ try {
810
+ const redisMessages = await drainRedisBuildQueue(app, BUILD_GUIDE_QUEUE_CONCURRENCY);
811
+ await processBuildQueueMessages(app, redisMessages);
812
+ const remaining = Math.max(1, BUILD_GUIDE_QUEUE_CONCURRENCY - redisMessages.length);
813
+ await processQueuedBuildsFromDb(app, remaining);
814
+ } finally {
815
+ buildQueueProcessing = false;
816
+ }
817
+ }
818
+ async function drainRedisBuildQueue(app, count) {
819
+ var _a, _b;
820
+ const redis = await getBuildQueueRedis(app);
821
+ if (!redis) return [];
822
+ const key = getBuildQueueRedisKey(app);
823
+ const messages = [];
824
+ for (let i = 0; i < count; i += 1) {
825
+ const raw = await redis.sendCommand(["LPOP", key]);
826
+ if (!raw) break;
827
+ try {
828
+ messages.push(JSON.parse(String(raw)));
829
+ } catch (error) {
830
+ (_b = (_a = app.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(_a, `[plugin-build-guide-block] Dropped invalid Redis build message: ${(error == null ? void 0 : error.message) || error}`);
831
+ }
832
+ }
833
+ return messages;
834
+ }
835
+ function createBuildQueueMessageFromSpace(space) {
836
+ const runId = space.get("buildRunId");
837
+ if (!runId) return null;
838
+ return {
839
+ spaceId: String(space.get("id")),
840
+ runId: String(runId),
841
+ queuedAt: space.get("buildQueuedAt") ? new Date(space.get("buildQueuedAt")).toISOString() : void 0
842
+ };
843
+ }
844
+ async function processQueuedBuildsFromDb(app, count) {
845
+ const spaceRepo = app.db.getRepository("aiBuildGuideSpaces");
846
+ const spaces = await spaceRepo.find({
847
+ filter: {
848
+ status: "building",
849
+ buildPhase: "queued"
850
+ },
851
+ sort: ["buildQueuedAt"],
852
+ limit: count
853
+ });
854
+ const messages = spaces.map(createBuildQueueMessageFromSpace).filter(Boolean);
855
+ await processBuildQueueMessages(app, messages);
856
+ }
857
+ async function processBuildQueueMessages(app, messages) {
858
+ if (!messages.length) return;
859
+ await Promise.all(messages.map((message) => processQueuedBuild(app, message)));
860
+ }
861
+ async function markBuildError(app, spaceId, runId, error) {
862
+ const buildLog = (error == null ? void 0 : error.message) || String(error);
863
+ let updated = false;
864
+ if (runId) {
865
+ updated = await updateSpaceForRun(
866
+ app,
867
+ { spaceId, runId },
868
+ {
869
+ status: "error",
870
+ buildPhase: "error",
871
+ buildLog,
872
+ buildHeartbeatAt: /* @__PURE__ */ new Date()
873
+ },
874
+ true
875
+ );
876
+ } else {
877
+ await app.db.getRepository("aiBuildGuideSpaces").update({
878
+ filterByTk: spaceId,
879
+ values: {
880
+ status: "error",
881
+ buildPhase: "error",
882
+ buildLog
883
+ }
884
+ });
885
+ updated = true;
886
+ }
887
+ if (!updated) {
888
+ return;
889
+ }
890
+ await app.db.getRepository("aiBuildGuidePages").update({
891
+ filter: {
892
+ spaceId,
893
+ status: "building"
894
+ },
436
895
  values: {
437
- status: "completed",
438
- buildPhase: "completed",
439
- buildLog: `Built ${completedPages.length} chapters successfully`,
440
- generatedMarkdown: combinedMarkdown,
441
- generatedHtml: combinedHtml
896
+ status: "error",
897
+ buildLog
898
+ }
899
+ });
900
+ }
901
+ async function enqueueBuild(app, message) {
902
+ var _a, _b;
903
+ try {
904
+ const queuedInRedis = await enqueueBuildToRedis(app, message);
905
+ if (queuedInRedis) {
906
+ await publishBuildQueueWake(app, message);
907
+ return;
908
+ }
909
+ await publishBuildQueueWake(app, message);
910
+ if (isBuildGuideWorker(app)) {
911
+ await app.eventQueue.publish(BUILD_GUIDE_QUEUE_CHANNEL, message, {
912
+ timeout: BUILD_GUIDE_QUEUE_TIMEOUT_MS,
913
+ maxRetries: 0
914
+ });
915
+ return;
916
+ }
917
+ (_b = (_a = app.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(
918
+ _a,
919
+ `[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`
920
+ );
921
+ } catch (error) {
922
+ await markBuildError(app, message.spaceId, message.runId, error);
923
+ throw error;
924
+ }
925
+ }
926
+ async function processQueuedBuild(app, message) {
927
+ var _a, _b;
928
+ const spaceId = message == null ? void 0 : message.spaceId;
929
+ const runId = message == null ? void 0 : message.runId;
930
+ if (!spaceId || !runId) {
931
+ (_b = (_a = app.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(_a, "[plugin-build-guide-block] Build queue message missing spaceId or runId");
932
+ return;
933
+ }
934
+ await withBuildRunLock(app, spaceId, async () => {
935
+ var _a2, _b2, _c, _d, _e, _f, _g, _h, _i, _j;
936
+ const run = { spaceId, runId };
937
+ const workerId = getBuildWorkerId(app);
938
+ const claimed = await claimBuildRun(app, run, workerId);
939
+ if (!claimed) {
940
+ (_b2 = (_a2 = app.log) == null ? void 0 : _a2.info) == null ? void 0 : _b2.call(_a2, `[plugin-build-guide-block] Build ${runId} for space "${spaceId}" was already claimed or stale`);
941
+ return;
942
+ }
943
+ const spaceRepo = app.db.getRepository("aiBuildGuideSpaces");
944
+ const space = await spaceRepo.findById(spaceId);
945
+ if (!space) {
946
+ (_d = (_c = app.log) == null ? void 0 : _c.warn) == null ? void 0 : _d.call(_c, `[plugin-build-guide-block] Build space "${spaceId}" not found; skipping queued build`);
947
+ return;
948
+ }
949
+ if (space.get("status") !== "building") {
950
+ (_f = (_e = app.log) == null ? void 0 : _e.info) == null ? void 0 : _f.call(
951
+ _e,
952
+ `[plugin-build-guide-block] Build space "${spaceId}" is ${space.get("status")}; skipping queued build`
953
+ );
954
+ return;
955
+ }
956
+ const stopHeartbeat = startBuildHeartbeat(app, run);
957
+ try {
958
+ await runBuild(app, app.db, run);
959
+ } catch (error) {
960
+ if (error instanceof StaleBuildRunError) {
961
+ (_h = (_g = app.log) == null ? void 0 : _g.info) == null ? void 0 : _h.call(_g, `[plugin-build-guide-block] ${error.message}`);
962
+ return;
963
+ }
964
+ (_j = (_i = app.log) == null ? void 0 : _i.error) == null ? void 0 : _j.call(_i, "Build Guide Worker Error", error);
965
+ await markBuildError(app, spaceId, runId, error);
966
+ } finally {
967
+ stopHeartbeat();
968
+ }
969
+ });
970
+ }
971
+ function registerBuildGuideQueue(app) {
972
+ app.eventQueue.subscribe(BUILD_GUIDE_QUEUE_CHANNEL, {
973
+ concurrency: BUILD_GUIDE_QUEUE_CONCURRENCY,
974
+ idle: () => isBuildGuideWorker(app),
975
+ process: async (message) => {
976
+ await processQueuedBuild(app, message);
442
977
  }
443
978
  });
979
+ if (!isBuildGuideWorker(app)) {
980
+ app.on("afterStart", () => clearLocalBuildMemoryQueue(app));
981
+ }
982
+ startBuildGuideQueueProcessor(app);
983
+ }
984
+ function unregisterBuildGuideQueue(app) {
985
+ app.eventQueue.unsubscribe(BUILD_GUIDE_QUEUE_CHANNEL);
986
+ stopBuildGuideQueueProcessor(app);
987
+ }
988
+ async function withBuildTriggerLock(app, spaceId, fn) {
989
+ return app.lockManager.runExclusive(`build-guide:trigger:${spaceId}`, fn, BUILD_TRIGGER_LOCK_TTL_MS);
990
+ }
991
+ async function withBuildRunLock(app, spaceId, fn) {
992
+ return app.lockManager.runExclusive(`build-guide:run:${spaceId}`, fn, BUILD_RUN_LOCK_TTL_MS);
993
+ }
994
+ async function recoverInterruptedBuilds(app) {
995
+ var _a, _b;
996
+ const spaceRepo = app.db.getRepository("aiBuildGuideSpaces");
997
+ const pageRepo = app.db.getRepository("aiBuildGuidePages");
998
+ const staleBefore = new Date(Date.now() - BUILD_STALE_MS);
999
+ const spaces = await spaceRepo.find({
1000
+ filter: {
1001
+ status: "building",
1002
+ $or: [{ buildHeartbeatAt: null }, { buildHeartbeatAt: { $lt: staleBefore } }]
1003
+ }
1004
+ });
1005
+ for (const space of spaces) {
1006
+ const spaceId = String(space.get("id"));
1007
+ const runId = String(space.get("buildRunId") || import_crypto.default.randomUUID());
1008
+ const SpaceModel = getSpaceModel(app);
1009
+ const [affected] = await SpaceModel.update(
1010
+ {
1011
+ buildPhase: "queued",
1012
+ buildLog: "Build re-queued after worker restart",
1013
+ buildRunId: runId,
1014
+ buildQueuedAt: /* @__PURE__ */ new Date(),
1015
+ buildStartedAt: null,
1016
+ buildHeartbeatAt: null,
1017
+ buildWorkerId: null
1018
+ },
1019
+ {
1020
+ where: {
1021
+ id: spaceId,
1022
+ status: "building",
1023
+ buildRunId: space.get("buildRunId") || null
1024
+ }
1025
+ }
1026
+ );
1027
+ if (!affected) {
1028
+ continue;
1029
+ }
1030
+ await pageRepo.update({
1031
+ filter: {
1032
+ spaceId,
1033
+ status: "building"
1034
+ },
1035
+ values: {
1036
+ status: "pending",
1037
+ buildLog: "Build re-queued after worker restart"
1038
+ }
1039
+ });
1040
+ await enqueueBuild(app, {
1041
+ spaceId,
1042
+ runId,
1043
+ queuedAt: (/* @__PURE__ */ new Date()).toISOString()
1044
+ });
1045
+ }
1046
+ if (spaces.length) {
1047
+ (_b = (_a = app.log) == null ? void 0 : _a.info) == null ? void 0 : _b.call(_a, `[plugin-build-guide-block] Re-queued ${spaces.length} interrupted build(s)`);
1048
+ }
444
1049
  }
445
1050
  async function build(ctx, next) {
446
1051
  const { filterByTk } = ctx.action.params;
447
- const repository = ctx.db.getRepository("aiBuildGuideSpaces");
448
- const space = await repository.findById(filterByTk);
449
- if (!space) {
450
- ctx.throw(404, "Space not found");
451
- }
452
- if (space.get("status") === "building") {
453
- ctx.throw(409, "A build is already in progress for this space");
1052
+ if (!filterByTk) {
1053
+ ctx.throw(400, "Space id is required");
454
1054
  }
455
1055
  const app = ctx.app;
456
- const db = ctx.db;
457
- try {
1056
+ const repository = ctx.db.getRepository("aiBuildGuideSpaces");
1057
+ const body = await withBuildTriggerLock(app, String(filterByTk), async () => {
1058
+ var _a, _b;
1059
+ const runId = import_crypto.default.randomUUID();
1060
+ const space = await repository.findById(filterByTk);
1061
+ if (!space) {
1062
+ ctx.throw(404, "Space not found");
1063
+ }
1064
+ if (space.get("status") === "building") {
1065
+ ctx.throw(409, "A build is already in progress for this space");
1066
+ }
458
1067
  await repository.update({
459
1068
  filterByTk,
460
1069
  values: {
461
1070
  status: "building",
462
1071
  buildPhase: "queued",
463
- buildLog: "Build queued"
1072
+ buildLog: "Build queued",
1073
+ buildRunId: runId,
1074
+ buildQueuedAt: /* @__PURE__ */ new Date(),
1075
+ buildStartedAt: null,
1076
+ buildHeartbeatAt: null,
1077
+ buildWorkerId: null
464
1078
  }
465
1079
  });
466
- runBuild(app, db, filterByTk).catch(async (error) => {
467
- app.log.error("Build Guide Background Error", error);
468
- try {
469
- await repository.update({
470
- filterByTk,
471
- values: {
472
- status: "error",
473
- buildPhase: "error",
474
- buildLog: error.message || String(error)
475
- }
476
- });
477
- } catch (updateErr) {
478
- app.log.error("Failed to persist build error status", updateErr);
479
- }
1080
+ await enqueueBuild(app, {
1081
+ spaceId: String(filterByTk),
1082
+ runId,
1083
+ userId: ((_b = (_a = ctx.state) == null ? void 0 : _a.currentUser) == null ? void 0 : _b.id) ?? null,
1084
+ queuedAt: (/* @__PURE__ */ new Date()).toISOString()
480
1085
  });
481
- ctx.body = { status: "building" };
482
- } catch (error) {
483
- app.log.error("Build Guide Error", error);
484
- await repository.update({
485
- filterByTk,
486
- values: {
487
- status: "error",
488
- buildPhase: "error",
489
- buildLog: error.message || String(error)
490
- }
491
- });
492
- ctx.throw(500, error.message || "Error occurred during build");
493
- }
1086
+ return { status: "building" };
1087
+ });
1088
+ ctx.body = body;
494
1089
  await next();
495
1090
  }
496
1091
  // Annotate the CommonJS export names for ESM import in node:
497
1092
  0 && (module.exports = {
498
- build
1093
+ WORKER_JOB_BUILD_GUIDE_PROCESS,
1094
+ build,
1095
+ recoverInterruptedBuilds,
1096
+ registerBuildGuideQueue,
1097
+ unregisterBuildGuideQueue
499
1098
  });