@karmaniverous/jeeves-server 3.0.0-0

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 (260) hide show
  1. package/.env.local +13 -0
  2. package/.env.local.template +13 -0
  3. package/.tsbuildinfo +1 -0
  4. package/CHANGELOG.md +450 -0
  5. package/about.md +82 -0
  6. package/client/README.md +73 -0
  7. package/client/eslint.config.js +23 -0
  8. package/client/index.html +14 -0
  9. package/client/package-lock.json +5181 -0
  10. package/client/package.json +60 -0
  11. package/client/public/vite.svg +1 -0
  12. package/client/src/App.tsx +22 -0
  13. package/client/src/components/AccountMenu.tsx +167 -0
  14. package/client/src/components/ActionDropdown.tsx +120 -0
  15. package/client/src/components/CodeEditor.tsx +143 -0
  16. package/client/src/components/CodeViewer.tsx +113 -0
  17. package/client/src/components/ConfirmDialog.tsx +32 -0
  18. package/client/src/components/DirectoryRow.tsx +62 -0
  19. package/client/src/components/DirectoryTable.tsx +42 -0
  20. package/client/src/components/DownloadDropdown.tsx +116 -0
  21. package/client/src/components/DriveList.tsx +54 -0
  22. package/client/src/components/EmbeddedDiagramPanzoom.ts +28 -0
  23. package/client/src/components/FileContentView.tsx +155 -0
  24. package/client/src/components/InlineSvgPanzoom.ts +60 -0
  25. package/client/src/components/LazyDiagram.ts +93 -0
  26. package/client/src/components/LinkDropdown.tsx +134 -0
  27. package/client/src/components/MarkdownView.tsx +115 -0
  28. package/client/src/components/MermaidViewer.tsx +21 -0
  29. package/client/src/components/PlantUmlViewer.tsx +21 -0
  30. package/client/src/components/SearchModal.tsx +424 -0
  31. package/client/src/components/SvgViewer.tsx +107 -0
  32. package/client/src/components/TabBar.tsx +96 -0
  33. package/client/src/components/layout/Header.tsx +270 -0
  34. package/client/src/components/panzoom.ts +203 -0
  35. package/client/src/components/renderableUtils.ts +15 -0
  36. package/client/src/components/runner/JobTable.tsx +153 -0
  37. package/client/src/components/runner/RunHistory.tsx +140 -0
  38. package/client/src/components/runner/StatsBar.tsx +43 -0
  39. package/client/src/components/runner/StatusPill.tsx +27 -0
  40. package/client/src/components/runner/jobTableUtils.ts +65 -0
  41. package/client/src/components/scrollUtils.ts +39 -0
  42. package/client/src/components/ui/alert-dialog.tsx +107 -0
  43. package/client/src/components/ui/button.tsx +40 -0
  44. package/client/src/components/ui/dropdown-menu.tsx +79 -0
  45. package/client/src/components/ui/input.tsx +26 -0
  46. package/client/src/components/useActionState.ts +43 -0
  47. package/client/src/hooks/useFileBrowser.ts +102 -0
  48. package/client/src/hooks/useFileData.ts +78 -0
  49. package/client/src/hooks/useScrollAnchor.ts +70 -0
  50. package/client/src/hooks/useShareSettings.ts +22 -0
  51. package/client/src/hooks/useTopBar.ts +27 -0
  52. package/client/src/index.css +281 -0
  53. package/client/src/lib/AuthContext.ts +27 -0
  54. package/client/src/lib/api.ts +239 -0
  55. package/client/src/lib/auth.tsx +50 -0
  56. package/client/src/lib/codeBlockCm6.ts +129 -0
  57. package/client/src/lib/codeBlockCopy.ts +43 -0
  58. package/client/src/lib/codemirror.ts +77 -0
  59. package/client/src/lib/runner-api.ts +172 -0
  60. package/client/src/lib/svg.ts +50 -0
  61. package/client/src/lib/theme.ts +34 -0
  62. package/client/src/lib/utils.ts +6 -0
  63. package/client/src/main.tsx +11 -0
  64. package/client/src/pages/FileBrowser.tsx +135 -0
  65. package/client/src/pages/Home.tsx +46 -0
  66. package/client/src/pages/Runner.tsx +151 -0
  67. package/client/src/pages/RunnerJob.tsx +170 -0
  68. package/client/tsconfig.app.json +32 -0
  69. package/client/tsconfig.json +7 -0
  70. package/client/tsconfig.node.json +26 -0
  71. package/client/vite.config.ts +35 -0
  72. package/content/privacy.md +61 -0
  73. package/content/terms.md +41 -0
  74. package/dist/client/assets/CodeEditor-0XHVI8Nu.js +1 -0
  75. package/dist/client/assets/CodeViewer-CykMVsfX.js +1 -0
  76. package/dist/client/assets/index--MBieNJA.js +1 -0
  77. package/dist/client/assets/index-BENeXQI_.js +1 -0
  78. package/dist/client/assets/index-BbBpoOxz.js +1 -0
  79. package/dist/client/assets/index-BdV9g5AM.js +6 -0
  80. package/dist/client/assets/index-BjAilRri.js +2 -0
  81. package/dist/client/assets/index-BqbhWo2I.js +3 -0
  82. package/dist/client/assets/index-CVbycZ0H.js +1 -0
  83. package/dist/client/assets/index-Cs5oz2oJ.js +5 -0
  84. package/dist/client/assets/index-D8KZVveX.js +1 -0
  85. package/dist/client/assets/index-DC4HMHxY.js +13 -0
  86. package/dist/client/assets/index-DbMebkkd.css +1 -0
  87. package/dist/client/assets/index-DcY2RXqX.js +1 -0
  88. package/dist/client/assets/index-Duy-tZYV.js +1 -0
  89. package/dist/client/assets/index-Dw7rDFmE.js +7 -0
  90. package/dist/client/assets/index-FlCUvrjv.js +2 -0
  91. package/dist/client/assets/index-K6OVmfhg.js +1 -0
  92. package/dist/client/assets/index-LjwgzZ7F.js +62 -0
  93. package/dist/client/assets/index-MLwyFRN0.js +1 -0
  94. package/dist/client/assets/index-OpqBpSjn.js +1 -0
  95. package/dist/client/assets/index-SsHei0HE.js +1 -0
  96. package/dist/client/assets/index-uQa2yckk.js +1 -0
  97. package/dist/client/assets/index-udkXoIER.js +1 -0
  98. package/dist/client/index.html +15 -0
  99. package/dist/client/vite.svg +1 -0
  100. package/dist/src/auth/google.js +57 -0
  101. package/dist/src/auth/keys.js +185 -0
  102. package/dist/src/auth/resolve.js +102 -0
  103. package/dist/src/auth/session.js +57 -0
  104. package/dist/src/cli/commands/config.js +100 -0
  105. package/dist/src/cli/commands/config.test.js +84 -0
  106. package/dist/src/cli/commands/service.js +93 -0
  107. package/dist/src/cli/commands/start.js +24 -0
  108. package/dist/src/cli/index.js +20 -0
  109. package/dist/src/config/index.js +90 -0
  110. package/dist/src/config/loadConfig.test.js +127 -0
  111. package/dist/src/config/resolve.js +134 -0
  112. package/dist/src/config/resolve.test.js +148 -0
  113. package/dist/src/config/schema.js +159 -0
  114. package/dist/src/config/substituteEnvVars.js +45 -0
  115. package/dist/src/config/substituteEnvVars.test.js +51 -0
  116. package/dist/src/config/types.js +5 -0
  117. package/dist/src/routes/api/auth-status.js +56 -0
  118. package/dist/src/routes/api/diagrams.js +35 -0
  119. package/dist/src/routes/api/directory.js +93 -0
  120. package/dist/src/routes/api/drives.js +15 -0
  121. package/dist/src/routes/api/export.js +218 -0
  122. package/dist/src/routes/api/fileContent.js +286 -0
  123. package/dist/src/routes/api/index.js +33 -0
  124. package/dist/src/routes/api/linkInfo.js +71 -0
  125. package/dist/src/routes/api/linkInfo.test.js +104 -0
  126. package/dist/src/routes/api/middleware.js +117 -0
  127. package/dist/src/routes/api/raw.js +38 -0
  128. package/dist/src/routes/api/runner.js +59 -0
  129. package/dist/src/routes/api/search.js +236 -0
  130. package/dist/src/routes/api/sharing.js +203 -0
  131. package/dist/src/routes/api/status.js +68 -0
  132. package/dist/src/routes/api/status.test.js +62 -0
  133. package/dist/src/routes/auth.js +99 -0
  134. package/dist/src/routes/event.js +77 -0
  135. package/dist/src/routes/event.test.js +206 -0
  136. package/dist/src/routes/health.js +10 -0
  137. package/dist/src/routes/keys.js +129 -0
  138. package/dist/src/routes/path/index.js +17 -0
  139. package/dist/src/routes/static.js +30 -0
  140. package/dist/src/server.js +90 -0
  141. package/dist/src/services/deepShareLinks.js +163 -0
  142. package/dist/src/services/diagramCache.js +104 -0
  143. package/dist/src/services/embeddedDiagrams.js +136 -0
  144. package/dist/src/services/eventLog.js +55 -0
  145. package/dist/src/services/eventLog.test.js +113 -0
  146. package/dist/src/services/eventQueue.js +154 -0
  147. package/dist/src/services/eventQueue.test.js +104 -0
  148. package/dist/src/services/export.js +220 -0
  149. package/dist/src/services/exportCache.js +196 -0
  150. package/dist/src/services/markdown.js +147 -0
  151. package/dist/src/services/mermaid.js +97 -0
  152. package/dist/src/services/plantuml.js +145 -0
  153. package/dist/src/services/puppeteer.js +156 -0
  154. package/dist/src/util/breadcrumbs.js +22 -0
  155. package/dist/src/util/crypto.js +56 -0
  156. package/dist/src/util/crypto.test.js +99 -0
  157. package/dist/src/util/fileDetection.js +66 -0
  158. package/dist/src/util/fileDetection.test.js +89 -0
  159. package/dist/src/util/formatters.js +43 -0
  160. package/dist/src/util/formatters.test.js +83 -0
  161. package/dist/src/util/packageVersion.js +25 -0
  162. package/dist/src/util/platform.js +148 -0
  163. package/dist/src/util/state.js +46 -0
  164. package/dist/vitest.config.js +12 -0
  165. package/favicon.svg +3 -0
  166. package/guides/access-decision-flow.mmd +24 -0
  167. package/guides/access-decision-flow.svg +1 -0
  168. package/guides/api-integration.md +236 -0
  169. package/guides/deployment.md +287 -0
  170. package/guides/event-gateway.md +204 -0
  171. package/guides/event-gateway.mmd +17 -0
  172. package/guides/event-gateway.svg +1 -0
  173. package/guides/exports.md +239 -0
  174. package/guides/setup.md +313 -0
  175. package/guides/sharing.md +204 -0
  176. package/jeeves-server.config.template.json +25 -0
  177. package/package.json +124 -0
  178. package/scripts/download-plantuml.js +70 -0
  179. package/src/auth/google.ts +93 -0
  180. package/src/auth/keys.ts +252 -0
  181. package/src/auth/resolve.ts +157 -0
  182. package/src/auth/session.ts +77 -0
  183. package/src/cli/commands/config.test.ts +107 -0
  184. package/src/cli/commands/config.ts +113 -0
  185. package/src/cli/commands/service.ts +129 -0
  186. package/src/cli/commands/start.ts +27 -0
  187. package/src/cli/index.ts +25 -0
  188. package/src/config/index.ts +113 -0
  189. package/src/config/loadConfig.test.ts +155 -0
  190. package/src/config/resolve.test.ts +192 -0
  191. package/src/config/resolve.ts +173 -0
  192. package/src/config/schema.ts +179 -0
  193. package/src/config/substituteEnvVars.test.ts +64 -0
  194. package/src/config/substituteEnvVars.ts +52 -0
  195. package/src/config/types.ts +129 -0
  196. package/src/routes/api/auth-status.ts +85 -0
  197. package/src/routes/api/diagrams.ts +53 -0
  198. package/src/routes/api/directory.ts +123 -0
  199. package/src/routes/api/drives.ts +23 -0
  200. package/src/routes/api/export.ts +314 -0
  201. package/src/routes/api/fileContent.ts +414 -0
  202. package/src/routes/api/index.ts +37 -0
  203. package/src/routes/api/linkInfo.test.ts +132 -0
  204. package/src/routes/api/linkInfo.ts +83 -0
  205. package/src/routes/api/middleware.ts +156 -0
  206. package/src/routes/api/raw.ts +54 -0
  207. package/src/routes/api/runner.ts +107 -0
  208. package/src/routes/api/search.ts +321 -0
  209. package/src/routes/api/sharing.ts +259 -0
  210. package/src/routes/api/status.test.ts +72 -0
  211. package/src/routes/api/status.ts +82 -0
  212. package/src/routes/auth.ts +143 -0
  213. package/src/routes/event.test.ts +248 -0
  214. package/src/routes/event.ts +109 -0
  215. package/src/routes/health.ts +13 -0
  216. package/src/routes/keys.ts +192 -0
  217. package/src/routes/path/index.ts +24 -0
  218. package/src/routes/static.ts +54 -0
  219. package/src/server.ts +104 -0
  220. package/src/services/deepShareLinks.ts +203 -0
  221. package/src/services/diagramCache.ts +128 -0
  222. package/src/services/embeddedDiagrams.ts +168 -0
  223. package/src/services/eventLog.test.ts +144 -0
  224. package/src/services/eventLog.ts +68 -0
  225. package/src/services/eventQueue.test.ts +127 -0
  226. package/src/services/eventQueue.ts +196 -0
  227. package/src/services/export.ts +267 -0
  228. package/src/services/exportCache.ts +216 -0
  229. package/src/services/markdown.ts +189 -0
  230. package/src/services/mermaid.ts +113 -0
  231. package/src/services/plantuml.ts +172 -0
  232. package/src/services/puppeteer.ts +188 -0
  233. package/src/types/fastify.d.ts +13 -0
  234. package/src/types/jsonmap.d.ts +10 -0
  235. package/src/types/plantuml-encoder.d.ts +4 -0
  236. package/src/util/breadcrumbs.ts +33 -0
  237. package/src/util/crypto.test.ts +132 -0
  238. package/src/util/crypto.ts +79 -0
  239. package/src/util/fileDetection.test.ts +115 -0
  240. package/src/util/fileDetection.ts +70 -0
  241. package/src/util/formatters.test.ts +105 -0
  242. package/src/util/formatters.ts +44 -0
  243. package/src/util/packageVersion.ts +30 -0
  244. package/src/util/platform.ts +178 -0
  245. package/src/util/state.ts +55 -0
  246. package/test-docs/diagram-retry-test.md +18 -0
  247. package/test-docs/embedded-diagrams.md +52 -0
  248. package/test-docs/lazy-diagrams-test.md +333 -0
  249. package/test-docs/page-a.md +7 -0
  250. package/test-docs/page-b.md +7 -0
  251. package/test-docs/page-c.md +7 -0
  252. package/test-docs/sub/page-d.md +7 -0
  253. package/test-docs/test-diagram.puml +13 -0
  254. package/test-docs/validate-deep-share.js +318 -0
  255. package/tsconfig.json +37 -0
  256. package/tsdoc.json +13 -0
  257. package/vendor/.plantuml-version +1 -0
  258. package/vendor/plantuml.jar +0 -0
  259. package/vitest.config.js +12 -0
  260. package/vitest.config.ts +13 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Content-addressed diagram render cache.
