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,69 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { PIPELINES, getPipelinesForJob, getAllPipelineJobNames } from './cron-pipelines'
4
+
5
+ describe('PIPELINES', () => {
6
+ it('has 3 pipeline definitions', () => {
7
+ expect(PIPELINES).toHaveLength(3)
8
+ })
9
+
10
+ it('Morning Briefing has correct edges', () => {
11
+ const p = PIPELINES.find(p => p.name === 'Morning Briefing')!
12
+ expect(p.edges).toHaveLength(1)
13
+ expect(p.edges[0]).toEqual({
14
+ from: 'vault-morning-snapshot',
15
+ to: 'builder-briefing',
16
+ artifact: 'vault-snapshot.json',
17
+ })
18
+ })
19
+
20
+ it('Pulse Daily Pipeline has 4 edges', () => {
21
+ const p = PIPELINES.find(p => p.name === 'Pulse Daily Pipeline')!
22
+ expect(p.edges).toHaveLength(4)
23
+ })
24
+ })
25
+
26
+ describe('getPipelinesForJob', () => {
27
+ it('finds pipelines for a source job', () => {
28
+ const result = getPipelinesForJob('vault-morning-snapshot')
29
+ expect(result).toHaveLength(1)
30
+ expect(result[0].name).toBe('Morning Briefing')
31
+ })
32
+
33
+ it('finds pipelines for a target job', () => {
34
+ const result = getPipelinesForJob('builder-briefing')
35
+ expect(result).toHaveLength(1)
36
+ expect(result[0].name).toBe('Morning Briefing')
37
+ })
38
+
39
+ it('finds multiple pipelines for jobs in multiple pipelines', () => {
40
+ const result = getPipelinesForJob('pulse-daily-hype-brief')
41
+ expect(result).toHaveLength(1) // only in Pulse Daily Pipeline
42
+ expect(result[0].name).toBe('Pulse Daily Pipeline')
43
+ })
44
+
45
+ it('returns empty for standalone jobs', () => {
46
+ const result = getPipelinesForJob('kaze-japan-flights')
47
+ expect(result).toHaveLength(0)
48
+ })
49
+ })
50
+
51
+ describe('getAllPipelineJobNames', () => {
52
+ it('returns all unique job names from pipelines', () => {
53
+ const names = getAllPipelineJobNames()
54
+ expect(names.has('vault-morning-snapshot')).toBe(true)
55
+ expect(names.has('builder-briefing')).toBe(true)
56
+ expect(names.has('pulse-feed-aggregator')).toBe(true)
57
+ expect(names.has('pulse-daily-hype-brief')).toBe(true)
58
+ expect(names.has('herald-linkedin-content')).toBe(true)
59
+ expect(names.has('pulse-lumen-bridge')).toBe(true)
60
+ expect(names.has('seo-data-drop-reminder')).toBe(true)
61
+ expect(names.has('seo-team-weekly')).toBe(true)
62
+ })
63
+
64
+ it('does not contain standalone jobs', () => {
65
+ const names = getAllPipelineJobNames()
66
+ expect(names.has('kaze-japan-flights')).toBe(false)
67
+ expect(names.has('robin-weekly-brief')).toBe(false)
68
+ })
69
+ })
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Manually-defined pipeline map — describes implicit cron pipelines
3
+ * based on file I/O and scheduling dependencies.
4
+ */
5
+
6
+ export interface PipelineEdge {
7
+ from: string
8
+ to: string
9
+ artifact: string
10
+ }
11
+
12
+ export interface Pipeline {
13
+ name: string
14
+ edges: PipelineEdge[]
15
+ }
16
+
17
+ export const PIPELINES: Pipeline[] = [
18
+ {
19
+ name: 'Morning Briefing',
20
+ edges: [
21
+ { from: 'vault-morning-snapshot', to: 'builder-briefing', artifact: 'vault-snapshot.json' },
22
+ ],
23
+ },
24
+ {
25
+ name: 'Pulse Daily Pipeline',
26
+ edges: [
27
+ { from: 'pulse-feed-aggregator', to: 'pulse-daily-hype-brief', artifact: 'pulse-feed-latest.txt' },
28
+ { from: 'pulse-launch-watcher', to: 'pulse-daily-hype-brief', artifact: 'pulse-launches-latest.txt' },
29
+ { from: 'pulse-daily-hype-brief', to: 'herald-linkedin-content', artifact: 'pulse-feed-latest.txt' },
30
+ { from: 'pulse-daily-hype-brief', to: 'pulse-lumen-bridge', artifact: 'hot-signals.json' },
31
+ ],
32
+ },
33
+ {
34
+ name: 'SEO Weekly Pipeline',
35
+ edges: [
36
+ { from: 'seo-data-drop-reminder', to: 'seo-team-weekly', artifact: 'SEO-Drops/' },
37
+ ],
38
+ },
39
+ ]
40
+
41
+ /** Get all pipelines that include a specific job name. */
42
+ export function getPipelinesForJob(name: string): Pipeline[] {
43
+ return PIPELINES.filter(p =>
44
+ p.edges.some(e => e.from === name || e.to === name)
45
+ )
46
+ }
47
+
48
+ /** Get the set of all job names that appear in any pipeline. */
49
+ export function getAllPipelineJobNames(): Set<string> {
50
+ const names = new Set<string>()
51
+ for (const p of PIPELINES) {
52
+ for (const e of p.edges) {
53
+ names.add(e.from)
54
+ names.add(e.to)
55
+ }
56
+ }
57
+ return names
58
+ }
@@ -0,0 +1,118 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ const { mockReadFileSync, mockReaddirSync, mockExistsSync } = vi.hoisted(() => ({
5
+ mockReadFileSync: vi.fn(),
6
+ mockReaddirSync: vi.fn(),
7
+ mockExistsSync: vi.fn(),
8
+ }))
9
+
10
+ vi.mock('fs', () => ({
11
+ readFileSync: mockReadFileSync,
12
+ readdirSync: mockReaddirSync,
13
+ existsSync: mockExistsSync,
14
+ default: { readFileSync: mockReadFileSync, readdirSync: mockReaddirSync, existsSync: mockExistsSync },
15
+ }))
16
+
17
+ import { getCronRuns } from './cron-runs'
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks()
21
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
22
+ mockExistsSync.mockReturnValue(true)
23
+ })
24
+
25
+ describe('getCronRuns', () => {
26
+ it('parses JSONL lines and returns sorted newest-first', () => {
27
+ const lines = [
28
+ JSON.stringify({ ts: 1000, jobId: 'a', action: 'finished', status: 'ok', summary: 'done', durationMs: 5000, deliveryStatus: 'delivered' }),
29
+ JSON.stringify({ ts: 3000, jobId: 'a', action: 'finished', status: 'error', error: 'timeout', durationMs: 10000, deliveryStatus: 'unknown' }),
30
+ JSON.stringify({ ts: 2000, jobId: 'a', action: 'finished', status: 'ok', summary: 'ok', durationMs: 3000, deliveryStatus: 'delivered' }),
31
+ ].join('\n')
32
+
33
+ mockReaddirSync.mockReturnValue(['a.jsonl'])
34
+ mockReadFileSync.mockReturnValue(lines)
35
+
36
+ const runs = getCronRuns()
37
+ expect(runs).toHaveLength(3)
38
+ expect(runs[0].ts).toBe(3000)
39
+ expect(runs[0].status).toBe('error')
40
+ expect(runs[1].ts).toBe(2000)
41
+ expect(runs[2].ts).toBe(1000)
42
+ })
43
+
44
+ it('filters by jobId when provided', () => {
45
+ const lines = JSON.stringify({ ts: 1000, jobId: 'abc', action: 'finished', status: 'ok', durationMs: 100 })
46
+ mockReadFileSync.mockReturnValue(lines)
47
+
48
+ const runs = getCronRuns('abc')
49
+ expect(runs).toHaveLength(1)
50
+ expect(runs[0].jobId).toBe('abc')
51
+ // Should not call readdirSync when filtering by jobId
52
+ expect(mockReaddirSync).not.toHaveBeenCalled()
53
+ })
54
+
55
+ it('returns empty array when runs dir does not exist', () => {
56
+ mockExistsSync.mockReturnValue(false)
57
+ const runs = getCronRuns()
58
+ expect(runs).toEqual([])
59
+ })
60
+
61
+ it('skips non-finished actions', () => {
62
+ const lines = [
63
+ JSON.stringify({ ts: 1000, jobId: 'a', action: 'started', status: 'ok' }),
64
+ JSON.stringify({ ts: 2000, jobId: 'a', action: 'finished', status: 'ok', durationMs: 100 }),
65
+ ].join('\n')
66
+
67
+ mockReaddirSync.mockReturnValue(['a.jsonl'])
68
+ mockReadFileSync.mockReturnValue(lines)
69
+
70
+ const runs = getCronRuns()
71
+ expect(runs).toHaveLength(1)
72
+ expect(runs[0].ts).toBe(2000)
73
+ })
74
+
75
+ it('skips malformed JSON lines', () => {
76
+ const lines = [
77
+ 'not valid json',
78
+ JSON.stringify({ ts: 1000, jobId: 'a', action: 'finished', status: 'ok', durationMs: 100 }),
79
+ '{ broken',
80
+ ].join('\n')
81
+
82
+ mockReaddirSync.mockReturnValue(['a.jsonl'])
83
+ mockReadFileSync.mockReturnValue(lines)
84
+
85
+ const runs = getCronRuns()
86
+ expect(runs).toHaveLength(1)
87
+ })
88
+
89
+ it('skips empty lines', () => {
90
+ const lines = '\n\n' + JSON.stringify({ ts: 1000, jobId: 'a', action: 'finished', status: 'ok', durationMs: 100 }) + '\n\n'
91
+ mockReaddirSync.mockReturnValue(['a.jsonl'])
92
+ mockReadFileSync.mockReturnValue(lines)
93
+
94
+ const runs = getCronRuns()
95
+ expect(runs).toHaveLength(1)
96
+ })
97
+
98
+ it('handles unreadable files gracefully', () => {
99
+ mockReaddirSync.mockReturnValue(['a.jsonl', 'b.jsonl'])
100
+ mockReadFileSync.mockImplementation((filePath: string) => {
101
+ if (filePath.includes('a.jsonl')) throw new Error('permission denied')
102
+ return JSON.stringify({ ts: 1000, jobId: 'b', action: 'finished', status: 'ok', durationMs: 100 })
103
+ })
104
+
105
+ const runs = getCronRuns()
106
+ expect(runs).toHaveLength(1)
107
+ expect(runs[0].jobId).toBe('b')
108
+ })
109
+
110
+ it('returns empty when jobId file does not exist', () => {
111
+ mockExistsSync.mockImplementation((p: string) => {
112
+ // Runs dir exists but specific file does not
113
+ return !p.endsWith('.jsonl')
114
+ })
115
+ const runs = getCronRuns('nonexistent')
116
+ expect(runs).toEqual([])
117
+ })
118
+ })
@@ -0,0 +1,67 @@
1
+ import { CronRun } from '@/lib/types'
2
+ import { readFileSync, readdirSync, existsSync } from 'fs'
3
+ import path from 'path'
4
+ import { requireEnv } from '@/lib/env'
5
+
6
+ /** Derive the cron runs directory from WORKSPACE_PATH (go up from workspace to .openclaw/cron/runs) */
7
+ function getRunsDir(): string {
8
+ return path.resolve(requireEnv('WORKSPACE_PATH'), '..', 'cron', 'runs')
9
+ }
10
+
11
+ /**
12
+ * Parse a single JSONL line into a CronRun.
13
+ * Returns null if the line can't be parsed or is not a "finished" action.
14
+ */
15
+ function parseLine(line: string): CronRun | null {
16
+ if (!line.trim()) return null
17
+ try {
18
+ const obj = JSON.parse(line)
19
+ if (obj.action && obj.action !== 'finished') return null
20
+ return {
21
+ ts: typeof obj.ts === 'number' ? obj.ts : 0,
22
+ jobId: String(obj.jobId || ''),
23
+ status: obj.status === 'ok' ? 'ok' : 'error',
24
+ summary: typeof obj.summary === 'string' ? obj.summary : null,
25
+ error: typeof obj.error === 'string' ? obj.error : null,
26
+ durationMs: typeof obj.durationMs === 'number' ? obj.durationMs : 0,
27
+ deliveryStatus: typeof obj.deliveryStatus === 'string' ? obj.deliveryStatus : null,
28
+ }
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Read JSONL run history files. Returns CronRun[] sorted newest-first.
36
+ * If jobId is provided, reads only that job's file. Otherwise reads all files.
37
+ */
38
+ export function getCronRuns(jobId?: string): CronRun[] {
39
+ const runsDir = getRunsDir()
40
+ if (!existsSync(runsDir)) return []
41
+
42
+ let files: string[]
43
+ if (jobId) {
44
+ const filePath = path.join(runsDir, `${jobId}.jsonl`)
45
+ files = existsSync(filePath) ? [filePath] : []
46
+ } else {
47
+ files = readdirSync(runsDir)
48
+ .filter(f => f.endsWith('.jsonl'))
49
+ .map(f => path.join(runsDir, f))
50
+ }
51
+
52
+ const runs: CronRun[] = []
53
+ for (const filePath of files) {
54
+ try {
55
+ const content = readFileSync(filePath, 'utf-8')
56
+ for (const line of content.split('\n')) {
57
+ const run = parseLine(line)
58
+ if (run) runs.push(run)
59
+ }
60
+ } catch {
61
+ // Skip unreadable files
62
+ }
63
+ }
64
+
65
+ runs.sort((a, b) => b.ts - a.ts)
66
+ return runs
67
+ }
@@ -0,0 +1,222 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { parseSchedule, describeCron, formatDuration, parseScheduleSlots } from './cron-utils'
4
+
5
+ // --- parseSchedule ---
6
+
7
+ describe('parseSchedule', () => {
8
+ it('handles a plain string', () => {
9
+ const result = parseSchedule('0 8 * * *')
10
+ expect(result).toEqual({ expression: '0 8 * * *', timezone: null })
11
+ })
12
+
13
+ it('handles object with expression + timezone', () => {
14
+ const result = parseSchedule({ expression: '0 8 * * *', timezone: 'America/Chicago' })
15
+ expect(result).toEqual({ expression: '0 8 * * *', timezone: 'America/Chicago' })
16
+ })
17
+
18
+ it('handles object with cron key', () => {
19
+ const result = parseSchedule({ cron: '0 10 * * 1-5' })
20
+ expect(result).toEqual({ expression: '0 10 * * 1-5', timezone: null })
21
+ })
22
+
23
+ it('handles object with value key', () => {
24
+ const result = parseSchedule({ value: '0 6 * * 1' })
25
+ expect(result).toEqual({ expression: '0 6 * * 1', timezone: null })
26
+ })
27
+
28
+ it('handles null', () => {
29
+ const result = parseSchedule(null)
30
+ expect(result).toEqual({ expression: '', timezone: null })
31
+ })
32
+
33
+ it('handles undefined', () => {
34
+ const result = parseSchedule(undefined)
35
+ expect(result).toEqual({ expression: '', timezone: null })
36
+ })
37
+
38
+ it('handles empty object', () => {
39
+ const result = parseSchedule({})
40
+ expect(result).toEqual({ expression: '', timezone: null })
41
+ })
42
+
43
+ it('ignores non-string timezone', () => {
44
+ const result = parseSchedule({ expression: '0 8 * * *', timezone: 123 })
45
+ expect(result).toEqual({ expression: '0 8 * * *', timezone: null })
46
+ })
47
+
48
+ it('handles actual data format: { kind: "cron", expr: "...", tz: "..." }', () => {
49
+ const result = parseSchedule({ kind: 'cron', expr: '0 6 * * *', tz: 'America/Chicago' })
50
+ expect(result).toEqual({ expression: '0 6 * * *', timezone: 'America/Chicago' })
51
+ })
52
+
53
+ it('handles expr without tz', () => {
54
+ const result = parseSchedule({ kind: 'cron', expr: '0 12 * * 1' })
55
+ expect(result).toEqual({ expression: '0 12 * * 1', timezone: null })
56
+ })
57
+
58
+ it('prefers expr over expression when both present', () => {
59
+ const result = parseSchedule({ expr: '0 6 * * *', expression: '0 8 * * *' })
60
+ expect(result).toEqual({ expression: '0 6 * * *', timezone: null })
61
+ })
62
+ })
63
+
64
+ // --- describeCron ---
65
+
66
+ describe('describeCron', () => {
67
+ it('daily at 8:00 AM', () => {
68
+ expect(describeCron('0 8 * * *')).toBe('Daily at 8 AM')
69
+ })
70
+
71
+ it('daily at 3:00 PM', () => {
72
+ expect(describeCron('0 15 * * *')).toBe('Daily at 3 PM')
73
+ })
74
+
75
+ it('daily at 12:00 AM (midnight)', () => {
76
+ expect(describeCron('0 0 * * *')).toBe('Daily at 12 AM')
77
+ })
78
+
79
+ it('daily at 12:00 PM (noon)', () => {
80
+ expect(describeCron('0 12 * * *')).toBe('Daily at 12 PM')
81
+ })
82
+
83
+ it('daily with non-zero minutes', () => {
84
+ expect(describeCron('30 14 * * *')).toBe('Daily at 2:30 PM')
85
+ })
86
+
87
+ it('weekdays at 10:00 AM', () => {
88
+ expect(describeCron('0 10 * * 1-5')).toBe('Weekdays at 10 AM')
89
+ })
90
+
91
+ it('Mondays at 6:00 AM', () => {
92
+ expect(describeCron('0 6 * * 1')).toBe('Mondays at 6 AM')
93
+ })
94
+
95
+ it('Sundays at 12:00 AM', () => {
96
+ expect(describeCron('0 0 * * 0')).toBe('Sundays at 12 AM')
97
+ })
98
+
99
+ it('Fridays at 5:00 PM', () => {
100
+ expect(describeCron('0 17 * * 5')).toBe('Fridays at 5 PM')
101
+ })
102
+
103
+ it('Saturdays at 9:00 AM', () => {
104
+ expect(describeCron('0 9 * * 6')).toBe('Saturdays at 9 AM')
105
+ })
106
+
107
+ it('every 2 days at 12:00 PM', () => {
108
+ expect(describeCron('0 12 */2 * *')).toBe('Every 2 days at 12 PM')
109
+ })
110
+
111
+ it('every minute', () => {
112
+ expect(describeCron('* * * * *')).toBe('Every minute')
113
+ })
114
+
115
+ it('every hour', () => {
116
+ expect(describeCron('0 * * * *')).toBe('Every hour')
117
+ })
118
+
119
+ it('monthly on the 1st at 8:00 AM', () => {
120
+ expect(describeCron('0 8 1 * *')).toBe('Monthly on the 1st at 8 AM')
121
+ })
122
+
123
+ it('monthly on the 2nd at 9:00 AM', () => {
124
+ expect(describeCron('0 9 2 * *')).toBe('Monthly on the 2nd at 9 AM')
125
+ })
126
+
127
+ it('monthly on the 3rd at 10:00 AM', () => {
128
+ expect(describeCron('0 10 3 * *')).toBe('Monthly on the 3rd at 10 AM')
129
+ })
130
+
131
+ it('monthly on the 15th at 6:00 PM', () => {
132
+ expect(describeCron('0 18 15 * *')).toBe('Monthly on the 15th at 6 PM')
133
+ })
134
+
135
+ it('returns raw expression for unparseable input', () => {
136
+ expect(describeCron('*/5 */2 1,15 * 1-5')).toBe('*/5 */2 1,15 * 1-5')
137
+ })
138
+
139
+ it('returns raw expression for 6-field cron', () => {
140
+ expect(describeCron('0 0 8 * * *')).toBe('0 0 8 * * *')
141
+ })
142
+
143
+ it('returns empty string for empty input', () => {
144
+ expect(describeCron('')).toBe('')
145
+ })
146
+
147
+ it('returns empty string for whitespace-only input', () => {
148
+ expect(describeCron(' ')).toBe('')
149
+ })
150
+ })
151
+
152
+ // --- formatDuration ---
153
+
154
+ describe('formatDuration', () => {
155
+ it('formats seconds only', () => {
156
+ expect(formatDuration(45000)).toBe('45s')
157
+ })
158
+
159
+ it('formats zero', () => {
160
+ expect(formatDuration(0)).toBe('0s')
161
+ })
162
+
163
+ it('formats minutes and seconds', () => {
164
+ expect(formatDuration(147116)).toBe('2m 27s')
165
+ })
166
+
167
+ it('formats exact minutes', () => {
168
+ expect(formatDuration(120000)).toBe('2m')
169
+ })
170
+
171
+ it('formats hours and minutes', () => {
172
+ expect(formatDuration(3660000)).toBe('1h 1m')
173
+ })
174
+
175
+ it('formats exact hours', () => {
176
+ expect(formatDuration(3600000)).toBe('1h 0m')
177
+ })
178
+
179
+ it('handles negative values', () => {
180
+ expect(formatDuration(-1)).toBe('—')
181
+ })
182
+
183
+ it('handles Infinity', () => {
184
+ expect(formatDuration(Infinity)).toBe('—')
185
+ })
186
+ })
187
+
188
+ // --- parseScheduleSlots ---
189
+
190
+ describe('parseScheduleSlots', () => {
191
+ it('parses daily cron', () => {
192
+ const result = parseScheduleSlots('0 8 * * *')
193
+ expect(result).toEqual({ hour: 8, minute: 0, days: [0, 1, 2, 3, 4, 5, 6] })
194
+ })
195
+
196
+ it('parses weekday cron', () => {
197
+ const result = parseScheduleSlots('0 10 * * 1-5')
198
+ expect(result).toEqual({ hour: 10, minute: 0, days: [1, 2, 3, 4, 5] })
199
+ })
200
+
201
+ it('parses specific day cron', () => {
202
+ const result = parseScheduleSlots('0 12 * * 1')
203
+ expect(result).toEqual({ hour: 12, minute: 0, days: [1] })
204
+ })
205
+
206
+ it('parses comma-separated days', () => {
207
+ const result = parseScheduleSlots('30 9 * * 1,3,5')
208
+ expect(result).toEqual({ hour: 9, minute: 30, days: [1, 3, 5] })
209
+ })
210
+
211
+ it('returns null for empty input', () => {
212
+ expect(parseScheduleSlots('')).toBeNull()
213
+ })
214
+
215
+ it('returns null for 6-field cron', () => {
216
+ expect(parseScheduleSlots('0 0 8 * * *')).toBeNull()
217
+ })
218
+
219
+ it('returns null for wildcard hour', () => {
220
+ expect(parseScheduleSlots('0 * * * *')).toBeNull()
221
+ })
222
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Cron schedule parsing and human-readable description utilities.
3
+ * No external dependencies — covers the ~10 patterns observed in production.
4
+ */
5
+
6
+ const DAY_NAMES = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays']
7
+
8
+ function formatTime(hour: number, minute: number): string {
9
+ const h = hour % 12 || 12
10
+ const ampm = hour < 12 ? 'AM' : 'PM'
11
+ const m = minute === 0 ? '' : `:${String(minute).padStart(2, '0')}`
12
+ return `${h}${m} ${ampm}`
13
+ }
14
+
15
+ function formatTimeWithMinute(hour: number, minute: number): string {
16
+ const h = hour % 12 || 12
17
+ const ampm = hour < 12 ? 'AM' : 'PM'
18
+ return `${h}:${String(minute).padStart(2, '0')} ${ampm}`
19
+ }
20
+
21
+ /**
22
+ * Extract a cron expression string and optional timezone from the raw schedule
23
+ * value returned by OpenClaw CLI. Handles:
24
+ * - string: "0 8 * * *"
25
+ * - object: { kind: "cron", expr: "0 8 * * *", tz: "America/Chicago" }
26
+ * - object: { expression: "0 8 * * *", timezone: "America/Chicago" }
27
+ * - object: { cron: "0 8 * * *" }
28
+ * - object: { value: "0 8 * * *" }
29
+ * - null/undefined/missing
30
+ */
31
+ export function parseSchedule(raw: unknown): { expression: string; timezone: string | null } {
32
+ if (raw == null) return { expression: '', timezone: null }
33
+ if (typeof raw === 'string') return { expression: raw, timezone: null }
34
+ if (typeof raw === 'object') {
35
+ const obj = raw as Record<string, unknown>
36
+ const expression = String(obj.expr ?? obj.expression ?? obj.cron ?? obj.value ?? '')
37
+ const tz = obj.tz ?? obj.timezone
38
+ const timezone = typeof tz === 'string' ? tz : null
39
+ return { expression, timezone }
40
+ }
41
+ return { expression: String(raw), timezone: null }
42
+ }
43
+
44
+ /**
45
+ * Format a duration in milliseconds to a human-readable string.
46
+ * e.g. 147116 → "2m 27s", 45000 → "45s", 3600000 → "1h 0m"
47
+ */
48
+ export function formatDuration(ms: number): string {
49
+ if (ms < 0 || !Number.isFinite(ms)) return '—'
50
+ const totalSec = Math.round(ms / 1000)
51
+ if (totalSec < 60) return `${totalSec}s`
52
+ const mins = Math.floor(totalSec / 60)
53
+ const secs = totalSec % 60
54
+ if (mins < 60) return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
55
+ const hrs = Math.floor(mins / 60)
56
+ const remMins = mins % 60
57
+ return `${hrs}h ${remMins}m`
58
+ }
59
+
60
+ /**
61
+ * Parse a 5-field cron expression into schedule slots for weekly grid display.
62
+ * Returns an array of { hour, minute, days } where days is 0=Sun..6=Sat.
63
+ * Returns null for unparseable expressions.
64
+ */
65
+ export function parseScheduleSlots(expression: string): { hour: number; minute: number; days: number[] } | null {
66
+ if (!expression || !expression.trim()) return null
67
+ const parts = expression.trim().split(/\s+/)
68
+ if (parts.length !== 5) return null
69
+
70
+ const [min, hour, dom, , dow] = parts
71
+ const minNum = parseInt(min, 10)
72
+ const hourNum = parseInt(hour, 10)
73
+ if (isNaN(minNum) || isNaN(hourNum)) return null
74
+
75
+ let days: number[]
76
+
77
+ if (dow === '*') {
78
+ // Every day (or dom-based — treat as daily for weekly view)
79
+ days = [0, 1, 2, 3, 4, 5, 6]
80
+ } else if (dow === '1-5') {
81
+ days = [1, 2, 3, 4, 5]
82
+ } else if (dow === '0-6' || dow === '0,1,2,3,4,5,6') {
83
+ days = [0, 1, 2, 3, 4, 5, 6]
84
+ } else if (dow.includes(',')) {
85
+ days = dow.split(',').map(Number).filter(n => !isNaN(n) && n >= 0 && n <= 6)
86
+ if (days.length === 0) return null
87
+ } else {
88
+ const dowNum = parseInt(dow, 10)
89
+ if (isNaN(dowNum) || dowNum < 0 || dowNum > 6) return null
90
+ days = [dowNum]
91
+ }
92
+
93
+ return { hour: hourNum, minute: minNum, days }
94
+ }
95
+
96
+ /**
97
+ * Convert a 5-field cron expression to a human-readable description.
98
+ * Falls back to the raw expression for anything unparseable.
99
+ */
100
+ export function describeCron(expression: string): string {
101
+ if (!expression || !expression.trim()) return ''
102
+
103
+ const parts = expression.trim().split(/\s+/)
104
+ if (parts.length !== 5) return expression
105
+
106
+ const [min, hour, dom, , dow] = parts
107
+
108
+ // Every minute: * * * * *
109
+ if (min === '*' && hour === '*' && dom === '*' && dow === '*') {
110
+ return 'Every minute'
111
+ }
112
+
113
+ // Every hour: 0 * * * *
114
+ if (min !== '*' && hour === '*' && dom === '*' && dow === '*') {
115
+ return 'Every hour'
116
+ }
117
+
118
+ const hourNum = parseInt(hour, 10)
119
+ const minNum = parseInt(min, 10)
120
+ if (isNaN(hourNum) || isNaN(minNum)) return expression
121
+
122
+ const time = minNum === 0 ? formatTime(hourNum, minNum) : formatTimeWithMinute(hourNum, minNum)
123
+
124
+ // Every N days: 0 12 */2 * *
125
+ if (dom.startsWith('*/') && dow === '*') {
126
+ const interval = parseInt(dom.slice(2), 10)
127
+ if (!isNaN(interval)) {
128
+ return `Every ${interval} days at ${time}`
129
+ }
130
+ }
131
+
132
+ // Monthly: 0 8 1 * *
133
+ if (dom !== '*' && dow === '*') {
134
+ const dayNum = parseInt(dom, 10)
135
+ if (!isNaN(dayNum)) {
136
+ const suffix = dayNum === 1 ? 'st' : dayNum === 2 ? 'nd' : dayNum === 3 ? 'rd' : 'th'
137
+ return `Monthly on the ${dayNum}${suffix} at ${time}`
138
+ }
139
+ }
140
+
141
+ // Weekdays: 0 10 * * 1-5
142
+ if (dom === '*' && dow === '1-5') {
143
+ return `Weekdays at ${time}`
144
+ }
145
+
146
+ // Specific day of week: 0 6 * * 1
147
+ if (dom === '*') {
148
+ const dowNum = parseInt(dow, 10)
149
+ if (!isNaN(dowNum) && dowNum >= 0 && dowNum <= 6) {
150
+ return `${DAY_NAMES[dowNum]} at ${time}`
151
+ }
152
+ }
153
+
154
+ // Daily: 0 8 * * *
155
+ if (dom === '*' && dow === '*') {
156
+ return `Daily at ${time}`
157
+ }
158
+
159
+ return expression
160
+ }