@prmichaelsen/acp-visualizer 0.2.1 → 0.5.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/src/lib/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Status values for milestones and tasks */
2
- export type Status = 'completed' | 'in_progress' | 'not_started'
2
+ export type Status = 'completed' | 'in_progress' | 'not_started' | 'wont_do'
3
3
 
4
4
  /** Unknown properties from agent-maintained YAML are preserved here */
5
5
  export type ExtraFields = Record<string, unknown>
@@ -37,11 +37,64 @@ describe('parseProgressYaml with real files', () => {
37
37
  expect(totalTasks).toBeGreaterThan(0)
38
38
 
39
39
  for (const m of result.milestones) {
40
- expect(['completed', 'in_progress', 'not_started']).toContain(m.status)
40
+ expect(['completed', 'in_progress', 'not_started', 'wont_do']).toContain(m.status)
41
41
  }
42
42
 
43
43
  console.log(
44
44
  `Parsed: ${result.milestones.length} milestones, ${totalTasks} tasks, ${result.recent_work.length} work entries`,
45
45
  )
46
46
  })
47
+
48
+ it('parses agentbase.me progress.yaml with inline milestones and standalone tasks', () => {
49
+ const path = '/home/prmichaelsen/.acp/projects/agentbase.me/agent/progress.yaml'
50
+ let raw: string
51
+ try {
52
+ raw = readFileSync(path, 'utf-8')
53
+ } catch {
54
+ console.log('Skipping — agentbase.me progress.yaml not found')
55
+ return
56
+ }
57
+
58
+ const result = parseProgressYaml(raw)
59
+
60
+ // Should find project metadata
61
+ expect(result.project.name).toBe('agentbase.me')
62
+
63
+ // Should find inline milestones (milestone_1_firebase_analytics, etc.)
64
+ expect(result.milestones.length).toBeGreaterThan(0)
65
+ const milestoneIds = result.milestones.map((m) => m.id)
66
+ expect(milestoneIds).toContain('M1') // from milestone_1_firebase_analytics
67
+ expect(milestoneIds).toContain('M47') // from milestone_47_agent_memory_system
68
+
69
+ // Should find standard milestones too
70
+ expect(milestoneIds).toContain('M19') // from milestones: array
71
+ expect(milestoneIds).toContain('M21')
72
+
73
+ // Should find tasks from inline milestones
74
+ const m1Tasks = result.tasks['M1'] || []
75
+ expect(m1Tasks.length).toBeGreaterThan(0)
76
+ // Task 79 has wont_do status
77
+ const wontDoTask = m1Tasks.find((t) => String(t.id) === '79')
78
+ if (wontDoTask) {
79
+ expect(wontDoTask.status).toBe('wont_do')
80
+ }
81
+
82
+ // Should find standalone/unassigned tasks
83
+ const unassigned = result.tasks['_unassigned'] || []
84
+ expect(unassigned.length).toBeGreaterThan(0)
85
+ // Synthetic milestone should exist
86
+ expect(milestoneIds).toContain('_unassigned')
87
+
88
+ const totalTasks = Object.values(result.tasks).reduce(
89
+ (sum, ts) => sum + ts.length,
90
+ 0,
91
+ )
92
+
93
+ // Should collect next_steps from next_immediate_steps too
94
+ expect(result.next_steps.length).toBeGreaterThan(0)
95
+
96
+ console.log(
97
+ `Parsed agentbase.me: ${result.milestones.length} milestones, ${totalTasks} tasks, ${unassigned.length} unassigned`,
98
+ )
99
+ })
47
100
  })
@@ -47,6 +47,7 @@ function normalizeStatus(value: unknown): Status {
47
47
  .replace(/[\s-]/g, '_')
48
48
  if (s === 'completed' || s === 'done' || s === 'complete') return 'completed'
49
49
  if (s === 'in_progress' || s === 'active' || s === 'wip' || s === 'started') return 'in_progress'
50
+ if (s === 'wont_do' || s === 'won_t_do' || s === 'skipped' || s === 'cancelled' || s === 'canceled') return 'wont_do'
50
51
  return 'not_started'
51
52
  }
