@prmichaelsen/acp-visualizer 0.5.0 → 0.5.3
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/package.json +2 -2
- package/src/components/DependencyGraph.tsx +1 -1
- package/src/lib/file-watcher.ts +6 -1
- package/src/lib/useAutoRefresh.ts +3 -0
- package/src/routes/__root.tsx +57 -12
- package/src/routes/api/watch.ts +23 -17
- package/src/routes/index.tsx +21 -5
- package/src/services/github.service.ts +57 -33
- package/src/services/progress-database.service.ts +1 -1
- package/src/services/projects.service.ts +1 -1
- package/vite.config.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prmichaelsen/acp-visualizer",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browser-based dashboard for visualizing ACP progress.yaml data",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"dev": "vite dev --port 3400 --host",
|
|
17
17
|
"build": "vite build",
|
|
18
18
|
"serve": "vite preview",
|
|
19
|
-
"deploy": "export $(cat .env.cloudflare.local | xargs) && npm run build && wrangler deploy",
|
|
19
|
+
"deploy": "export $(cat .env.cloudflare.local | xargs) && VITE_HOSTED=true npm run build && wrangler deploy",
|
|
20
20
|
"tail": "export $(cat .env.cloudflare.local | xargs) && wrangler tail",
|
|
21
21
|
"test": "vitest",
|
|
22
22
|
"test:run": "vitest run",
|
|
@@ -35,7 +35,7 @@ const statusColors: Record<Status, { bg: string; border: string; text: string }>
|
|
|
35
35
|
|
|
36
36
|
function buildGraph(data: ProgressData): { nodes: GraphNode[]; edges: GraphEdge[]; width: number; height: number } {
|
|
37
37
|
const g = new dagre.graphlib.Graph()
|
|
38
|
-
g.setGraph({ rankdir: '
|
|
38
|
+
g.setGraph({ rankdir: 'TB', nodesep: 30, ranksep: 60, marginx: 20, marginy: 20 })
|
|
39
39
|
g.setDefaultEdgeLabel(() => ({}))
|
|
40
40
|
|
|
41
41
|
// Collect all tasks with their milestone context
|
package/src/lib/file-watcher.ts
CHANGED
|
@@ -15,6 +15,11 @@ export async function getFileWatcher() {
|
|
|
15
15
|
const clients = new Set<Controller>()
|
|
16
16
|
|
|
17
17
|
try {
|
|
18
|
+
// existsSync will throw on Workers where fs is stubbed
|
|
19
|
+
const { existsSync } = await import('fs')
|
|
20
|
+
if (!existsSync(filePath)) {
|
|
21
|
+
throw new Error(`File not found: ${filePath}`)
|
|
22
|
+
}
|
|
18
23
|
watch(filePath, (eventType) => {
|
|
19
24
|
if (eventType === 'change') {
|
|
20
25
|
for (const controller of clients) {
|
|
@@ -27,7 +32,7 @@ export async function getFileWatcher() {
|
|
|
27
32
|
}
|
|
28
33
|
})
|
|
29
34
|
} catch (err) {
|
|
30
|
-
|
|
35
|
+
throw new Error(`[FileWatcher] Cannot watch: ${err}`)
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
watcher = {
|
|
@@ -5,6 +5,9 @@ export function useAutoRefresh() {
|
|
|
5
5
|
const router = useRouter()
|
|
6
6
|
|
|
7
7
|
useEffect(() => {
|
|
8
|
+
// Skip file-watcher SSE in hosted mode — no local filesystem to watch
|
|
9
|
+
if (import.meta.env.VITE_HOSTED) return
|
|
10
|
+
|
|
8
11
|
const eventSource = new EventSource('/api/watch')
|
|
9
12
|
|
|
10
13
|
eventSource.onmessage = () => {
|
package/src/routes/__root.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { HeadContent, Scripts, createRootRoute, Outlet, useRouter } from '@tanstack/react-router'
|
|
2
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { HeadContent, Scripts, createRootRoute, Outlet, useRouter, useRouterState } from '@tanstack/react-router'
|
|
2
|
+
import { useState, useCallback, useEffect } from 'react'
|
|
3
3
|
import { useAutoRefresh } from '../lib/useAutoRefresh'
|
|
4
4
|
import { Sidebar } from '../components/Sidebar'
|
|
5
5
|
import { Header } from '../components/Header'
|
|
@@ -21,17 +21,20 @@ export const Route = createRootRoute({
|
|
|
21
21
|
let progressData: ProgressData | null = null
|
|
22
22
|
let projects: AcpProject[] = []
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
// VITE_HOSTED mode: skip filesystem access entirely (Cloudflare Workers)
|
|
25
|
+
if (!import.meta.env.VITE_HOSTED) {
|
|
26
|
+
try {
|
|
27
|
+
const [result, projectList] = await Promise.all([
|
|
28
|
+
getProgressData({ data: {} }),
|
|
29
|
+
listProjects(),
|
|
30
|
+
])
|
|
31
|
+
if (result.ok) {
|
|
32
|
+
progressData = result.data
|
|
33
|
+
}
|
|
34
|
+
projects = projectList
|
|
35
|
+
} catch {
|
|
36
|
+
// No filesystem available
|
|
31
37
|
}
|
|
32
|
-
projects = projectList
|
|
33
|
-
} catch {
|
|
34
|
-
// Cloudflare Workers or other environment without filesystem
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
return { progressData, projects }
|
|
@@ -74,18 +77,59 @@ function AutoRefresh() {
|
|
|
74
77
|
return null
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
/** Read ?repo=owner/repo from current URL search params */
|
|
81
|
+
function getRepoFromUrl(): { owner: string; repo: string } | null {
|
|
82
|
+
if (typeof window === 'undefined') return null
|
|
83
|
+
const params = new URLSearchParams(window.location.search)
|
|
84
|
+
const repo = params.get('repo')
|
|
85
|
+
if (!repo) return null
|
|
86
|
+
const parts = repo.split('/')
|
|
87
|
+
if (parts.length < 2) return null
|
|
88
|
+
return { owner: parts[0], repo: parts[1] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Update ?repo= search param without full navigation */
|
|
92
|
+
function setRepoInUrl(ownerRepo: string | null) {
|
|
93
|
+
if (typeof window === 'undefined') return
|
|
94
|
+
const url = new URL(window.location.href)
|
|
95
|
+
if (ownerRepo) {
|
|
96
|
+
url.searchParams.set('repo', ownerRepo)
|
|
97
|
+
} else {
|
|
98
|
+
url.searchParams.delete('repo')
|
|
99
|
+
}
|
|
100
|
+
window.history.replaceState({}, '', url.toString())
|
|
101
|
+
}
|
|
102
|
+
|
|
77
103
|
function RootLayout() {
|
|
78
104
|
const context = Route.useRouteContext()
|
|
79
105
|
const [progressData, setProgressData] = useState(context.progressData)
|
|
80
106
|
const [currentProject, setCurrentProject] = useState<string | null>(
|
|
81
107
|
context.progressData?.project.name || null,
|
|
82
108
|
)
|
|
109
|
+
const [initialLoadDone, setInitialLoadDone] = useState(false)
|
|
110
|
+
|
|
111
|
+
// On mount, check for ?repo= param and auto-load
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (initialLoadDone) return
|
|
114
|
+
setInitialLoadDone(true)
|
|
115
|
+
|
|
116
|
+
const repoParam = getRepoFromUrl()
|
|
117
|
+
if (repoParam && !progressData) {
|
|
118
|
+
fetchGitHubProgress({ data: repoParam }).then((result) => {
|
|
119
|
+
if (result.ok) {
|
|
120
|
+
setProgressData(result.data)
|
|
121
|
+
setCurrentProject(`${repoParam.owner}/${repoParam.repo}`)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}, [initialLoadDone, progressData])
|
|
83
126
|
|
|
84
127
|
const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
|
|
85
128
|
const result = await fetchGitHubProgress({ data: { owner, repo } })
|
|
86
129
|
if (result.ok) {
|
|
87
130
|
setProgressData(result.data)
|
|
88
131
|
setCurrentProject(`${owner}/${repo}`)
|
|
132
|
+
setRepoInUrl(`${owner}/${repo}`)
|
|
89
133
|
} else {
|
|
90
134
|
throw new Error(result.message)
|
|
91
135
|
}
|
|
@@ -99,6 +143,7 @@ function RootLayout() {
|
|
|
99
143
|
if (result.ok) {
|
|
100
144
|
setProgressData(result.data)
|
|
101
145
|
setCurrentProject(projectId)
|
|
146
|
+
setRepoInUrl(null) // Clear repo param for local projects
|
|
102
147
|
}
|
|
103
148
|
}
|
|
104
149
|
} catch {
|
package/src/routes/api/watch.ts
CHANGED
|
@@ -1,28 +1,34 @@
|
|
|
1
1
|
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
-
import { getFileWatcher } from '../../lib/file-watcher'
|
|
3
2
|
|
|
4
3
|
export const Route = createFileRoute('/api/watch')({
|
|
5
4
|
server: {
|
|
6
5
|
handlers: {
|
|
7
6
|
GET: async () => {
|
|
8
|
-
|
|
7
|
+
// In hosted mode (Cloudflare Workers), there's no filesystem to watch.
|
|
8
|
+
// Return 204 immediately instead of opening a stream that hangs forever.
|
|
9
|
+
try {
|
|
10
|
+
const { getFileWatcher } = await import('../../lib/file-watcher')
|
|
11
|
+
const watcher = await getFileWatcher()
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const stream = new ReadableStream({
|
|
14
|
+
start(controller) {
|
|
15
|
+
watcher.addClient(controller)
|
|
16
|
+
},
|
|
17
|
+
cancel(controller) {
|
|
18
|
+
watcher.removeClient(controller)
|
|
19
|
+
},
|
|
20
|
+
})
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
return new Response(stream, {
|
|
23
|
+
headers: {
|
|
24
|
+
'Content-Type': 'text/event-stream',
|
|
25
|
+
'Cache-Control': 'no-cache',
|
|
26
|
+
'Connection': 'keep-alive',
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
} catch {
|
|
30
|
+
return new Response(null, { status: 204 })
|
|
31
|
+
}
|
|
26
32
|
},
|
|
27
33
|
},
|
|
28
34
|
},
|
package/src/routes/index.tsx
CHANGED
|
@@ -12,13 +12,29 @@ function HomePage() {
|
|
|
12
12
|
const progressData = useProgressData()
|
|
13
13
|
|
|
14
14
|
if (!progressData) {
|
|
15
|
+
const isHosted = import.meta.env.VITE_HOSTED
|
|
16
|
+
|
|
15
17
|
return (
|
|
16
18
|
<div className="p-6">
|
|
17
|
-
<div className="p-
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
<div className="p-5 bg-gray-900/50 border border-gray-800 rounded-xl text-center max-w-md mx-auto mt-12">
|
|
20
|
+
<h2 className="text-lg font-semibold mb-2">ACP Progress Visualizer</h2>
|
|
21
|
+
{isHosted ? (
|
|
22
|
+
<>
|
|
23
|
+
<p className="text-gray-400 text-sm mb-4">
|
|
24
|
+
Enter a GitHub repository in the sidebar to visualize its ACP progress.
|
|
25
|
+
</p>
|
|
26
|
+
<div className="text-xs text-gray-600 space-y-1">
|
|
27
|
+
<p>Example: <span className="font-mono text-gray-400">prmichaelsen/agent-context-protocol</span></p>
|
|
28
|
+
<p>The repo must have <span className="font-mono text-gray-400">agent/progress.yaml</span></p>
|
|
29
|
+
</div>
|
|
30
|
+
</>
|
|
31
|
+
) : (
|
|
32
|
+
<>
|
|
33
|
+
<p className="text-red-400/70 text-sm">
|
|
34
|
+
No progress.yaml found. Make sure you're in an ACP project directory.
|
|
35
|
+
</p>
|
|
36
|
+
</>
|
|
37
|
+
)}
|
|
22
38
|
</div>
|
|
23
39
|
</div>
|
|
24
40
|
)
|
|
@@ -5,47 +5,71 @@ export type GitHubResult =
|
|
|
5
5
|
| { ok: true; data: ProgressData }
|
|
6
6
|
| { ok: false; error: string; message: string }
|
|
7
7
|
|
|
8
|
+
async function fetchBranch(
|
|
9
|
+
parseProgressYaml: (raw: string) => ProgressData,
|
|
10
|
+
owner: string,
|
|
11
|
+
repo: string,
|
|
12
|
+
branch: string,
|
|
13
|
+
headers: Record<string, string>,
|
|
14
|
+
): Promise<GitHubResult> {
|
|
15
|
+
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/agent/progress.yaml`
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(url, { headers })
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
if (response.status === 404) {
|
|
20
|
+
return { ok: false, error: 'NOT_FOUND', message: `No progress.yaml found at ${owner}/${repo} (branch: ${branch})` }
|
|
21
|
+
}
|
|
22
|
+
return { ok: false, error: 'FETCH_ERROR', message: `GitHub returned ${response.status}: ${response.statusText}` }
|
|
23
|
+
}
|
|
24
|
+
const raw = await response.text()
|
|
25
|
+
const data = parseProgressYaml(raw)
|
|
26
|
+
return { ok: true, data }
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return { ok: false, error: 'NETWORK_ERROR', message: err instanceof Error ? err.message : 'Failed to fetch from GitHub' }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export const fetchGitHubProgress = createServerFn({ method: 'GET' })
|
|
9
|
-
.
|
|
33
|
+
.inputValidator((input: { owner: string; repo: string; branch?: string; token?: string }) => input)
|
|
10
34
|
.handler(async ({ data: input }): Promise<GitHubResult> => {
|
|
11
35
|
const { parseProgressYaml } = await import('../lib/yaml-loader')
|
|
12
36
|
|
|
13
|
-
const
|
|
14
|
-
|
|
37
|
+
const fetchHeaders: Record<string, string> = { 'Accept': 'text/plain' }
|
|
38
|
+
if (input.token) {
|
|
39
|
+
fetchHeaders['Authorization'] = `token ${input.token}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If branch specified, use it directly
|
|
43
|
+
if (input.branch) {
|
|
44
|
+
return fetchBranch(parseProgressYaml, input.owner, input.repo, input.branch, fetchHeaders)
|
|
45
|
+
}
|
|
15
46
|
|
|
47
|
+
// Try GitHub API for default branch, fall back to trying main → mainline → master
|
|
48
|
+
let detectedBranch: string | null = null
|
|
16
49
|
try {
|
|
17
|
-
const
|
|
18
|
-
'
|
|
19
|
-
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
50
|
+
const metaRes = await fetch(`https://api.github.com/repos/${input.owner}/${input.repo}`, {
|
|
51
|
+
headers: { 'User-Agent': 'acp-visualizer', ...(input.token ? { Authorization: `token ${input.token}` } : {}) },
|
|
52
|
+
})
|
|
53
|
+
if (metaRes.ok) {
|
|
54
|
+
const meta = await metaRes.json() as { default_branch?: string }
|
|
55
|
+
detectedBranch = meta.default_branch || null
|
|
22
56
|
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Rate limited or network error — fall through to branch probing
|
|
59
|
+
}
|
|
23
60
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
}
|
|
61
|
+
const branches = detectedBranch
|
|
62
|
+
? [detectedBranch]
|
|
63
|
+
: ['main', 'mainline', 'master']
|
|
40
64
|
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
65
|
+
for (const branch of branches) {
|
|
66
|
+
const result = await fetchBranch(parseProgressYaml, input.owner, input.repo, branch, fetchHeaders)
|
|
67
|
+
if (result.ok || result.error !== 'NOT_FOUND') return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: 'NOT_FOUND',
|
|
73
|
+
message: `No progress.yaml found at ${input.owner}/${input.repo} (tried branches: ${branches.join(', ')})`,
|
|
50
74
|
}
|
|
51
75
|
})
|
|
@@ -6,7 +6,7 @@ export type ProgressResult =
|
|
|
6
6
|
| { ok: false; error: 'FILE_NOT_FOUND' | 'PARSE_ERROR' | 'NO_FILESYSTEM'; message: string; path: string }
|
|
7
7
|
|
|
8
8
|
export const getProgressData = createServerFn({ method: 'GET' })
|
|
9
|
-
.
|
|
9
|
+
.inputValidator((input: { path?: string }) => input)
|
|
10
10
|
.handler(async ({ data: input }): Promise<ProgressResult> => {
|
|
11
11
|
try {
|
|
12
12
|
const fs = await import('fs')
|
|
@@ -44,7 +44,7 @@ export const listProjects = createServerFn({ method: 'GET' }).handler(
|
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
export const getProjectProgressPath = createServerFn({ method: 'GET' })
|
|
47
|
-
.
|
|
47
|
+
.inputValidator((input: { projectId: string }) => input)
|
|
48
48
|
.handler(async ({ data }): Promise<string | null> => {
|
|
49
49
|
try {
|
|
50
50
|
const { readFileSync, existsSync } = await import('fs')
|
package/vite.config.ts
CHANGED
|
@@ -7,10 +7,10 @@ import { cloudflare } from '@cloudflare/vite-plugin'
|
|
|
7
7
|
|
|
8
8
|
export default defineConfig(({ command }) => ({
|
|
9
9
|
plugins: [
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Only use Cloudflare plugin for production builds — local dev uses Node.js SSR
|
|
11
|
+
...(command === 'build'
|
|
12
|
+
? [cloudflare({ viteEnvironment: { name: 'ssr' } })]
|
|
13
|
+
: []),
|
|
14
14
|
viteTsConfigPaths({
|
|
15
15
|
projects: ['./tsconfig.json'],
|
|
16
16
|
}),
|