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.
- package/.env.example +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- 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
|
+
}
|
package/lib/env.test.ts
ADDED
|
@@ -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
|
+
}
|