52
53
 
@@ -56,7 +57,9 @@ function safeString(value: unknown, fallback = ''): string {
56
57
  }
57
58
 
58
59
  function safeNumber(value: unknown, fallback = 0): number {
59
- const n = Number(value)
60
+ // Strip trailing '%' (e.g. "100%" → "100") before parsing
61
+ const cleaned = typeof value === 'string' ? value.replace(/%$/, '') : value
62
+ const n = Number(cleaned)
60
63
  return Number.isFinite(n) ? n : fallback
61
64
  }
62
65
 
@@ -267,6 +270,84 @@ const EMPTY_PROGRESS_DATA: ProgressData = {
267
270
  progress: { planning: 0, implementation: 0, overall: 0 },
268
271
  }
269
272
 
273
+ // --- Inline milestone extraction ---
274
+ // Some progress.yaml files use top-level keys like `milestone_1_firebase_analytics:`
275
+ // containing milestone metadata + inline `tasks:` arrays.
276
+
277
+ const INLINE_MILESTONE_PATTERN = /^milestone_(\d+)_/
278
+
279
+ function extractInlineMilestones(d: Record<string, unknown>): {
280
+ milestones: Milestone[]
281
+ tasks: Record<string, Task[]>
282
+ } {
283
+ const milestones: Milestone[] = []
284
+ const tasks: Record<string, Task[]> = {}
285
+
286
+ for (const [key, value] of Object.entries(d)) {
287
+ const match = key.match(INLINE_MILESTONE_PATTERN)
288
+ if (!match) continue
289
+ const obj = asRecord(value)
290
+ // Extract milestone ID from the name field (e.g. "M1 - ...") or synthesize from key
291
+ const nameStr = safeString(obj.name)
292
+ const idFromName = nameStr.match(/^(M\d+)\b/)?.[1]
293
+ const milestoneId = idFromName || `M${match[1]}`
294
+
295
+ const milestone = normalizeMilestone(
296
+ { ...obj, id: milestoneId },
297
+ milestones.length,
298
+ )
299
+ milestones.push(milestone)
300
+
301
+ // Extract inline tasks if present
302
+ if (Array.isArray(obj.tasks)) {
303
+ tasks[milestoneId] = obj.tasks.map((t, i) =>
304
+ normalizeTask(t, milestoneId, i),
305
+ )
306
+ }
307
+ }
308
+
309
+ return { milestones, tasks }
310
+ }
311
+
312
+ // --- Standalone / unassigned task extraction ---
313
+
314
+ function extractLooseTasks(
315
+ d: Record<string, unknown>,
316
+ keys: string[],
317
+ ): Task[] {
318
+ const result: Task[] = []
319
+ for (const key of keys) {
320
+ const arr = d[key]
321
+ if (Array.isArray(arr)) {
322
+ result.push(
323
+ ...arr.map((t, i) => normalizeTask(t, '_unassigned', result.length + i)),
324
+ )
325
+ }
326
+ }
327
+ return result
328
+ }
329
+
330
+ // --- next_steps from multiple sources ---
331
+
332
+ function collectNextSteps(d: Record<string, unknown>): string[] {
333
+ const steps = normalizeStringArray(d.next_steps)
334
+ // next_immediate_steps may be a structured object with sub-keys or an array
335
+ const immediate = d.next_immediate_steps
336
+ if (immediate) {
337
+ if (Array.isArray(immediate)) {
338
+ steps.push(...immediate.map(String))
339
+ } else if (typeof immediate === 'object' && immediate !== null) {
340
+ // Structured object like { ready_to_implement: [...], security: [...] }
341
+ for (const arr of Object.values(immediate)) {
342
+ if (Array.isArray(arr)) {
343
+ steps.push(...arr.map(String))
344
+ }
345
+ }
346
+ }
347
+ }
348
+ return steps
349
+ }
350
+
270
351
  export function parseProgressYaml(raw: string): ProgressData {
271
352
  try {
272
353
  // json: true allows duplicated keys (last wins) — common in agent-maintained YAML
@@ -276,14 +357,62 @@ export function parseProgressYaml(raw: string): ProgressData {
276
357
  }
277
358
  const d = doc as Record<string, unknown>
278
359
 
279
- const milestones = normalizeMilestones(d.milestones)
360
+ // Standard milestones array
361
+ const standardMilestones = normalizeMilestones(d.milestones)
362
+
363
+ // Inline milestone_N_* top-level keys
364
+ const inline = extractInlineMilestones(d)
365
+
366
+ // Merge: inline milestones first (they tend to be older/lower-numbered),
367
+ // then standard milestones. Deduplicate by ID (standard wins).
368
+ const seenIds = new Set<string>()
369
+ const allMilestones: Milestone[] = []
370
+ for (const m of [...inline.milestones, ...standardMilestones]) {
371
+ if (!seenIds.has(m.id)) {
372
+ seenIds.add(m.id)
373
+ allMilestones.push(m)
374
+ }
375
+ }
376
+
377
+ // Standard tasks (from top-level `tasks:` key — last-wins with json: true)
378
+ const standardTasks = normalizeTasks(d.tasks, allMilestones)
379
+
380
+ // Merge inline tasks with standard tasks (standard wins on conflict)
381
+ const allTasks: Record<string, Task[]> = { ...inline.tasks }
382
+ for (const [key, tasks] of Object.entries(standardTasks)) {
383
+ allTasks[key] = tasks // standard overwrites inline for same key
384
+ }
385
+
386
+ // Standalone and unassigned tasks
387
+ const looseTasks = extractLooseTasks(d, ['standalone_tasks', 'unassigned_tasks'])
388
+ if (looseTasks.length > 0) {
389
+ const existing = allTasks['_unassigned'] || []
390
+ allTasks['_unassigned'] = [...existing, ...looseTasks]
391
+ // Add a synthetic milestone for unassigned tasks if not already present
392
+ if (!seenIds.has('_unassigned')) {
393
+ seenIds.add('_unassigned')
394
+ allMilestones.push({
395
+ id: '_unassigned',
396
+ name: 'Unassigned Tasks',
397
+ status: 'in_progress',
398
+ progress: 0,
399
+ started: null,
400
+ completed: null,
401
+ estimated_weeks: '0',
402
+ tasks_completed: looseTasks.filter((t) => t.status === 'completed').length,
403
+ tasks_total: looseTasks.length,
404
+ notes: 'Tasks not assigned to a specific milestone',
405
+ extra: { synthetic: true },
406
+ })
407
+ }
408
+ }
280
409
 
281
410
  return {
282
411
  project: normalizeProject(d.project),
283
- milestones,
284
- tasks: normalizeTasks(d.tasks, milestones),
412
+ milestones: allMilestones,
413
+ tasks: allTasks,
285
414
  recent_work: normalizeWorkEntries(d.recent_work),
286
- next_steps: normalizeStringArray(d.next_steps),
415
+ next_steps: collectNextSteps(d),
287
416
  notes: normalizeStringArray(d.notes),
288
417
  current_blockers: normalizeStringArray(d.current_blockers),
289
418
  documentation: normalizeDocStats(d.documentation),
@@ -1,26 +1,40 @@
1
- import { HeadContent, Scripts, createRootRoute, Outlet } from '@tanstack/react-router'
1
+ import { HeadContent, Scripts, createRootRoute, Outlet, useRouter } from '@tanstack/react-router'
2
+ import { useState, useCallback } from 'react'
2
3
  import { useAutoRefresh } from '../lib/useAutoRefresh'
3
4
  import { Sidebar } from '../components/Sidebar'
4
5
  import { Header } from '../components/Header'
5
6
  import { getProgressData } from '../services/progress-database.service'
7
+ import { listProjects, getProjectProgressPath } from '../services/projects.service'
8
+ import { fetchGitHubProgress } from '../services/github.service'
6
9
  import type { ProgressData } from '../lib/types'
10
+ import type { AcpProject } from '../services/projects.service'
11
+ import { ProgressProvider } from '../contexts/ProgressContext'
7
12
 
8
13
  import appCss from '../styles.css?url'
9
14
 
10
15
  export const Route = createRootRoute({
16
+ component: RootLayout,
17
+ notFoundComponent: NotFound,
18
+ shellComponent: RootDocument,
19
+
11
20
  beforeLoad: async () => {
12
21
  let progressData: ProgressData | null = null
22
+ let projects: AcpProject[] = []
13
23
 
14
24
  try {
15
- const result = await getProgressData()
25
+ const [result, projectList] = await Promise.all([
26
+ getProgressData({ data: {} }),
27
+ listProjects(),
28
+ ])
16
29
  if (result.ok) {
17
30
  progressData = result.data
18
31
  }
19
- } catch (error) {
20
- console.error('[Root] Failed to load progress data:', error)
32
+ projects = projectList
33
+ } catch {
34
+ // Cloudflare Workers or other environment without filesystem
21
35
  }
22
36
 
23
- return { progressData }
37
+ return { progressData, projects }
24
38
  },
25
39
 
26
40
  head: () => ({
@@ -40,34 +54,89 @@ export const Route = createRootRoute({
40
54
  { rel: 'stylesheet', href: appCss },
41
55
  ],
42
56
  }),
43
-
44
- shellComponent: RootDocument,
45
57
  })
46
58
 
59
+ function NotFound() {
60
+ return (
61
+ <div className="flex items-center justify-center h-full">
62
+ <div className="text-center">
63
+ <h2 className="text-xl font-semibold text-gray-200 mb-2">Page Not Found</h2>
64
+ <p className="text-sm text-gray-400">
65
+ The page you're looking for doesn't exist.
66
+ </p>
67
+ </div>
68
+ </div>
69
+ )
70
+ }
71
+
47
72
  function AutoRefresh() {
48
73
  useAutoRefresh()
49
74
  return null
50
75
  }
51
76
 
52
- function RootDocument({ children }: { children: React.ReactNode }) {
53
- const { progressData } = Route.useRouteContext()
77
+ function RootLayout() {
78
+ const context = Route.useRouteContext()
79
+ const [progressData, setProgressData] = useState(context.progressData)
80
+ const [currentProject, setCurrentProject] = useState<string | null>(
81
+ context.progressData?.project.name || null,
82
+ )
83
+
84
+ const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
85
+ const result = await fetchGitHubProgress({ data: { owner, repo } })
86
+ if (result.ok) {
87
+ setProgressData(result.data)
88
+ setCurrentProject(`${owner}/${repo}`)
89
+ } else {
90
+ throw new Error(result.message)
91
+ }
92
+ }, [])
93
+
94
+ const handleProjectSwitch = useCallback(async (projectId: string) => {
95
+ try {
96
+ const path = await getProjectProgressPath({ data: { projectId } })
97
+ if (path) {
98
+ const result = await getProgressData({ data: { path } })
99
+ if (result.ok) {
100
+ setProgressData(result.data)
101
+ setCurrentProject(projectId)
102
+ }
103
+ }
104
+ } catch {
105
+ // Project switch failed — likely no filesystem access
106
+ }
107
+ }, [])
54
108
 
55
109
  return (
56
- <html lang="en">
57
- <head>
58
- <HeadContent />
59
- </head>
60
- <body>
61
- <AutoRefresh />
62
- <div className="flex h-screen bg-gray-950 text-gray-100">
63
- <Sidebar />
110
+ <>
111
+ <AutoRefresh />
112
+ <div className="flex h-screen bg-gray-950 text-gray-100">
113
+ <Sidebar
114
+ projects={context.projects}
115
+ currentProject={currentProject}
116
+ onProjectSelect={handleProjectSwitch}
117
+ onGitHubLoad={handleGitHubLoad}
118
+ />
119
+ <ProgressProvider data={progressData}>
64
120
  <div className="flex-1 flex flex-col overflow-hidden">
65
121
  <Header data={progressData} />
66
122
  <main className="flex-1 overflow-auto">
67
- {children}
123
+ <Outlet />
68
124
  </main>
69
125
  </div>
70
- </div>
126
+ </ProgressProvider>
127
+ </div>
128
+ </>
129
+ )
130
+ }
131
+
132
+ function RootDocument({ children }: { children: React.ReactNode }) {
133
+ return (
134
+ <html lang="en">
135
+ <head>
136
+ <HeadContent />
137
+ </head>
138
+ <body>
139
+ {children}
71
140
  <Scripts />
72
141
  </body>
73
142
  </html>
@@ -1,12 +1,12 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
- import { Clock } from 'lucide-react'
2
+ import { useProgressData } from '../contexts/ProgressContext'
3
3
 
4
4
  export const Route = createFileRoute('/activity')({
5
5
  component: ActivityPage,
6
6
  })
7
7
 
8
8
  function ActivityPage() {
9
- const { progressData } = Route.useRouteContext()
9
+ const progressData = useProgressData()
10
10
 
11
11
  if (!progressData) {
12
12
  return (
@@ -2,13 +2,14 @@ import { createFileRoute } from '@tanstack/react-router'
2
2
  import { StatusBadge } from '../components/StatusBadge'
3
3
  import { ProgressBar } from '../components/ProgressBar'
4
4
  import { BurndownChart } from '../components/BurndownChart'
5
+ import { useProgressData } from '../contexts/ProgressContext'
5
6
 
6
7
  export const Route = createFileRoute('/')({
7
8
  component: HomePage,
8
9
  })
9
10
 
10
11
  function HomePage() {
11
- const { progressData } = Route.useRouteContext()
12
+ const progressData = useProgressData()
12
13
 
13
14
  if (!progressData) {
14
15
  return (
@@ -1,20 +1,26 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
- import { useState } from 'react'
2
+ import { useState, lazy, Suspense } from 'react'
3
3
  import { MilestoneTable } from '../components/MilestoneTable'
4
4
  import { MilestoneTree } from '../components/MilestoneTree'
5
5
  import { MilestoneKanban } from '../components/MilestoneKanban'
6
+ import { MilestoneGantt } from '../components/MilestoneGantt'
6
7
  import { ViewToggle, type ViewMode } from '../components/ViewToggle'
7
8
  import { FilterBar } from '../components/FilterBar'
8
9
  import { SearchInput } from '../components/SearchInput'
9
10
  import { useFilteredData } from '../lib/useFilteredData'
11
+ import { useProgressData } from '../contexts/ProgressContext'
10
12
  import type { Status } from '../lib/types'
11
13
 
14
+ // Lazy-load DependencyGraph to keep dagre out of the SSR bundle
15
+ // (dagre uses CommonJS require() which fails on Cloudflare Workers)
16
+ const DependencyGraph = lazy(() => import('../components/DependencyGraph').then(m => ({ default: m.DependencyGraph })))
17
+
12
18
  export const Route = createFileRoute('/milestones')({
13
19
  component: MilestonesPage,
14
20
  })
15
21
 
16
22
  function MilestonesPage() {
17
- const { progressData } = Route.useRouteContext()
23
+ const progressData = useProgressData()
18
24
  const [view, setView] = useState<ViewMode>('table')
19
25
  const [status, setStatus] = useState<Status | 'all'>('all')
20
26
  const [search, setSearch] = useState('')
@@ -47,8 +53,14 @@ function MilestonesPage() {
47
53
  <MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
48
54
  ) : view === 'tree' ? (
49
55
  <MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
50
- ) : (
56
+ ) : view === 'kanban' ? (
51
57
  <MilestoneKanban milestones={filtered.milestones} tasks={filtered.tasks} />
58
+ ) : view === 'gantt' ? (
59
+ <MilestoneGantt milestones={filtered.milestones} tasks={filtered.tasks} />
60
+ ) : (
61
+ <Suspense fallback={<p className="text-gray-500 text-sm">Loading graph...</p>}>
62
+ <DependencyGraph data={filtered} />
63
+ </Suspense>
52
64
  )}
53
65
  </div>
54
66
  )
@@ -4,13 +4,14 @@ import { SearchInput } from '../components/SearchInput'
4
4
  import { StatusBadge } from '../components/StatusBadge'
5
5
  import { StatusDot } from '../components/StatusDot'
6
6
  import { buildSearchIndex } from '../lib/search'
7
+ import { useProgressData } from '../contexts/ProgressContext'
7
8
 
8
9
  export const Route = createFileRoute('/search')({
9
10
  component: SearchPage,
10
11
  })
11
12
 
12
13
  function SearchPage() {
13
- const { progressData } = Route.useRouteContext()
14
+ const progressData = useProgressData()
14
15
  const [query, setQuery] = useState('')
15
16
 
16
17
  const results = useMemo(() => {
@@ -1,6 +1,7 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
  import { StatusDot } from '../components/StatusDot'
3
3
  import { ExtraFieldsBadge } from '../components/ExtraFieldsBadge'
4
+ import { useProgressData } from '../contexts/ProgressContext'
4
5
  import type { Task } from '../lib/types'
5
6
 
6
7
  export const Route = createFileRoute('/tasks')({
@@ -8,7 +9,7 @@ export const Route = createFileRoute('/tasks')({
8
9
  })
9
10
 
10
11
  function TasksPage() {
11
- const { progressData } = Route.useRouteContext()
12
+ const progressData = useProgressData()
12
13
 
13
14
  if (!progressData) {
14
15
  return (
package/src/server.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Cloudflare Workers Entry Point
3
+ *
4
+ * Minimal server — passes all requests to TanStack Start.
5
+ */
6
+
7
+ import startServer from '@tanstack/react-start/server-entry'
8
+
9
+ export default {
10
+ async fetch(request: Request, env: unknown, ctx: unknown) {
11
+ return (startServer as any).fetch(request, env, ctx)
12
+ },
13
+ }
@@ -0,0 +1,51 @@
1
+ import { createServerFn } from '@tanstack/react-start'
2
+ import type { ProgressData } from '../lib/types'
3
+
4
+ export type GitHubResult =
5
+ | { ok: true; data: ProgressData }
6
+ | { ok: false; error: string; message: string }
7
+
8
+ export const fetchGitHubProgress = createServerFn({ method: 'GET' })
9
+ .validator((input: { owner: string; repo: string; branch?: string; token?: string }) => input)
10
+ .handler(async ({ data: input }): Promise<GitHubResult> => {
11
+ const { parseProgressYaml } = await import('../lib/yaml-loader')
12
+
13
+ const branch = input.branch || 'main'
14
+ const url = `https://raw.githubusercontent.com/${input.owner}/${input.repo}/${branch}/agent/progress.yaml`
15
+
16
+ try {
17
+ const headers: Record<string, string> = {
18
+ 'Accept': 'text/plain',
19
+ }
20
+ if (input.token) {
21
+ headers['Authorization'] = `token ${input.token}`
22
+ }
23
+
24
+ const response = await fetch(url, { headers })
25
+
26
+ if (!response.ok) {
27
+ if (response.status === 404) {
28
+ return {
29
+ ok: false,
30
+ error: 'NOT_FOUND',
31
+ message: `No progress.yaml found at ${input.owner}/${input.repo} (branch: ${branch})`,
32
+ }
33
+ }
34
+ return {
35
+ ok: false,
36
+ error: 'FETCH_ERROR',
37
+ message: `GitHub returned ${response.status}: ${response.statusText}`,
38
+ }
39
+ }
40
+
41
+ const raw = await response.text()
42
+ const data = parseProgressYaml(raw)
43
+ return { ok: true, data }
44
+ } catch (err) {
45
+ return {
46
+ ok: false,
47
+ error: 'NETWORK_ERROR',
48
+ message: err instanceof Error ? err.message : 'Failed to fetch from GitHub',
49
+ }
50
+ }
51
+ })
@@ -3,47 +3,25 @@ import type { ProgressData } from '../lib/types'
3
3
 
4
4
  export type ProgressResult =
5
5
  | { ok: true; data: ProgressData }
6
- | { ok: false; error: 'FILE_NOT_FOUND' | 'PARSE_ERROR'; message: string; path: string }
7
-
8
- export const getProgressData = createServerFn({ method: 'GET' }).handler(
9
- async (): Promise<ProgressResult> => {
10
- // Dynamic imports keep fs and yaml-loader out of the client bundle
11
- const { readFileSync } = await import('fs')
12
- const { parseProgressYaml } = await import('../lib/yaml-loader')
13
- const { getProgressYamlPath } = await import('../lib/config')
14
-
15
- const filePath = getProgressYamlPath()
6
+ | { ok: false; error: 'FILE_NOT_FOUND' | 'PARSE_ERROR' | 'NO_FILESYSTEM'; message: string; path: string }
16
7
 
8
+ export const getProgressData = createServerFn({ method: 'GET' })
9
+ .validator((input: { path?: string }) => input)
10
+ .handler(async ({ data: input }): Promise<ProgressResult> => {
17
11
  try {
18
- const raw = readFileSync(filePath, 'utf-8')
12
+ const fs = await import('fs')
13
+ const { parseProgressYaml } = await import('../lib/yaml-loader')
14
+ const { getProgressYamlPath } = await import('../lib/config')
19
15
 
20
- try {
21
- const data = parseProgressYaml(raw)
22
- return { ok: true, data }
23
- } catch (err) {
24
- return {
25
- ok: false,
26
- error: 'PARSE_ERROR',
27
- message: err instanceof Error ? err.message : 'Failed to parse YAML',
28
- path: filePath,
29
- }
30
- }
31
- } catch (err) {
32
- const code = (err as NodeJS.ErrnoException).code
33
- if (code === 'ENOENT') {
34
- return {
35
- ok: false,
36
- error: 'FILE_NOT_FOUND',
37
- message: `progress.yaml not found at: ${filePath}`,
38
- path: filePath,
39
- }
40
- }
41
- return {
42
- ok: false,
43
- error: 'PARSE_ERROR',
44
- message: err instanceof Error ? err.message : 'Failed to read file',
45
- path: filePath,
16
+ const filePath = input.path || getProgressYamlPath()
17
+ const raw = fs.readFileSync(filePath, 'utf-8')
18
+ const data = parseProgressYaml(raw)
19
+ return { ok: true, data }
20
+ } catch (err: any) {
21
+ if (err?.code === 'ENOENT') {
22
+ return { ok: false, error: 'FILE_NOT_FOUND', message: `progress.yaml not found`, path: input.path || '' }
46
23
  }
24
+ // Cloudflare Workers: fs module exists but readFileSync throws
25
+ return { ok: false, error: 'NO_FILESYSTEM', message: 'No local filesystem — use GitHub input to load a project', path: '' }
47
26
  }
48
- },
49
- )
27
+ })