3
+ *
4
+ * Cache key = sha256(type + '\\0' + source). Cached artifacts are SVG strings
5
+ * stored as files in a single directory. No timestamp comparison needed:
6
+ * if the source changes, the hash changes → automatic cache miss.
7
+ */
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ let cacheDir = null;
12
+ /**
13
+ * Initialize the cache directory. Call once at startup.
14
+ * Defaults to `.diagram-cache` in the current working directory.
15
+ */
16
+ export function initDiagramCache(dir) {
17
+ cacheDir = dir ?? path.resolve('.diagram-cache');
18
+ fs.mkdirSync(cacheDir, { recursive: true });
19
+ }
20
+ /**
21
+ * Get the cache directory path (for diagnostics / health endpoint).
22
+ */
23
+ export function getDiagramCacheDir() {
24
+ return cacheDir;
25
+ }
26
+ /**
27
+ * Compute the cache key for a diagram.
28
+ */
29
+ function cacheKey(type, source) {
30
+ return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
31
+ }
32
+ /**
33
+ * Look up a cached diagram. Returns the content as a string or null on miss.
34
+ * @param format - Output format extension (e.g. 'svg', 'png', 'pdf'). Defaults to 'svg'.
35
+ */
36
+ export function getCachedDiagram(type, source, format = 'svg') {
37
+ if (!cacheDir)
38
+ return null;
39
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
40
+ try {
41
+ return fs.readFileSync(file, 'utf8');
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Look up a cached diagram as a Buffer (for binary formats like PNG/PDF).
49
+ * @param format - Output format extension (e.g. 'png', 'pdf').
50
+ */
51
+ export function getCachedDiagramBuffer(type, source, format) {
52
+ if (!cacheDir)
53
+ return null;
54
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
55
+ try {
56
+ return fs.readFileSync(file);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * Store a rendered diagram in the cache (string content).
64
+ * @param format - Output format extension. Defaults to 'svg'.
65
+ */
66
+ export function cacheDiagram(type, source, content, format = 'svg') {
67
+ if (!cacheDir)
68
+ return;
69
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
70
+ try {
71
+ fs.writeFileSync(file, content, 'utf8');
72
+ }
73
+ catch (err) {
74
+ console.error('[diagramCache] write failed:', err.message);
75
+ }
76
+ }
77
+ /**
78
+ * Store a rendered diagram in the cache (binary content).
79
+ * @param format - Output format extension (e.g. 'png', 'pdf').
80
+ */
81
+ export function cacheDiagramBuffer(type, source, buffer, format) {
82
+ if (!cacheDir)
83
+ return;
84
+ const file = path.join(cacheDir, `${cacheKey(type, source)}.${format}`);
85
+ try {
86
+ fs.writeFileSync(file, buffer);
87
+ }
88
+ catch (err) {
89
+ console.error('[diagramCache] write failed:', err.message);
90
+ }
91
+ }
92
+ /**
93
+ * Get a cached diagram or render it and cache the result.
94
+ * Eliminates the repeated getCachedDiagram → render → cacheDiagram pattern.
95
+ */
96
+ export async function getOrRenderDiagram(type, source, renderFn, format = 'svg') {
97
+ const cached = getCachedDiagram(type, source, format);
98
+ if (cached)
99
+ return cached;
100
+ const result = await renderFn();
101
+ if (result)
102
+ cacheDiagram(type, source, result, format);
103
+ return result;
104
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Embedded diagram support for Mermaid and PlantUML code blocks in markdown.
3
+ *
4
+ * Strategy: parseMarkdown() calls registerDiagram() which stores the source
5
+ * and returns a placeholder div. The client lazily fetches rendered SVGs
6
+ * via GET /api/diagram/:type/:hash.svg, which renders on-demand and caches.
7
+ *
8
+ * For PDF/DOCX export (server-side rendering), renderEmbeddedDiagrams()
9
+ * replaces placeholders with inline SVGs synchronously.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import { getOrRenderDiagram } from './diagramCache.js';
13
+ import { renderMermaidFromSource } from './mermaid.js';
14
+ import { renderPlantUmlFromSource } from './plantuml.js';
15
+ /** Placeholder format used by the markdown renderer */
16
+ const PLACEHOLDER_RE = /<!--DIAGRAM:(mermaid|plantuml):([a-f0-9]{64})-->/g;
17
+ /**
18
+ * In-flight diagram sources, keyed by content hash.
19
+ * Entries are cleaned up after a TTL to prevent unbounded growth.
20
+ */
21
+ const diagramSources = new Map();
22
+ /** TTL for source map entries (10 minutes) */
23
+ const SOURCE_TTL_MS = 10 * 60 * 1000;
24
+ /** Periodic cleanup interval */
25
+ let cleanupInterval = null;
26
+ function startCleanup() {
27
+ if (cleanupInterval)
28
+ return;
29
+ cleanupInterval = setInterval(() => {
30
+ const now = Date.now();
31
+ for (const [hash, entry] of diagramSources) {
32
+ if (now - entry.createdAt > SOURCE_TTL_MS) {
33
+ diagramSources.delete(hash);
34
+ }
35
+ }
36
+ }, 60_000);
37
+ // Don't keep process alive just for cleanup
38
+ cleanupInterval.unref();
39
+ }
40
+ /**
41
+ * Compute content hash matching the cache key format.
42
+ */
43
+ export function diagramHash(type, source) {
44
+ return crypto.createHash('sha256').update(`${type}\0${source}`).digest('hex');
45
+ }
46
+ /** Module-level context directory for the current markdown parse. */
47
+ let currentContextDir;
48
+ /**
49
+ * Set the context directory for diagram registration.
50
+ * Call before parseMarkdown() so registered diagrams know their !include context.
51
+ */
52
+ export function setDiagramContext(contextDir) {
53
+ currentContextDir = contextDir;
54
+ }
55
+ /**
56
+ * Register a diagram source and return a placeholder HTML div.
57
+ * Called synchronously from the marked renderer.
58
+ */
59
+ export function registerDiagram(type, source) {
60
+ const hash = diagramHash(type, source);
61
+ diagramSources.set(hash, {
62
+ source,
63
+ contextDir: currentContextDir,
64
+ createdAt: Date.now(),
65
+ });
66
+ startCleanup();
67
+ // Emit a client-side placeholder that the LazyDiagram component will hydrate
68
+ return `<div class="embedded-diagram-lazy" data-diagram-type="${type}" data-diagram-hash="${hash}"><!--DIAGRAM:${type}:${hash}--></div>\n`;
69
+ }
70
+ /**
71
+ * Look up a registered diagram source by hash.
72
+ * Used by the /api/diagram endpoint for on-demand rendering.
73
+ */
74
+ export function getDiagramSource(hash) {
75
+ const entry = diagramSources.get(hash);
76
+ if (!entry)
77
+ return null;
78
+ return { type: '', source: entry.source, contextDir: entry.contextDir };
79
+ }
80
+ /**
81
+ * Render a diagram to SVG (with cache). Used by the /api/diagram endpoint.
82
+ */
83
+ export async function renderDiagramToSvg(type, source,
84
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
85
+ _contextDir) {
86
+ try {
87
+ return await getOrRenderDiagram(type, source, () => {
88
+ if (type === 'mermaid')
89
+ return renderMermaidFromSource(source);
90
+ if (type === 'plantuml')
91
+ return renderPlantUmlFromSource(source);
92
+ return null;
93
+ });
94
+ }
95
+ catch (err) {
96
+ console.error(`[embeddedDiagrams] ${type} render failed:`, err.message);
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Replace all diagram placeholders in rendered HTML with inline SVGs.
102
+ * Used for PDF/DOCX export where client-side lazy loading isn't available.
103
+ */
104
+ export async function renderEmbeddedDiagrams(html, contextDir) {
105
+ const matches = [...html.matchAll(PLACEHOLDER_RE)];
106
+ if (matches.length === 0)
107
+ return html;
108
+ let result = html;
109
+ for (const match of matches) {
110
+ const [placeholder, type, hash] = match;
111
+ const entry = diagramSources.get(hash);
112
+ if (!entry)
113
+ continue;
114
+ const source = entry.source;
115
+ if (!source)
116
+ continue;
117
+ const svg = await renderDiagramToSvg(type, source, contextDir ?? entry.contextDir);
118
+ if (svg) {
119
+ const wrapped = `<div class="embedded-diagram-rendered" data-type="${type}">${svg}</div>`;
120
+ result = result.replace(placeholder, wrapped);
121
+ }
122
+ else {
123
+ const escaped = escapeHtml(source);
124
+ const errorBlock = `<div class="embedded-diagram-error" data-type="${type}"><div class="diagram-error-label">${type} render failed</div><pre class="hljs"><code>${escaped}</code></pre></div>`;
125
+ result = result.replace(placeholder, errorBlock);
126
+ }
127
+ }
128
+ return result;
129
+ }
130
+ function escapeHtml(str) {
131
+ return str
132
+ .replace(/&/g, '&amp;')
133
+ .replace(/</g, '&lt;')
134
+ .replace(/>/g, '&gt;')
135
+ .replace(/"/g, '&quot;');
136
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Event logging service - logs all events (matched and unmatched)
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { getConfig } from '../config/index.js';
7
+ import { nowIso } from '../util/formatters.js';
8
+ /**
9
+ * Ensure directory exists
10
+ */
11
+ function ensureDir(dirPath) {
12
+ fs.mkdirSync(dirPath, { recursive: true });
13
+ }
14
+ /**
15
+ * Parse JSONL file into entries
16
+ */
17
+ function parseJsonl(filePath) {
18
+ if (!fs.existsSync(filePath))
19
+ return [];
20
+ const content = fs.readFileSync(filePath, 'utf8');
21
+ return content
22
+ .split('\n')
23
+ .filter((line) => line.trim())
24
+ .map((line) => JSON.parse(line));
25
+ }
26
+ /**
27
+ * Write entries to JSONL file
28
+ */
29
+ function writeJsonl(filePath, entries) {
30
+ const content = entries.map((entry) => JSON.stringify(entry)).join('\n');
31
+ fs.writeFileSync(filePath, content + '\n', 'utf8');
32
+ }
33
+ /**
34
+ * Purge entries older than configured retention period
35
+ */
36
+ function purgeOldEntries(entries) {
37
+ const { eventLogPurgeMs } = getConfig();
38
+ const cutoff = new Date(Date.now() - eventLogPurgeMs);
39
+ return entries.filter((entry) => new Date(entry.ts) >= cutoff);
40
+ }
41
+ /**
42
+ * Append an event to the log and purge old entries
43
+ */
44
+ export function logEvent(entry) {
45
+ const { eventLogPath } = getConfig();
46
+ ensureDir(path.dirname(eventLogPath));
47
+ // Read existing entries
48
+ const entries = parseJsonl(eventLogPath);
49
+ // Add new entry with timestamp
50
+ entries.push({ ts: nowIso(), ...entry });
51
+ // Purge old entries
52
+ const purgedEntries = purgeOldEntries(entries);
53
+ // Write back
54
+ writeJsonl(eventLogPath, purgedEntries);
55
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Event log service tests
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { nowIso } from '../util/formatters.js';
8
+ // Test helper functions (isolated from config system)
9
+ function parseJsonl(filePath) {
10
+ if (!fs.existsSync(filePath))
11
+ return [];
12
+ const content = fs.readFileSync(filePath, 'utf8');
13
+ return content
14
+ .split('\n')
15
+ .filter((line) => line.trim())
16
+ .map((line) => JSON.parse(line));
17
+ }
18
+ function writeJsonl(filePath, entries) {
19
+ const content = entries.map((entry) => JSON.stringify(entry)).join('\n');
20
+ fs.writeFileSync(filePath, content + '\n', 'utf8');
21
+ }
22
+ function purgeOldEntries(entries, retentionMs) {
23
+ const cutoff = new Date(Date.now() - retentionMs);
24
+ return entries.filter((entry) => new Date(entry.ts) >= cutoff);
25
+ }
26
+ describe('eventLog', () => {
27
+ it('should parse JSONL entries correctly', () => {
28
+ const entry1 = {
29
+ ts: nowIso(),
30
+ event: 'test-event',
31
+ matched: true,
32
+ exitCode: 0,
33
+ durationMs: 123,
34
+ };
35
+ const entry2 = {
36
+ ts: nowIso(),
37
+ event: null,
38
+ matched: false,
39
+ bodyPreview: '{"type":"unknown"}',
40
+ };
41
+ const tempFile = path.join(process.cwd(), 'test-temp-log.jsonl');
42
+ writeJsonl(tempFile, [entry1, entry2]);
43
+ const parsed = parseJsonl(tempFile);
44
+ expect(parsed).toHaveLength(2);
45
+ expect(parsed[0].event).toBe('test-event');
46
+ expect(parsed[0].matched).toBe(true);
47
+ expect(parsed[1].event).toBeNull();
48
+ expect(parsed[1].matched).toBe(false);
49
+ // Cleanup
50
+ fs.unlinkSync(tempFile);
51
+ });
52
+ it('should purge entries older than retention period', () => {
53
+ const now = Date.now();
54
+ const retentionMs = 86400000; // 1 day
55
+ const oldEntry = {
56
+ ts: new Date(now - 2 * retentionMs).toISOString(),
57
+ event: 'old-event',
58
+ matched: true,
59
+ exitCode: 0,
60
+ durationMs: 100,
61
+ };
62
+ const recentEntry = {
63
+ ts: new Date(now - 0.5 * retentionMs).toISOString(),
64
+ event: 'recent-event',
65
+ matched: true,
66
+ exitCode: 0,
67
+ durationMs: 200,
68
+ };
69
+ const currentEntry = {
70
+ ts: new Date(now).toISOString(),
71
+ event: 'current-event',
72
+ matched: true,
73
+ exitCode: 0,
74
+ durationMs: 300,
75
+ };
76
+ const purged = purgeOldEntries([oldEntry, recentEntry, currentEntry], retentionMs);
77
+ expect(purged).toHaveLength(2);
78
+ expect(purged[0].event).toBe('recent-event');
79
+ expect(purged[1].event).toBe('current-event');
80
+ });
81
+ it('should write JSONL with proper newline formatting', () => {
82
+ const entries = [
83
+ {
84
+ ts: nowIso(),
85
+ event: 'event1',
86
+ matched: true,
87
+ exitCode: 0,
88
+ durationMs: 100,
89
+ },
90
+ {
91
+ ts: nowIso(),
92
+ event: 'event2',
93
+ matched: true,
94
+ exitCode: 1,
95
+ durationMs: 200,
96
+ },
97
+ ];
98
+ const tempFile = path.join(process.cwd(), 'test-temp-write.jsonl');
99
+ writeJsonl(tempFile, entries);
100
+ const content = fs.readFileSync(tempFile, 'utf8');
101
+ const lines = content.split('\n').filter((line) => line.trim());
102
+ expect(lines).toHaveLength(2);
103
+ expect(JSON.parse(lines[0]).event).toBe('event1');
104
+ expect(JSON.parse(lines[1]).event).toBe('event2');
105
+ // Cleanup
106
+ fs.unlinkSync(tempFile);
107
+ });
108
+ it('should handle empty log file', () => {
109
+ const tempFile = path.join(process.cwd(), 'test-temp-empty.jsonl');
110
+ const parsed = parseJsonl(tempFile);
111
+ expect(parsed).toHaveLength(0);
112
+ });
113
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Durable event queue for webhook event processing
3
+ */
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { getConfig } from '../config/index.js';
8
+ import { nowIso } from '../util/formatters.js';
9
+ import { logEvent } from './eventLog.js';
10
+ /**
11
+ * Ensure directory exists
12
+ */
13
+ function ensureDir(dirPath) {
14
+ fs.mkdirSync(dirPath, { recursive: true });
15
+ }
16
+ /**
17
+ * Append a JSON object as a line to the general event log (backward compatibility)
18
+ * This is separate from the Event Gateway event log
19
+ */
20
+ export function appendEvent(event) {
21
+ const { eventsLog } = getConfig();
22
+ ensureDir(path.dirname(eventsLog));
23
+ const line = JSON.stringify({ at: nowIso(), ...event }) + '\n';
24
+ fs.appendFileSync(eventsLog, line, 'utf8');
25
+ }
26
+ /**
27
+ * Append a queue entry to the durable queue
28
+ */
29
+ export function enqueue(event, cmd, body, timeoutMs) {
30
+ const { eventQueuePath } = getConfig();
31
+ ensureDir(path.dirname(eventQueuePath));
32
+ const entry = {
33
+ ts: nowIso(),
34
+ event,
35
+ cmd,
36
+ body,
37
+ timeoutMs,
38
+ };
39
+ const line = JSON.stringify(entry) + '\n';
40
+ fs.appendFileSync(eventQueuePath, line, 'utf8');
41
+ }
42
+ /**
43
+ * Read cursor position (byte offset of last processed entry)
44
+ */
45
+ function readCursor() {
46
+ const { eventQueueCursorPath } = getConfig();
47
+ if (!fs.existsSync(eventQueueCursorPath))
48
+ return 0;
49
+ const content = fs.readFileSync(eventQueueCursorPath, 'utf8').trim();
50
+ return parseInt(content, 10) || 0;
51
+ }
52
+ /**
53
+ * Write cursor position
54
+ */
55
+ function writeCursor(position) {
56
+ const { eventQueueCursorPath } = getConfig();
57
+ ensureDir(path.dirname(eventQueueCursorPath));
58
+ fs.writeFileSync(eventQueueCursorPath, position.toString(), 'utf8');
59
+ }
60
+ /**
61
+ * Parse JSONL entries from file starting at cursor position
62
+ */
63
+ function readEntriesFromCursor() {
64
+ const { eventQueuePath } = getConfig();
65
+ if (!fs.existsSync(eventQueuePath)) {
66
+ return { entries: [], newPosition: 0 };
67
+ }
68
+ const cursor = readCursor();
69
+ const buf = fs.readFileSync(eventQueuePath);
70
+ // Read from cursor position (byte-based to match writeCursor)
71
+ const remaining = buf.subarray(cursor).toString('utf8');
72
+ const lines = remaining
73
+ .split('\n')
74
+ .filter((line) => line.trim())
75
+ .map((line) => JSON.parse(line));
76
+ const newPosition = buf.length;
77
+ return { entries: lines, newPosition };
78
+ }
79
+ /**
80
+ * Execute a queue entry (spawn command with body piped to stdin)
81
+ */
82
+ async function executeEntry(entry) {
83
+ const startTime = Date.now();
84
+ return new Promise((resolve) => {
85
+ // Parse command and args
86
+ const parts = entry.cmd.split(/\s+/);
87
+ const command = parts[0];
88
+ const args = parts.slice(1);
89
+ const proc = spawn(command, args, {
90
+ stdio: ['pipe', 'inherit', 'inherit'],
91
+ shell: true,
92
+ });
93
+ // Pipe body as JSON to stdin
94
+ const bodyJson = JSON.stringify(entry.body);
95
+ proc.stdin.write(bodyJson);
96
+ proc.stdin.end();
97
+ // Setup timeout
98
+ const timeout = setTimeout(() => {
99
+ proc.kill('SIGTERM');
100
+ }, entry.timeoutMs);
101
+ proc.on('exit', (code) => {
102
+ clearTimeout(timeout);
103
+ const exitCode = code ?? -1;
104
+ const durationMs = Date.now() - startTime;
105
+ resolve({ exitCode, durationMs });
106
+ });
107
+ proc.on('error', () => {
108
+ clearTimeout(timeout);
109
+ const durationMs = Date.now() - startTime;
110
+ resolve({ exitCode: -1, durationMs });
111
+ });
112
+ });
113
+ }
114
+ /**
115
+ * Process one batch of queue entries
116
+ */
117
+ async function processBatch() {
118
+ const { entries, newPosition } = readEntriesFromCursor();
119
+ for (const entry of entries) {
120
+ try {
121
+ const { exitCode, durationMs } = await executeEntry(entry);
122
+ // Log to event log
123
+ logEvent({
124
+ event: entry.event,
125
+ matched: true,
126
+ exitCode,
127
+ durationMs,
128
+ });
129
+ }
130
+ catch {
131
+ // Log error but continue (errors are ignored per spec)
132
+ logEvent({
133
+ event: entry.event,
134
+ matched: true,
135
+ exitCode: -1,
136
+ durationMs: 0,
137
+ });
138
+ }
139
+ }
140
+ // Update cursor
141
+ if (entries.length > 0) {
142
+ writeCursor(newPosition);
143
+ }
144
+ }
145
+ /**
146
+ * Start the queue drain loop
147
+ */
148
+ export function startQueueProcessor() {
149
+ setInterval(() => {
150
+ void processBatch();
151
+ }, 5000);
152
+ // Process immediately on start
153
+ void processBatch();
154
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Event queue service tests
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { nowIso } from '../util/formatters.js';
8
+ describe('eventQueue', () => {
9
+ it('should format queue entries correctly', () => {
10
+ const entry = {
11
+ ts: nowIso(),
12
+ event: 'test-event',
13
+ cmd: 'node test.js',
14
+ body: { foo: 'bar', baz: 123 },
15
+ timeoutMs: 30000,
16
+ };
17
+ const json = JSON.stringify(entry);
18
+ const parsed = JSON.parse(json);
19
+ expect(parsed.event).toBe('test-event');
20
+ expect(parsed.cmd).toBe('node test.js');
21
+ expect(parsed.body).toEqual({ foo: 'bar', baz: 123 });
22
+ expect(parsed.timeoutMs).toBe(30000);
23
+ expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
24
+ });
25
+ it('should write entries as JSONL', () => {
26
+ const entry1 = {
27
+ ts: nowIso(),
28
+ event: 'event1',
29
+ cmd: 'cmd1',
30
+ body: { a: 1 },
31
+ timeoutMs: 10000,
32
+ };
33
+ const entry2 = {
34
+ ts: nowIso(),
35
+ event: 'event2',
36
+ cmd: 'cmd2',
37
+ body: { b: 2 },
38
+ timeoutMs: 20000,
39
+ };
40
+ const tempFile = path.join(process.cwd(), 'test-queue.jsonl');
41
+ const lines = [entry1, entry2].map((e) => JSON.stringify(e)).join('\n');
42
+ fs.writeFileSync(tempFile, lines + '\n', 'utf8');
43
+ const content = fs.readFileSync(tempFile, 'utf8');
44
+ const parsed = content
45
+ .split('\n')
46
+ .filter((line) => line.trim())
47
+ .map((line) => JSON.parse(line));
48
+ expect(parsed).toHaveLength(2);
49
+ expect(parsed[0].event).toBe('event1');
50
+ expect(parsed[1].event).toBe('event2');
51
+ // Cleanup
52
+ fs.unlinkSync(tempFile);
53
+ });
54
+ it('should handle cursor-based reading', () => {
55
+ const entry1 = JSON.stringify({
56
+ ts: nowIso(),
57
+ event: 'e1',
58
+ cmd: 'c1',
59
+ body: {},
60
+ timeoutMs: 1000,
61
+ });
62
+ const entry2 = JSON.stringify({
63
+ ts: nowIso(),
64
+ event: 'e2',
65
+ cmd: 'c2',
66
+ body: {},
67
+ timeoutMs: 2000,
68
+ });
69
+ const tempFile = path.join(process.cwd(), 'test-cursor-queue.jsonl');
70
+ fs.writeFileSync(tempFile, entry1 + '\n' + entry2 + '\n', 'utf8');
71
+ const fullContent = fs.readFileSync(tempFile, 'utf8');
72
+ const cursorPosition = Buffer.byteLength(entry1 + '\n', 'utf8');
73
+ // Read from cursor
74
+ const remaining = fullContent.slice(cursorPosition);
75
+ const lines = remaining
76
+ .split('\n')
77
+ .filter((line) => line.trim())
78
+ .map((line) => JSON.parse(line));
79
+ expect(lines).toHaveLength(1);
80
+ expect(lines[0].event).toBe('e2');
81
+ // Cleanup
82
+ fs.unlinkSync(tempFile);
83
+ });
84
+ it('should preserve body structure through JSON serialization', () => {
85
+ const complexBody = {
86
+ string: 'test',
87
+ number: 123,
88
+ boolean: true,
89
+ null: null,
90
+ array: [1, 2, 3],
91
+ nested: { a: { b: { c: 'deep' } } },
92
+ };
93
+ const entry = {
94
+ ts: nowIso(),
95
+ event: 'complex',
96
+ cmd: 'node test.js',
97
+ body: complexBody,
98
+ timeoutMs: 5000,
99
+ };
100
+ const json = JSON.stringify(entry);
101
+ const parsed = JSON.parse(json);
102
+ expect(parsed.body).toEqual(complexBody);
103
+ });
104
+ });