@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,144 @@
1
+ /**
2
+ * Event log service tests
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ import type { EventLogEntry } from '../config/types.js';
11
+ import { nowIso } from '../util/formatters.js';
12
+
13
+ // Test helper functions (isolated from config system)
14
+ function parseJsonl(filePath: string): EventLogEntry[] {
15
+ if (!fs.existsSync(filePath)) return [];
16
+
17
+ const content = fs.readFileSync(filePath, 'utf8');
18
+ return content
19
+ .split('\n')
20
+ .filter((line) => line.trim())
21
+ .map((line) => JSON.parse(line) as EventLogEntry);
22
+ }
23
+
24
+ function writeJsonl(filePath: string, entries: EventLogEntry[]): void {
25
+ const content = entries.map((entry) => JSON.stringify(entry)).join('\n');
26
+ fs.writeFileSync(filePath, content + '\n', 'utf8');
27
+ }
28
+
29
+ function purgeOldEntries(
30
+ entries: EventLogEntry[],
31
+ retentionMs: number,
32
+ ): EventLogEntry[] {
33
+ const cutoff = new Date(Date.now() - retentionMs);
34
+ return entries.filter((entry) => new Date(entry.ts) >= cutoff);
35
+ }
36
+
37
+ describe('eventLog', () => {
38
+ it('should parse JSONL entries correctly', () => {
39
+ const entry1: EventLogEntry = {
40
+ ts: nowIso(),
41
+ event: 'test-event',
42
+ matched: true,
43
+ exitCode: 0,
44
+ durationMs: 123,
45
+ };
46
+ const entry2: EventLogEntry = {
47
+ ts: nowIso(),
48
+ event: null,
49
+ matched: false,
50
+ bodyPreview: '{"type":"unknown"}',
51
+ };
52
+
53
+ const tempFile = path.join(process.cwd(), 'test-temp-log.jsonl');
54
+ writeJsonl(tempFile, [entry1, entry2]);
55
+
56
+ const parsed = parseJsonl(tempFile);
57
+
58
+ expect(parsed).toHaveLength(2);
59
+ expect(parsed[0].event).toBe('test-event');
60
+ expect(parsed[0].matched).toBe(true);
61
+ expect(parsed[1].event).toBeNull();
62
+ expect(parsed[1].matched).toBe(false);
63
+
64
+ // Cleanup
65
+ fs.unlinkSync(tempFile);
66
+ });
67
+
68
+ it('should purge entries older than retention period', () => {
69
+ const now = Date.now();
70
+ const retentionMs = 86400000; // 1 day
71
+
72
+ const oldEntry: EventLogEntry = {
73
+ ts: new Date(now - 2 * retentionMs).toISOString(),
74
+ event: 'old-event',
75
+ matched: true,
76
+ exitCode: 0,
77
+ durationMs: 100,
78
+ };
79
+
80
+ const recentEntry: EventLogEntry = {
81
+ ts: new Date(now - 0.5 * retentionMs).toISOString(),
82
+ event: 'recent-event',
83
+ matched: true,
84
+ exitCode: 0,
85
+ durationMs: 200,
86
+ };
87
+
88
+ const currentEntry: EventLogEntry = {
89
+ ts: new Date(now).toISOString(),
90
+ event: 'current-event',
91
+ matched: true,
92
+ exitCode: 0,
93
+ durationMs: 300,
94
+ };
95
+
96
+ const purged = purgeOldEntries(
97
+ [oldEntry, recentEntry, currentEntry],
98
+ retentionMs,
99
+ );
100
+
101
+ expect(purged).toHaveLength(2);
102
+ expect(purged[0].event).toBe('recent-event');
103
+ expect(purged[1].event).toBe('current-event');
104
+ });
105
+
106
+ it('should write JSONL with proper newline formatting', () => {
107
+ const entries: EventLogEntry[] = [
108
+ {
109
+ ts: nowIso(),
110
+ event: 'event1',
111
+ matched: true,
112
+ exitCode: 0,
113
+ durationMs: 100,
114
+ },
115
+ {
116
+ ts: nowIso(),
117
+ event: 'event2',
118
+ matched: true,
119
+ exitCode: 1,
120
+ durationMs: 200,
121
+ },
122
+ ];
123
+
124
+ const tempFile = path.join(process.cwd(), 'test-temp-write.jsonl');
125
+ writeJsonl(tempFile, entries);
126
+
127
+ const content = fs.readFileSync(tempFile, 'utf8');
128
+ const lines = content.split('\n').filter((line) => line.trim());
129
+
130
+ expect(lines).toHaveLength(2);
131
+ expect((JSON.parse(lines[0]) as EventLogEntry).event).toBe('event1');
132
+ expect((JSON.parse(lines[1]) as EventLogEntry).event).toBe('event2');
133
+
134
+ // Cleanup
135
+ fs.unlinkSync(tempFile);
136
+ });
137
+
138
+ it('should handle empty log file', () => {
139
+ const tempFile = path.join(process.cwd(), 'test-temp-empty.jsonl');
140
+ const parsed = parseJsonl(tempFile);
141
+
142
+ expect(parsed).toHaveLength(0);
143
+ });
144
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Event logging service - logs all events (matched and unmatched)
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { getConfig } from '../config/index.js';
9
+ import type { EventLogEntry } from '../config/types.js';
10
+ import { nowIso } from '../util/formatters.js';
11
+
12
+ /**
13
+ * Ensure directory exists
14
+ */
15
+ function ensureDir(dirPath: string): void {
16
+ fs.mkdirSync(dirPath, { recursive: true });
17
+ }
18
+
19
+ /**
20
+ * Parse JSONL file into entries
21
+ */
22
+ function parseJsonl(filePath: string): EventLogEntry[] {
23
+ if (!fs.existsSync(filePath)) return [];
24
+
25
+ const content = fs.readFileSync(filePath, 'utf8');
26
+ return content
27
+ .split('\n')
28
+ .filter((line) => line.trim())
29
+ .map((line) => JSON.parse(line) as EventLogEntry);
30
+ }
31
+
32
+ /**
33
+ * Write entries to JSONL file
34
+ */
35
+ function writeJsonl(filePath: string, entries: EventLogEntry[]): void {
36
+ const content = entries.map((entry) => JSON.stringify(entry)).join('\n');
37
+ fs.writeFileSync(filePath, content + '\n', 'utf8');
38
+ }
39
+
40
+ /**
41
+ * Purge entries older than configured retention period
42
+ */
43
+ function purgeOldEntries(entries: EventLogEntry[]): EventLogEntry[] {
44
+ const { eventLogPurgeMs } = getConfig();
45
+ const cutoff = new Date(Date.now() - eventLogPurgeMs);
46
+
47
+ return entries.filter((entry) => new Date(entry.ts) >= cutoff);
48
+ }
49
+
50
+ /**
51
+ * Append an event to the log and purge old entries
52
+ */
53
+ export function logEvent(entry: Omit<EventLogEntry, 'ts'>): void {
54
+ const { eventLogPath } = getConfig();
55
+ ensureDir(path.dirname(eventLogPath));
56
+
57
+ // Read existing entries
58
+ const entries = parseJsonl(eventLogPath);
59
+
60
+ // Add new entry with timestamp
61
+ entries.push({ ts: nowIso(), ...entry });
62
+
63
+ // Purge old entries
64
+ const purgedEntries = purgeOldEntries(entries);
65
+
66
+ // Write back
67
+ writeJsonl(eventLogPath, purgedEntries);
68
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Event queue service tests
3
+ */
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ import type { QueueEntry } from '../config/types.js';
11
+ import { nowIso } from '../util/formatters.js';
12
+
13
+ describe('eventQueue', () => {
14
+ it('should format queue entries correctly', () => {
15
+ const entry: QueueEntry = {
16
+ ts: nowIso(),
17
+ event: 'test-event',
18
+ cmd: 'node test.js',
19
+ body: { foo: 'bar', baz: 123 },
20
+ timeoutMs: 30000,
21
+ };
22
+
23
+ const json = JSON.stringify(entry);
24
+ const parsed = JSON.parse(json) as QueueEntry;
25
+
26
+ expect(parsed.event).toBe('test-event');
27
+ expect(parsed.cmd).toBe('node test.js');
28
+ expect(parsed.body).toEqual({ foo: 'bar', baz: 123 });
29
+ expect(parsed.timeoutMs).toBe(30000);
30
+ expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
31
+ });
32
+
33
+ it('should write entries as JSONL', () => {
34
+ const entry1: QueueEntry = {
35
+ ts: nowIso(),
36
+ event: 'event1',
37
+ cmd: 'cmd1',
38
+ body: { a: 1 },
39
+ timeoutMs: 10000,
40
+ };
41
+
42
+ const entry2: QueueEntry = {
43
+ ts: nowIso(),
44
+ event: 'event2',
45
+ cmd: 'cmd2',
46
+ body: { b: 2 },
47
+ timeoutMs: 20000,
48
+ };
49
+
50
+ const tempFile = path.join(process.cwd(), 'test-queue.jsonl');
51
+ const lines = [entry1, entry2].map((e) => JSON.stringify(e)).join('\n');
52
+ fs.writeFileSync(tempFile, lines + '\n', 'utf8');
53
+
54
+ const content = fs.readFileSync(tempFile, 'utf8');
55
+ const parsed = content
56
+ .split('\n')
57
+ .filter((line) => line.trim())
58
+ .map((line) => JSON.parse(line) as QueueEntry);
59
+
60
+ expect(parsed).toHaveLength(2);
61
+ expect(parsed[0].event).toBe('event1');
62
+ expect(parsed[1].event).toBe('event2');
63
+
64
+ // Cleanup
65
+ fs.unlinkSync(tempFile);
66
+ });
67
+
68
+ it('should handle cursor-based reading', () => {
69
+ const entry1 = JSON.stringify({
70
+ ts: nowIso(),
71
+ event: 'e1',
72
+ cmd: 'c1',
73
+ body: {},
74
+ timeoutMs: 1000,
75
+ });
76
+ const entry2 = JSON.stringify({
77
+ ts: nowIso(),
78
+ event: 'e2',
79
+ cmd: 'c2',
80
+ body: {},
81
+ timeoutMs: 2000,
82
+ });
83
+
84
+ const tempFile = path.join(process.cwd(), 'test-cursor-queue.jsonl');
85
+ fs.writeFileSync(tempFile, entry1 + '\n' + entry2 + '\n', 'utf8');
86
+
87
+ const fullContent = fs.readFileSync(tempFile, 'utf8');
88
+ const cursorPosition = Buffer.byteLength(entry1 + '\n', 'utf8');
89
+
90
+ // Read from cursor
91
+ const remaining = fullContent.slice(cursorPosition);
92
+ const lines = remaining
93
+ .split('\n')
94
+ .filter((line) => line.trim())
95
+ .map((line) => JSON.parse(line) as QueueEntry);
96
+
97
+ expect(lines).toHaveLength(1);
98
+ expect(lines[0].event).toBe('e2');
99
+
100
+ // Cleanup
101
+ fs.unlinkSync(tempFile);
102
+ });
103
+
104
+ it('should preserve body structure through JSON serialization', () => {
105
+ const complexBody = {
106
+ string: 'test',
107
+ number: 123,
108
+ boolean: true,
109
+ null: null,
110
+ array: [1, 2, 3],
111
+ nested: { a: { b: { c: 'deep' } } },
112
+ };
113
+
114
+ const entry: QueueEntry = {
115
+ ts: nowIso(),
116
+ event: 'complex',
117
+ cmd: 'node test.js',
118
+ body: complexBody,
119
+ timeoutMs: 5000,
120
+ };
121
+
122
+ const json = JSON.stringify(entry);
123
+ const parsed = JSON.parse(json) as QueueEntry;
124
+
125
+ expect(parsed.body).toEqual(complexBody);
126
+ });
127
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Durable event queue for webhook event processing
3
+ */
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ import { getConfig } from '../config/index.js';
10
+ import type { QueueEntry } from '../config/types.js';
11
+ import { nowIso } from '../util/formatters.js';
12
+ import { logEvent } from './eventLog.js';
13
+
14
+ /**
15
+ * Ensure directory exists
16
+ */
17
+ function ensureDir(dirPath: string): void {
18
+ fs.mkdirSync(dirPath, { recursive: true });
19
+ }
20
+
21
+ /**
22
+ * Append a JSON object as a line to the general event log (backward compatibility)
23
+ * This is separate from the Event Gateway event log
24
+ */
25
+ export function appendEvent(event: Record<string, unknown>): void {
26
+ const { eventsLog } = getConfig();
27
+ ensureDir(path.dirname(eventsLog));
28
+ const line = JSON.stringify({ at: nowIso(), ...event }) + '\n';
29
+ fs.appendFileSync(eventsLog, line, 'utf8');
30
+ }
31
+
32
+ /**
33
+ * Append a queue entry to the durable queue
34
+ */
35
+ export function enqueue(
36
+ event: string,
37
+ cmd: string,
38
+ body: Record<string, unknown>,
39
+ timeoutMs: number,
40
+ ): void {
41
+ const { eventQueuePath } = getConfig();
42
+ ensureDir(path.dirname(eventQueuePath));
43
+
44
+ const entry: QueueEntry = {
45
+ ts: nowIso(),
46
+ event,
47
+ cmd,
48
+ body,
49
+ timeoutMs,
50
+ };
51
+
52
+ const line = JSON.stringify(entry) + '\n';
53
+ fs.appendFileSync(eventQueuePath, line, 'utf8');
54
+ }
55
+
56
+ /**
57
+ * Read cursor position (byte offset of last processed entry)
58
+ */
59
+ function readCursor(): number {
60
+ const { eventQueueCursorPath } = getConfig();
61
+ if (!fs.existsSync(eventQueueCursorPath)) return 0;
62
+
63
+ const content = fs.readFileSync(eventQueueCursorPath, 'utf8').trim();
64
+ return parseInt(content, 10) || 0;
65
+ }
66
+
67
+ /**
68
+ * Write cursor position
69
+ */
70
+ function writeCursor(position: number): void {
71
+ const { eventQueueCursorPath } = getConfig();
72
+ ensureDir(path.dirname(eventQueueCursorPath));
73
+ fs.writeFileSync(eventQueueCursorPath, position.toString(), 'utf8');
74
+ }
75
+
76
+ /**
77
+ * Parse JSONL entries from file starting at cursor position
78
+ */
79
+ function readEntriesFromCursor(): {
80
+ entries: QueueEntry[];
81
+ newPosition: number;
82
+ } {
83
+ const { eventQueuePath } = getConfig();
84
+
85
+ if (!fs.existsSync(eventQueuePath)) {
86
+ return { entries: [], newPosition: 0 };
87
+ }
88
+
89
+ const cursor = readCursor();
90
+ const buf = fs.readFileSync(eventQueuePath);
91
+
92
+ // Read from cursor position (byte-based to match writeCursor)
93
+ const remaining = buf.subarray(cursor).toString('utf8');
94
+ const lines = remaining
95
+ .split('\n')
96
+ .filter((line) => line.trim())
97
+ .map((line) => JSON.parse(line) as QueueEntry);
98
+
99
+ const newPosition = buf.length;
100
+
101
+ return { entries: lines, newPosition };
102
+ }
103
+
104
+ /**
105
+ * Execute a queue entry (spawn command with body piped to stdin)
106
+ */
107
+ async function executeEntry(entry: QueueEntry): Promise<{
108
+ exitCode: number;
109
+ durationMs: number;
110
+ }> {
111
+ const startTime = Date.now();
112
+
113
+ return new Promise((resolve) => {
114
+ // Parse command and args
115
+ const parts = entry.cmd.split(/\s+/);
116
+ const command = parts[0];
117
+ const args = parts.slice(1);
118
+
119
+ const proc = spawn(command, args, {
120
+ stdio: ['pipe', 'inherit', 'inherit'],
121
+ shell: true,
122
+ });
123
+
124
+ // Pipe body as JSON to stdin
125
+ const bodyJson = JSON.stringify(entry.body);
126
+ proc.stdin.write(bodyJson);
127
+ proc.stdin.end();
128
+
129
+ // Setup timeout
130
+ const timeout = setTimeout(() => {
131
+ proc.kill('SIGTERM');
132
+ }, entry.timeoutMs);
133
+
134
+ proc.on('exit', (code) => {
135
+ clearTimeout(timeout);
136
+ const exitCode = code ?? -1;
137
+ const durationMs = Date.now() - startTime;
138
+ resolve({ exitCode, durationMs });
139
+ });
140
+
141
+ proc.on('error', () => {
142
+ clearTimeout(timeout);
143
+ const durationMs = Date.now() - startTime;
144
+ resolve({ exitCode: -1, durationMs });
145
+ });
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Process one batch of queue entries
151
+ */
152
+ async function processBatch(): Promise<void> {
153
+ const { entries, newPosition } = readEntriesFromCursor();
154
+
155
+ for (const entry of entries) {
156
+ try {
157
+ const { exitCode, durationMs } = await executeEntry(entry);
158
+
159
+ // Log to event log
160
+ logEvent({
161
+ event: entry.event,
162
+ matched: true,
163
+ exitCode,
164
+ durationMs,
165
+ });
166
+ } catch {
167
+ // Log error but continue (errors are ignored per spec)
168
+ logEvent({
169
+ event: entry.event,
170
+ matched: true,
171
+ exitCode: -1,
172
+ durationMs: 0,
173
+ });
174
+ }
175
+ }
176
+
177
+ // Update cursor
178
+ if (entries.length > 0) {
179
+ writeCursor(newPosition);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Start the queue drain loop
185
+ */
186
+ export function startQueueProcessor(): void {
187
+ setInterval(
188
+ () => {
189
+ void processBatch();
190
+ },
191
+ 5000, // Check every 5 seconds
192
+ );
193
+
194
+ // Process immediately on start
195
+ void processBatch();
196
+ }