clawport-ui 0.1.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 (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,502 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ const { mockExecSync } = vi.hoisted(() => ({
5
+ mockExecSync: vi.fn(),
6
+ }))
7
+
8
+ // Mock child_process (Dependency Inversion -- no real CLI calls)
9
+ vi.mock('child_process', () => ({
10
+ execSync: mockExecSync,
11
+ default: { execSync: mockExecSync },
12
+ }))
13
+
14
+ import { getCrons } from './crons'
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks()
18
+ vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
19
+ })
20
+
21
+ // --- Well-formed data ---
22
+
23
+ describe('getCrons - well-formed data', () => {
24
+ it('parses a flat array response', async () => {
25
+ const mockData = [
26
+ {
27
+ id: 'cron-1',
28
+ name: 'pulse-trending',
29
+ schedule: '0 8 * * *',
30
+ status: 'success',
31
+ state: {
32
+ nextRunAtMs: 1700000000000,
33
+ lastRunAtMs: 1699900000000,
34
+ },
35
+ },
36
+ ]
37
+ mockExecSync.mockReturnValue(JSON.stringify(mockData))
38
+
39
+ const crons = await getCrons()
40
+ expect(crons).toHaveLength(1)
41
+ expect(crons[0].id).toBe('cron-1')
42
+ expect(crons[0].name).toBe('pulse-trending')
43
+ expect(crons[0].schedule).toBe('0 8 * * *')
44
+ expect(crons[0].status).toBe('ok')
45
+ expect(crons[0].agentId).toBe('pulse')
46
+ expect(crons[0].nextRun).toBeTruthy()
47
+ expect(crons[0].lastRun).toBeTruthy()
48
+ expect(crons[0].lastError).toBeNull()
49
+ })
50
+
51
+ it('parses a { jobs: [...] } wrapper', async () => {
52
+ const mockData = {
53
+ jobs: [
54
+ {
55
+ id: 'cron-2',
56
+ name: 'seo-team-weekly',
57
+ schedule: '0 9 * * 1',
58
+ state: { status: 'ok' },
59
+ },
60
+ ],
61
+ }
62
+ mockExecSync.mockReturnValue(JSON.stringify(mockData))
63
+
64
+ const crons = await getCrons()
65
+ expect(crons).toHaveLength(1)
66
+ expect(crons[0].name).toBe('seo-team-weekly')
67
+ expect(crons[0].agentId).toBe('lumen')
68
+ })
69
+
70
+ it('parses a { data: [...] } wrapper', async () => {
71
+ const mockData = {
72
+ data: [
73
+ {
74
+ id: 'cron-3',
75
+ name: 'echo-reddit-scan',
76
+ schedule: '0 6 * * 0',
77
+ state: { status: 'completed' },
78
+ },
79
+ ],
80
+ }
81
+ mockExecSync.mockReturnValue(JSON.stringify(mockData))
82
+
83
+ const crons = await getCrons()
84
+ expect(crons).toHaveLength(1)
85
+ expect(crons[0].status).toBe('ok')
86
+ expect(crons[0].agentId).toBe('echo')
87
+ })
88
+
89
+ it('maps multiple crons to correct agents', async () => {
90
+ const mockData = [
91
+ { id: '1', name: 'pulse-daily', schedule: '0 8 * * *', state: {} },
92
+ { id: '2', name: 'herald-linkedin', schedule: '0 10 * * 1-5', state: {} },
93
+ { id: '3', name: 'kaze-flights', schedule: '0 7 * * *', state: {} },
94
+ { id: '4', name: 'spark-discover', schedule: '0 12 */2 * *', state: {} },
95
+ { id: '5', name: 'scribe-compress', schedule: '0 0 * * 0', state: {} },
96
+ { id: '6', name: 'robin-recon', schedule: '0 6 * * 1', state: {} },
97
+ { id: '7', name: 'vault-backup', schedule: '0 3 * * *', state: {} },
98
+ { id: '8', name: 'maven-calendar', schedule: '0 9 * * 1', state: {} },
99
+ { id: '9', name: 'team-memory-sync', schedule: '0 23 * * *', state: {} },
100
+ { id: '10', name: 'mochi-feed', schedule: '0 11 * * *', state: {} },
101
+ ]
102
+ mockExecSync.mockReturnValue(JSON.stringify(mockData))
103
+
104
+ const crons = await getCrons()
105
+ expect(crons).toHaveLength(10)
106
+
107
+ const agentMap: Record<string, string | null> = {}
108
+ for (const c of crons) agentMap[c.name] = c.agentId
109
+
110
+ expect(agentMap['pulse-daily']).toBe('pulse')
111
+ expect(agentMap['herald-linkedin']).toBe('herald')
112
+ expect(agentMap['kaze-flights']).toBe('kaze')
113
+ expect(agentMap['spark-discover']).toBe('spark')
114
+ expect(agentMap['scribe-compress']).toBe('scribe')
115
+ expect(agentMap['robin-recon']).toBe('robin')
116
+ expect(agentMap['vault-backup']).toBe('jarvis')
117
+ expect(agentMap['maven-calendar']).toBe('maven')
118
+ expect(agentMap['team-memory-sync']).toBe('scribe')
119
+ expect(agentMap['mochi-feed']).toBe('pulse')
120
+ })
121
+ })
122
+
123
+ // --- Status mapping ---
124
+
125
+ describe('getCrons - status mapping', () => {
126
+ function makeCronWithStatus(status: string) {
127
+ return JSON.stringify([{
128
+ id: 'test',
129
+ name: 'pulse-test',
130
+ schedule: '* * * * *',
131
+ state: { status },
132
+ }])
133
+ }
134
+
135
+ it('maps "success" to "ok"', async () => {
136
+ mockExecSync.mockReturnValue(makeCronWithStatus('success'))
137
+ const crons = await getCrons()
138
+ expect(crons[0].status).toBe('ok')
139
+ })
140
+
141
+ it('maps "completed" to "ok"', async () => {
142
+ mockExecSync.mockReturnValue(makeCronWithStatus('completed'))
143
+ const crons = await getCrons()
144
+ expect(crons[0].status).toBe('ok')
145
+ })
146
+
147
+ it('maps "ok" to "ok"', async () => {
148
+ mockExecSync.mockReturnValue(makeCronWithStatus('ok'))
149
+ const crons = await getCrons()
150
+ expect(crons[0].status).toBe('ok')
151
+ })
152
+
153
+ it('maps "error" to "error"', async () => {
154
+ mockExecSync.mockReturnValue(makeCronWithStatus('error'))
155
+ const crons = await getCrons()
156
+ expect(crons[0].status).toBe('error')
157
+ })
158
+
159
+ it('maps "failed" to "error"', async () => {
160
+ mockExecSync.mockReturnValue(makeCronWithStatus('failed'))
161
+ const crons = await getCrons()
162
+ expect(crons[0].status).toBe('error')
163
+ })
164
+
165
+ it('maps unknown status to "idle"', async () => {
166
+ mockExecSync.mockReturnValue(makeCronWithStatus('pending'))
167
+ const crons = await getCrons()
168
+ expect(crons[0].status).toBe('idle')
169
+ })
170
+
171
+ it('maps empty string status to "idle"', async () => {
172
+ mockExecSync.mockReturnValue(makeCronWithStatus(''))
173
+ const crons = await getCrons()
174
+ expect(crons[0].status).toBe('idle')
175
+ })
176
+
177
+ it('reads status from top-level when state.status is missing', async () => {
178
+ mockExecSync.mockReturnValue(JSON.stringify([{
179
+ id: 'test',
180
+ name: 'pulse-test',
181
+ schedule: '* * * * *',
182
+ status: 'error',
183
+ state: {},
184
+ }]))
185
+ const crons = await getCrons()
186
+ expect(crons[0].status).toBe('error')
187
+ })
188
+ })
189
+
190
+ // --- Error / lastError ---
191
+
192
+ describe('getCrons - error and lastError', () => {
193
+ it('captures lastError from state', async () => {
194
+ mockExecSync.mockReturnValue(JSON.stringify([{
195
+ id: 'test',
196
+ name: 'pulse-test',
197
+ schedule: '* * * * *',
198
+ state: { status: 'error', lastError: 'timeout after 10s' },
199
+ }]))
200
+ const crons = await getCrons()
201
+ expect(crons[0].lastError).toBe('timeout after 10s')
202
+ })
203
+
204
+ it('captures error from state.error fallback', async () => {
205
+ mockExecSync.mockReturnValue(JSON.stringify([{
206
+ id: 'test',
207
+ name: 'pulse-test',
208
+ schedule: '* * * * *',
209
+ state: { error: 'network failure' },
210
+ }]))
211
+ const crons = await getCrons()
212
+ expect(crons[0].lastError).toBe('network failure')
213
+ })
214
+
215
+ it('captures lastError from top-level', async () => {
216
+ mockExecSync.mockReturnValue(JSON.stringify([{
217
+ id: 'test',
218
+ name: 'pulse-test',
219
+ schedule: '* * * * *',
220
+ state: {},
221
+ lastError: 'out of memory',
222
+ }]))
223
+ const crons = await getCrons()
224
+ expect(crons[0].lastError).toBe('out of memory')
225
+ })
226
+
227
+ it('sets lastError to null when no error info present', async () => {
228
+ mockExecSync.mockReturnValue(JSON.stringify([{
229
+ id: 'test',
230
+ name: 'pulse-test',
231
+ schedule: '* * * * *',
232
+ state: {},
233
+ }]))
234
+ const crons = await getCrons()
235
+ expect(crons[0].lastError).toBeNull()
236
+ })
237
+ })
238
+
239
+ // --- Error propagation (current implementation throws) ---
240
+
241
+ describe('getCrons - error propagation', () => {
242
+ it('throws when execSync throws (CLI not installed)', async () => {
243
+ mockExecSync.mockImplementation(() => { throw new Error('ENOENT') })
244
+ await expect(getCrons()).rejects.toThrow('Failed to fetch cron jobs')
245
+ await expect(getCrons()).rejects.toThrow('ENOENT')
246
+ })
247
+
248
+ it('throws for invalid JSON output', async () => {
249
+ mockExecSync.mockReturnValue('not valid json {{')
250
+ await expect(getCrons()).rejects.toThrow('Failed to fetch cron jobs')
251
+ })
252
+ })
253
+
254
+ // --- Schedule as object (bug fix: [object Object]) ---
255
+
256
+ describe('getCrons - schedule object handling', () => {
257
+ it('parses schedule object with expression + timezone', async () => {
258
+ mockExecSync.mockReturnValue(JSON.stringify([{
259
+ id: 'cron-obj',
260
+ name: 'pulse-daily',
261
+ schedule: { expression: '0 8 * * *', timezone: 'America/Chicago' },
262
+ state: { status: 'ok' },
263
+ }]))
264
+ const crons = await getCrons()
265
+ expect(crons[0].schedule).toBe('0 8 * * *')
266
+ expect(crons[0].timezone).toBe('America/Chicago')
267
+ expect(crons[0].scheduleDescription).toBe('Daily at 8 AM')
268
+ })
269
+
270
+ it('parses schedule object with cron key', async () => {
271
+ mockExecSync.mockReturnValue(JSON.stringify([{
272
+ id: 'cron-obj2',
273
+ name: 'herald-linkedin',
274
+ schedule: { cron: '0 10 * * 1-5' },
275
+ state: { status: 'ok' },
276
+ }]))
277
+ const crons = await getCrons()
278
+ expect(crons[0].schedule).toBe('0 10 * * 1-5')
279
+ expect(crons[0].timezone).toBeNull()
280
+ expect(crons[0].scheduleDescription).toBe('Weekdays at 10 AM')
281
+ })
282
+
283
+ it('handles plain string schedule (no regression)', async () => {
284
+ mockExecSync.mockReturnValue(JSON.stringify([{
285
+ id: 'cron-str',
286
+ name: 'vault-backup',
287
+ schedule: '0 3 * * *',
288
+ state: { status: 'ok' },
289
+ }]))
290
+ const crons = await getCrons()
291
+ expect(crons[0].schedule).toBe('0 3 * * *')
292
+ expect(crons[0].timezone).toBeNull()
293
+ expect(crons[0].scheduleDescription).toBe('Daily at 3 AM')
294
+ })
295
+
296
+ it('handles missing schedule', async () => {
297
+ mockExecSync.mockReturnValue(JSON.stringify([{
298
+ id: 'cron-none',
299
+ name: 'pulse-test',
300
+ state: {},
301
+ }]))
302
+ const crons = await getCrons()
303
+ expect(crons[0].schedule).toBe('')
304
+ expect(crons[0].scheduleDescription).toBe('')
305
+ expect(crons[0].timezone).toBeNull()
306
+ })
307
+
308
+ it('never produces [object Object]', async () => {
309
+ mockExecSync.mockReturnValue(JSON.stringify([
310
+ { id: '1', name: 'a', schedule: { expression: '0 8 * * *' }, state: {} },
311
+ { id: '2', name: 'b', schedule: { cron: '* * * * *' }, state: {} },
312
+ { id: '3', name: 'c', schedule: 'plain string', state: {} },
313
+ { id: '4', name: 'd', state: {} },
314
+ ]))
315
+ const crons = await getCrons()
316
+ for (const cron of crons) {
317
+ expect(cron.schedule).not.toContain('[object Object]')
318
+ expect(cron.scheduleDescription).not.toContain('[object Object]')
319
+ }
320
+ })
321
+ })
322
+
323
+ // --- Graceful defaults for missing fields ---
324
+
325
+ describe('getCrons - missing fields defaults', () => {
326
+ it('handles job with all fields missing (defaults to safe values)', async () => {
327
+ mockExecSync.mockReturnValue(JSON.stringify([{}]))
328
+ const crons = await getCrons()
329
+ expect(crons).toHaveLength(1)
330
+ expect(crons[0].id).toBe('')
331
+ expect(crons[0].name).toBe('')
332
+ expect(crons[0].schedule).toBe('')
333
+ expect(crons[0].scheduleDescription).toBe('')
334
+ expect(crons[0].timezone).toBeNull()
335
+ expect(crons[0].status).toBe('idle')
336
+ expect(crons[0].lastRun).toBeNull()
337
+ expect(crons[0].nextRun).toBeNull()
338
+ expect(crons[0].lastError).toBeNull()
339
+ expect(crons[0].agentId).toBeNull()
340
+ })
341
+
342
+ it('handles job with no state object', async () => {
343
+ mockExecSync.mockReturnValue(JSON.stringify([{
344
+ id: 'x',
345
+ name: 'pulse-test',
346
+ schedule: '0 * * * *',
347
+ }]))
348
+ const crons = await getCrons()
349
+ expect(crons).toHaveLength(1)
350
+ expect(crons[0].status).toBe('idle')
351
+ })
352
+
353
+ it('uses j.name as id fallback when j.id is missing', async () => {
354
+ mockExecSync.mockReturnValue(JSON.stringify([{
355
+ name: 'herald-post',
356
+ schedule: '0 10 * * *',
357
+ state: {},
358
+ }]))
359
+ const crons = await getCrons()
360
+ expect(crons[0].id).toBe('herald-post')
361
+ })
362
+
363
+ it('returns null agentId for unrecognized name prefix', async () => {
364
+ mockExecSync.mockReturnValue(JSON.stringify([{
365
+ id: 'unknown',
366
+ name: 'mystery-cron',
367
+ schedule: '0 0 * * *',
368
+ state: {},
369
+ }]))
370
+ const crons = await getCrons()
371
+ expect(crons[0].agentId).toBeNull()
372
+ })
373
+
374
+ it('defaults new fields when missing', async () => {
375
+ mockExecSync.mockReturnValue(JSON.stringify([{
376
+ id: 'test',
377
+ name: 'pulse-test',
378
+ schedule: '* * * * *',
379
+ state: {},
380
+ }]))
381
+ const crons = await getCrons()
382
+ expect(crons[0].description).toBeNull()
383
+ expect(crons[0].enabled).toBe(true)
384
+ expect(crons[0].delivery).toBeNull()
385
+ expect(crons[0].lastDurationMs).toBeNull()
386
+ expect(crons[0].consecutiveErrors).toBe(0)
387
+ expect(crons[0].lastDeliveryStatus).toBeNull()
388
+ })
389
+
390
+ it('handles empty array from CLI', async () => {
391
+ mockExecSync.mockReturnValue(JSON.stringify([]))
392
+ const crons = await getCrons()
393
+ expect(crons).toEqual([])
394
+ })
395
+
396
+ it('handles empty object from CLI (no jobs/data key)', async () => {
397
+ mockExecSync.mockReturnValue(JSON.stringify({}))
398
+ const crons = await getCrons()
399
+ expect(crons).toEqual([])
400
+ })
401
+ })
402
+
403
+ // --- Date parsing ---
404
+
405
+ describe('getCrons - date parsing', () => {
406
+ it('converts nextRunAtMs (milliseconds) to ISO string', async () => {
407
+ const ts = 1700000000000
408
+ mockExecSync.mockReturnValue(JSON.stringify([{
409
+ id: 'test',
410
+ name: 'pulse-test',
411
+ schedule: '* * * * *',
412
+ state: { nextRunAtMs: ts },
413
+ }]))
414
+ const crons = await getCrons()
415
+ expect(crons[0].nextRun).toBe(new Date(ts).toISOString())
416
+ })
417
+
418
+ it('converts lastRunAtMs to ISO string', async () => {
419
+ const ts = 1699900000000
420
+ mockExecSync.mockReturnValue(JSON.stringify([{
421
+ id: 'test',
422
+ name: 'pulse-test',
423
+ schedule: '* * * * *',
424
+ state: { lastRunAtMs: ts },
425
+ }]))
426
+ const crons = await getCrons()
427
+ expect(crons[0].lastRun).toBe(new Date(ts).toISOString())
428
+ })
429
+
430
+ it('falls back to top-level nextRunAt', async () => {
431
+ const ts = 1700000000000
432
+ mockExecSync.mockReturnValue(JSON.stringify([{
433
+ id: 'test',
434
+ name: 'pulse-test',
435
+ schedule: '* * * * *',
436
+ state: {},
437
+ nextRunAt: ts,
438
+ }]))
439
+ const crons = await getCrons()
440
+ expect(crons[0].nextRun).toBe(new Date(ts).toISOString())
441
+ })
442
+ })
443
+
444
+ // --- Actual data format (expr/tz) + rich fields ---
445
+
446
+ describe('getCrons - actual data format with expr/tz and rich fields', () => {
447
+ it('parses schedule with { kind: "cron", expr, tz }', async () => {
448
+ mockExecSync.mockReturnValue(JSON.stringify([{
449
+ id: '0b133350-ca33-42ae-a4b3-9b4d249e4a6b',
450
+ name: 'builder-briefing',
451
+ description: 'Daily 6 AM wake-up message for John with image',
452
+ enabled: true,
453
+ schedule: { kind: 'cron', expr: '0 6 * * *', tz: 'America/Chicago' },
454
+ delivery: { mode: 'announce', channel: 'discord', to: 'channel:1475355059721339063' },
455
+ state: {
456
+ lastRunStatus: 'ok',
457
+ lastDurationMs: 147116,
458
+ lastDelivered: true,
459
+ lastDeliveryStatus: 'delivered',
460
+ consecutiveErrors: 0,
461
+ nextRunAtMs: 1772539200000,
462
+ lastRunAtMs: 1772452800026,
463
+ },
464
+ }]))
465
+ const crons = await getCrons()
466
+ expect(crons[0].schedule).toBe('0 6 * * *')
467
+ expect(crons[0].timezone).toBe('America/Chicago')
468
+ expect(crons[0].scheduleDescription).toBe('Daily at 6 AM')
469
+ expect(crons[0].description).toBe('Daily 6 AM wake-up message for John with image')
470
+ expect(crons[0].enabled).toBe(true)
471
+ expect(crons[0].delivery).toEqual({ mode: 'announce', channel: 'discord', to: 'channel:1475355059721339063' })
472
+ expect(crons[0].lastDurationMs).toBe(147116)
473
+ expect(crons[0].consecutiveErrors).toBe(0)
474
+ expect(crons[0].lastDeliveryStatus).toBe('delivered')
475
+ })
476
+
477
+ it('handles delivery with missing to field', async () => {
478
+ mockExecSync.mockReturnValue(JSON.stringify([{
479
+ id: 'test',
480
+ name: 'vault-morning-snapshot',
481
+ schedule: { kind: 'cron', expr: '0 5 * * *', tz: 'America/Chicago' },
482
+ delivery: { mode: 'announce', channel: 'discord' },
483
+ state: { lastRunStatus: 'error', consecutiveErrors: 3, lastDeliveryStatus: 'unknown' },
484
+ }]))
485
+ const crons = await getCrons()
486
+ expect(crons[0].delivery).toEqual({ mode: 'announce', channel: 'discord', to: null })
487
+ expect(crons[0].consecutiveErrors).toBe(3)
488
+ expect(crons[0].lastDeliveryStatus).toBe('unknown')
489
+ })
490
+
491
+ it('handles disabled job', async () => {
492
+ mockExecSync.mockReturnValue(JSON.stringify([{
493
+ id: 'disabled',
494
+ name: 'test-disabled',
495
+ enabled: false,
496
+ schedule: '* * * * *',
497
+ state: {},
498
+ }]))
499
+ const crons = await getCrons()
500
+ expect(crons[0].enabled).toBe(false)
501
+ })
502
+ })
package/lib/crons.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { CronJob, CronDelivery } from '@/lib/types'
2
+ import { execSync } from 'child_process'
3
+ import { parseSchedule, describeCron } from './cron-utils'
4
+ import { requireEnv } from '@/lib/env'
5
+
6
+ const PREFIX_MAP: [string, string][] = [
7
+ ['pulse-', 'pulse'],
8
+ ['herald-', 'herald'],
9
+ ['robin-', 'robin'],
10
+ ['seo-team-', 'lumen'],
11
+ ['seo-', 'lumen'],
12
+ ['echo-', 'echo'],
13
+ ['spark-', 'spark'],
14
+ ['scribe-', 'scribe'],
15
+ ['kaze-', 'kaze'],
16
+ ['vault-', 'jarvis'],
17
+ ['builder-', 'jarvis'],
18
+ ['clawport-', 'jarvis'],
19
+ ['maven-', 'maven'],
20
+ ['recon-', 'robin'],
21
+ ['team-memory-', 'scribe'],
22
+ ['mochi-', 'pulse'],
23
+ ]
24
+
25
+ function matchAgent(name: string): string | null {
26
+ for (const [prefix, agentId] of PREFIX_MAP) {
27
+ if (name.startsWith(prefix)) return agentId
28
+ }
29
+ return null
30
+ }
31
+
32
+ export async function getCrons(): Promise<CronJob[]> {
33
+ try {
34
+ const openclawBin = requireEnv('OPENCLAW_BIN')
35
+ const raw = execSync(`${openclawBin} cron list --json`, {
36
+ encoding: 'utf-8',
37
+ timeout: 10000,
38
+ })
39
+
40
+ const parsed = JSON.parse(raw)
41
+ const jobs: unknown[] = Array.isArray(parsed)
42
+ ? parsed
43
+ : parsed.jobs ?? parsed.data ?? []
44
+
45
+ return jobs.map((job: unknown) => {
46
+ const j = job as Record<string, unknown>
47
+ const state = (j.state as Record<string, unknown>) || {}
48
+ const name = String(j.name || '')
49
+ const { expression: schedule, timezone } = parseSchedule(j.schedule)
50
+
51
+ // Status can be in state.status or directly on j.status
52
+ const rawStatus = state.status ?? j.status ?? ''
53
+ let status: 'ok' | 'error' | 'idle' = 'idle'
54
+ if (rawStatus === 'error' || rawStatus === 'failed') {
55
+ status = 'error'
56
+ } else if (rawStatus === 'ok' || rawStatus === 'success' || rawStatus === 'completed') {
57
+ status = 'ok'
58
+ }
59
+
60
+ // nextRun: try state.nextRunAtMs first, then state.nextRunAt
61
+ const nextRunMs = state.nextRunAtMs ?? state.nextRunAt ?? j.nextRunAtMs ?? j.nextRunAt
62
+ const nextRun = nextRunMs
63
+ ? new Date(Number(nextRunMs)).toISOString()
64
+ : null
65
+
66
+ // lastRun: try state.lastRunAtMs, state.lastRunAt, or top-level equivalents
67
+ const lastRunRaw = state.lastRunAtMs ?? state.lastRunAt ?? j.lastRunAtMs ?? j.lastRunAt ?? j.last
68
+ const lastRun = lastRunRaw
69
+ ? (typeof lastRunRaw === 'number' ? new Date(lastRunRaw).toISOString() : String(lastRunRaw))
70
+ : null
71
+
72
+ const lastError = (state.lastError ?? state.error ?? j.lastError) ? String(state.lastError ?? state.error ?? j.lastError) : null
73
+
74
+ // Delivery config
75
+ const rawDelivery = j.delivery as Record<string, unknown> | undefined
76
+ let delivery: CronDelivery | null = null
77
+ if (rawDelivery && typeof rawDelivery === 'object') {
78
+ delivery = {
79
+ mode: String(rawDelivery.mode || ''),
80
+ channel: String(rawDelivery.channel || ''),
81
+ to: rawDelivery.to ? String(rawDelivery.to) : null,
82
+ }
83
+ }
84
+
85
+ // Rich state fields
86
+ const lastDurationMs = typeof state.lastDurationMs === 'number' ? state.lastDurationMs : null
87
+ const consecutiveErrors = typeof state.consecutiveErrors === 'number' ? state.consecutiveErrors : 0
88
+ const lastDeliveryStatus = typeof state.lastDeliveryStatus === 'string' ? state.lastDeliveryStatus : null
89
+
90
+ return {
91
+ id: String(j.id || j.name || ''),
92
+ name,
93
+ schedule,
94
+ scheduleDescription: describeCron(schedule),
95
+ timezone,
96
+ status,
97
+ lastRun,
98
+ nextRun,
99
+ lastError,
100
+ agentId: matchAgent(name),
101
+ description: typeof j.description === 'string' ? j.description : null,
102
+ enabled: j.enabled !== false,
103
+ delivery,
104
+ lastDurationMs,
105
+ consecutiveErrors,
106
+ lastDeliveryStatus,
107
+ }
108
+ })
109
+ } catch (err) {
110
+ throw new Error(
111
+ `Failed to fetch cron jobs: ${err instanceof Error ? err.message : String(err)}`
112
+ )
113
+ }
114
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { requireEnv } from '@/lib/env'
3
+
4
+ describe('requireEnv', () => {
5
+ beforeEach(() => {
6
+ vi.unstubAllEnvs()
7
+ })
8
+
9
+ afterEach(() => {
10
+ vi.unstubAllEnvs()
11
+ })
12
+
13
+ it('returns the value when the environment variable is set', () => {
14
+ vi.stubEnv('TEST_VAR', '/some/path')
15
+ expect(requireEnv('TEST_VAR')).toBe('/some/path')
16
+ })
17
+
18
+ it('throws when the environment variable is missing', () => {
19
+ delete process.env.TEST_MISSING_VAR
20
+ expect(() => requireEnv('TEST_MISSING_VAR')).toThrow()
21
+ })
22
+
23
+ it('throws when the environment variable is an empty string', () => {
24
+ vi.stubEnv('TEST_EMPTY_VAR', '')
25
+ expect(() => requireEnv('TEST_EMPTY_VAR')).toThrow()
26
+ })
27
+
28
+ it('error message includes the variable name', () => {
29
+ delete process.env.MY_SPECIAL_VAR
30
+ expect(() => requireEnv('MY_SPECIAL_VAR')).toThrow('MY_SPECIAL_VAR')
31
+ })
32
+
33
+ it('error message mentions .env.example', () => {
34
+ delete process.env.SOME_VAR
35
+ expect(() => requireEnv('SOME_VAR')).toThrow('.env.example')
36
+ })
37
+
38
+ it('error message includes "Missing required environment variable"', () => {
39
+ delete process.env.ANOTHER_VAR
40
+ expect(() => requireEnv('ANOTHER_VAR')).toThrow(
41
+ 'Missing required environment variable: ANOTHER_VAR'
42
+ )
43
+ })
44
+ })
package/lib/env.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Safely retrieve a required environment variable at runtime.
3
+ * Call inside functions (not at module top level) so imports don't crash during build/test.
4
+ */
5
+ export function requireEnv(name: string): string {
6
+ const value = process.env[name]
7
+ if (!value) {
8
+ throw new Error(
9
+ `Missing required environment variable: ${name}. ` +
10
+ `See .env.example for configuration.`
11
+ )
12
+ }
13
+ return value
14
+ }