create-claude-pipeline 0.2.0 → 0.3.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/package.json +1 -1
- package/template/.claude-pipeline/dashboard/package.json +1 -3
- package/template/.claude-pipeline/dashboard/server.ts +4 -3
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/events/route.ts +85 -0
- package/template/.claude-pipeline/dashboard/src/app/api/pipelines/stream/route.ts +72 -0
- package/template/.claude-pipeline/dashboard/src/hooks/use-pipeline-detail.ts +31 -45
- package/template/.claude-pipeline/dashboard/src/hooks/use-pipelines.ts +25 -38
- package/template/.claude-pipeline/dashboard/src/hooks/use-sse.ts +61 -0
- package/template/.claude-pipeline/dashboard/src/lib/pipelines.ts +2 -4
- package/template/.claude-pipeline/dashboard/src/lib/sse.ts +55 -0
- package/template/.claude-pipeline/dashboard/src/types/pipeline.ts +1 -7
- package/template/.claude-pipeline/dashboard/src/hooks/use-websocket.ts +0 -58
- package/template/.claude-pipeline/dashboard/src/lib/watcher.ts +0 -90
- package/template/.claude-pipeline/dashboard/src/lib/ws-server.ts +0 -123
package/package.json
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
"react-markdown": "^10.1.0",
|
|
17
17
|
"react-syntax-highlighter": "^16.1.1",
|
|
18
18
|
"remark-gfm": "^4.0.1",
|
|
19
|
-
"uuid": "^13.0.0"
|
|
20
|
-
"ws": "^8.20.0"
|
|
19
|
+
"uuid": "^13.0.0"
|
|
21
20
|
},
|
|
22
21
|
"devDependencies": {
|
|
23
22
|
"@types/node": "^20",
|
|
@@ -25,7 +24,6 @@
|
|
|
25
24
|
"@types/react-dom": "^18",
|
|
26
25
|
"@types/react-syntax-highlighter": "^15.5.13",
|
|
27
26
|
"@types/uuid": "^10.0.0",
|
|
28
|
-
"@types/ws": "^8.18.1",
|
|
29
27
|
"eslint": "^8",
|
|
30
28
|
"eslint-config-next": "14.2.35",
|
|
31
29
|
"postcss": "^8",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createServer } from "http";
|
|
2
2
|
import { parse } from "url";
|
|
3
3
|
import next from "next";
|
|
4
|
-
import { createWSServer } from "./src/lib/ws-server";
|
|
5
4
|
|
|
6
5
|
const dev = process.env.NODE_ENV !== "production";
|
|
7
6
|
const hostname = "localhost";
|
|
@@ -16,9 +15,11 @@ app.prepare().then(() => {
|
|
|
16
15
|
handle(req, res, parsedUrl);
|
|
17
16
|
});
|
|
18
17
|
|
|
19
|
-
createWSServer(server);
|
|
20
|
-
|
|
21
18
|
server.listen(port, () => {
|
|
22
19
|
console.log(`> Ready on http://${hostname}:${port}`);
|
|
20
|
+
console.log(`> PIPELINES_DIR: ${process.env.PIPELINES_DIR || "(default: ../pipelines)"}`);
|
|
23
21
|
});
|
|
22
|
+
}).catch((err) => {
|
|
23
|
+
console.error("Failed to start dashboard:", err);
|
|
24
|
+
process.exit(1);
|
|
24
25
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createSSEResponse } from "@/lib/sse";
|
|
2
|
+
import { readPipelineState, getPipelineDir } from "@/lib/pipelines";
|
|
3
|
+
import { detectCheckpoint } from "@/lib/checkpoint";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic";
|
|
8
|
+
|
|
9
|
+
export async function GET(
|
|
10
|
+
_request: Request,
|
|
11
|
+
{ params }: { params: { id: string } },
|
|
12
|
+
) {
|
|
13
|
+
const { id } = params;
|
|
14
|
+
const pipelineDir = getPipelineDir(id);
|
|
15
|
+
|
|
16
|
+
if (!fs.existsSync(pipelineDir)) {
|
|
17
|
+
return new Response(JSON.stringify({ error: "Pipeline not found" }), {
|
|
18
|
+
status: 404,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return createSSEResponse((writer, signal) => {
|
|
23
|
+
let prevActivitiesCount = 0;
|
|
24
|
+
let lastMtime = 0;
|
|
25
|
+
|
|
26
|
+
// Send initial state
|
|
27
|
+
const initialState = readPipelineState(id);
|
|
28
|
+
if (initialState) {
|
|
29
|
+
writer.write("pipeline:updated", { id, state: initialState });
|
|
30
|
+
prevActivitiesCount = initialState.activities.length;
|
|
31
|
+
|
|
32
|
+
const checkpoint = detectCheckpoint(initialState.activities);
|
|
33
|
+
if (checkpoint) {
|
|
34
|
+
writer.write("pipeline:checkpoint", { id, checkpoint });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Poll state.json for changes
|
|
39
|
+
const stateFile = path.join(pipelineDir, "state.json");
|
|
40
|
+
|
|
41
|
+
const interval = setInterval(() => {
|
|
42
|
+
if (signal.aborted) {
|
|
43
|
+
clearInterval(interval);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const stat = fs.statSync(stateFile);
|
|
49
|
+
if (stat.mtimeMs === lastMtime) return;
|
|
50
|
+
lastMtime = stat.mtimeMs;
|
|
51
|
+
|
|
52
|
+
const state = readPipelineState(id);
|
|
53
|
+
if (!state) return;
|
|
54
|
+
|
|
55
|
+
// Send full state update
|
|
56
|
+
writer.write("pipeline:updated", { id, state });
|
|
57
|
+
|
|
58
|
+
// Send new activities individually
|
|
59
|
+
const newActivities = state.activities.slice(prevActivitiesCount);
|
|
60
|
+
for (const activity of newActivities) {
|
|
61
|
+
writer.write("pipeline:activity", { id, activity });
|
|
62
|
+
}
|
|
63
|
+
prevActivitiesCount = state.activities.length;
|
|
64
|
+
|
|
65
|
+
// Check for checkpoint
|
|
66
|
+
const checkpoint = detectCheckpoint(state.activities);
|
|
67
|
+
if (checkpoint) {
|
|
68
|
+
writer.write("pipeline:checkpoint", { id, checkpoint });
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// state.json may be mid-write or pipeline removed
|
|
72
|
+
if (!fs.existsSync(pipelineDir)) {
|
|
73
|
+
writer.write("pipeline:removed", { id });
|
|
74
|
+
clearInterval(interval);
|
|
75
|
+
writer.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}, 500);
|
|
79
|
+
|
|
80
|
+
signal.addEventListener("abort", () => {
|
|
81
|
+
clearInterval(interval);
|
|
82
|
+
writer.close();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createSSEResponse } from "@/lib/sse";
|
|
2
|
+
import { listPipelines, readPipelineState, getPipelinesDir } from "@/lib/pipelines";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
export async function GET() {
|
|
9
|
+
return createSSEResponse((writer, signal) => {
|
|
10
|
+
// Send initial pipeline list
|
|
11
|
+
const pipelines = listPipelines();
|
|
12
|
+
writer.write("pipeline:init", { pipelines });
|
|
13
|
+
|
|
14
|
+
const pipelinesDir = getPipelinesDir();
|
|
15
|
+
if (!fs.existsSync(pipelinesDir)) {
|
|
16
|
+
fs.mkdirSync(pipelinesDir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Track known pipeline state file mtimes for change detection
|
|
20
|
+
const lastMtimes = new Map<string, number>();
|
|
21
|
+
|
|
22
|
+
// Poll for changes (simpler and more reliable than fs.watch across platforms)
|
|
23
|
+
const interval = setInterval(() => {
|
|
24
|
+
if (signal.aborted) {
|
|
25
|
+
clearInterval(interval);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const entries = fs.readdirSync(pipelinesDir, { withFileTypes: true });
|
|
31
|
+
const currentIds = new Set<string>();
|
|
32
|
+
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isDirectory()) continue;
|
|
35
|
+
currentIds.add(entry.name);
|
|
36
|
+
|
|
37
|
+
const stateFile = path.join(pipelinesDir, entry.name, "state.json");
|
|
38
|
+
try {
|
|
39
|
+
const stat = fs.statSync(stateFile);
|
|
40
|
+
const mtime = stat.mtimeMs;
|
|
41
|
+
const prev = lastMtimes.get(entry.name);
|
|
42
|
+
|
|
43
|
+
if (prev === undefined || prev !== mtime) {
|
|
44
|
+
lastMtimes.set(entry.name, mtime);
|
|
45
|
+
const state = readPipelineState(entry.name);
|
|
46
|
+
if (state) {
|
|
47
|
+
writer.write("pipeline:updated", { id: entry.name, state });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// state.json doesn't exist yet
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Detect removed pipelines
|
|
56
|
+
for (const id of lastMtimes.keys()) {
|
|
57
|
+
if (!currentIds.has(id)) {
|
|
58
|
+
lastMtimes.delete(id);
|
|
59
|
+
writer.write("pipeline:removed", { id });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// pipelines dir may not exist yet
|
|
64
|
+
}
|
|
65
|
+
}, 1000);
|
|
66
|
+
|
|
67
|
+
signal.addEventListener("abort", () => {
|
|
68
|
+
clearInterval(interval);
|
|
69
|
+
writer.close();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState,
|
|
4
|
-
import type { PipelineState, CheckpointInfo
|
|
5
|
-
import {
|
|
3
|
+
import { useState, useCallback } from "react";
|
|
4
|
+
import type { PipelineState, CheckpointInfo } from "@/types/pipeline";
|
|
5
|
+
import { useSSE } from "./use-sse";
|
|
6
6
|
|
|
7
7
|
export function usePipelineDetail(id: string) {
|
|
8
8
|
const [pipeline, setPipeline] = useState<PipelineState | null>(null);
|
|
@@ -10,56 +10,42 @@ export function usePipelineDetail(id: string) {
|
|
|
10
10
|
const [loading, setLoading] = useState(true);
|
|
11
11
|
const [notFound, setNotFound] = useState(false);
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const res = await fetch(`/api/pipelines/${id}`);
|
|
16
|
-
if (res.status === 404) {
|
|
17
|
-
setNotFound(true);
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
|
-
const data = await res.json();
|
|
21
|
-
setPipeline(data);
|
|
22
|
-
} catch {
|
|
23
|
-
// Ignore
|
|
24
|
-
} finally {
|
|
25
|
-
setLoading(false);
|
|
26
|
-
}
|
|
27
|
-
}, [id]);
|
|
13
|
+
const handleEvent = useCallback((event: string, data: unknown) => {
|
|
14
|
+
const d = data as Record<string, unknown>;
|
|
28
15
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
} else if (
|
|
16
|
+
if (event === "pipeline:updated" && d.id === id) {
|
|
17
|
+
setPipeline(d.state as PipelineState);
|
|
18
|
+
setLoading(false);
|
|
19
|
+
} else if (event === "pipeline:activity" && d.id === id) {
|
|
33
20
|
setPipeline((prev) => {
|
|
34
21
|
if (!prev) return prev;
|
|
35
|
-
|
|
22
|
+
const activity = d.activity as PipelineState["activities"][0];
|
|
23
|
+
return { ...prev, activities: [...prev.activities, activity] };
|
|
36
24
|
});
|
|
37
|
-
} else if (
|
|
38
|
-
setCheckpoint(
|
|
39
|
-
} else if (
|
|
25
|
+
} else if (event === "pipeline:checkpoint" && d.id === id) {
|
|
26
|
+
setCheckpoint(d.checkpoint as CheckpointInfo);
|
|
27
|
+
} else if (event === "pipeline:removed" && d.id === id) {
|
|
40
28
|
setNotFound(true);
|
|
41
29
|
}
|
|
42
30
|
}, [id]);
|
|
43
31
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
setCheckpoint(null);
|
|
62
|
-
}, [id, send]);
|
|
32
|
+
useSSE(`/api/pipelines/${id}/events`, handleEvent);
|
|
33
|
+
|
|
34
|
+
const respondToCheckpoint = useCallback(
|
|
35
|
+
async (action: "approve" | "reject", message?: string) => {
|
|
36
|
+
try {
|
|
37
|
+
await fetch(`/api/pipelines/${id}/checkpoint`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ action, message }),
|
|
41
|
+
});
|
|
42
|
+
setCheckpoint(null);
|
|
43
|
+
} catch {
|
|
44
|
+
// Ignore fetch errors
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
[id],
|
|
48
|
+
);
|
|
63
49
|
|
|
64
50
|
return { pipeline, checkpoint, loading, notFound, respondToCheckpoint };
|
|
65
51
|
}
|
|
@@ -1,37 +1,32 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState,
|
|
4
|
-
import type { PipelineSummary,
|
|
5
|
-
import {
|
|
3
|
+
import { useState, useCallback } from "react";
|
|
4
|
+
import type { PipelineSummary, PipelineState } from "@/types/pipeline";
|
|
5
|
+
import { useSSE } from "./use-sse";
|
|
6
6
|
|
|
7
7
|
export function usePipelines() {
|
|
8
8
|
const [pipelines, setPipelines] = useState<PipelineSummary[]>([]);
|
|
9
9
|
const [loading, setLoading] = useState(true);
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const res = await fetch("/api/pipelines");
|
|
14
|
-
const data = await res.json();
|
|
15
|
-
setPipelines(data.pipelines);
|
|
16
|
-
} catch {
|
|
17
|
-
// Ignore fetch errors
|
|
18
|
-
} finally {
|
|
19
|
-
setLoading(false);
|
|
20
|
-
}
|
|
21
|
-
}, []);
|
|
11
|
+
const handleEvent = useCallback((event: string, data: unknown) => {
|
|
12
|
+
const d = data as Record<string, unknown>;
|
|
22
13
|
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
if (event === "pipeline:init") {
|
|
15
|
+
const list = d.pipelines as PipelineSummary[];
|
|
16
|
+
setPipelines(list);
|
|
17
|
+
setLoading(false);
|
|
18
|
+
} else if (event === "pipeline:updated") {
|
|
19
|
+
const state = d.state as PipelineState;
|
|
20
|
+
const summary: PipelineSummary = {
|
|
21
|
+
id: state.id,
|
|
22
|
+
requirements: state.requirements,
|
|
23
|
+
status: state.status,
|
|
24
|
+
currentPhase: state.currentPhase,
|
|
25
|
+
createdAt: state.createdAt,
|
|
26
|
+
agents: state.agents,
|
|
27
|
+
};
|
|
25
28
|
setPipelines((prev) => {
|
|
26
|
-
const idx = prev.findIndex((p) => p.id ===
|
|
27
|
-
const summary: PipelineSummary = {
|
|
28
|
-
id: msg.state.id,
|
|
29
|
-
requirements: msg.state.requirements,
|
|
30
|
-
status: msg.state.status,
|
|
31
|
-
currentPhase: msg.state.currentPhase,
|
|
32
|
-
createdAt: msg.state.createdAt,
|
|
33
|
-
agents: msg.state.agents,
|
|
34
|
-
};
|
|
29
|
+
const idx = prev.findIndex((p) => p.id === state.id);
|
|
35
30
|
if (idx >= 0) {
|
|
36
31
|
const next = [...prev];
|
|
37
32
|
next[idx] = summary;
|
|
@@ -39,22 +34,14 @@ export function usePipelines() {
|
|
|
39
34
|
}
|
|
40
35
|
return [summary, ...prev];
|
|
41
36
|
});
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
setLoading(false);
|
|
38
|
+
} else if (event === "pipeline:removed") {
|
|
39
|
+
const id = d.id as string;
|
|
40
|
+
setPipelines((prev) => prev.filter((p) => p.id !== id));
|
|
44
41
|
}
|
|
45
42
|
}, []);
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
fetchPipelines();
|
|
51
|
-
}, [fetchPipelines]);
|
|
52
|
-
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (connected) {
|
|
55
|
-
send({ type: "subscribe:all" });
|
|
56
|
-
}
|
|
57
|
-
}, [connected, send]);
|
|
44
|
+
useSSE("/api/pipelines/stream", handleEvent);
|
|
58
45
|
|
|
59
46
|
return { pipelines, loading };
|
|
60
47
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export type SSEHandler = (event: string, data: unknown) => void;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* React hook for Server-Sent Events.
|
|
9
|
+
* Browser-native EventSource with automatic reconnection.
|
|
10
|
+
*/
|
|
11
|
+
export function useSSE(url: string | null, onEvent: SSEHandler) {
|
|
12
|
+
const [connected, setConnected] = useState(false);
|
|
13
|
+
const eventSourceRef = useRef<EventSource | null>(null);
|
|
14
|
+
const onEventRef = useRef(onEvent);
|
|
15
|
+
onEventRef.current = onEvent;
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!url) return;
|
|
19
|
+
|
|
20
|
+
const es = new EventSource(url);
|
|
21
|
+
eventSourceRef.current = es;
|
|
22
|
+
|
|
23
|
+
es.onopen = () => {
|
|
24
|
+
setConnected(true);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
es.onerror = () => {
|
|
28
|
+
setConnected(false);
|
|
29
|
+
// EventSource auto-reconnects natively
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Named event listener helper
|
|
33
|
+
const events = [
|
|
34
|
+
"pipeline:updated",
|
|
35
|
+
"pipeline:activity",
|
|
36
|
+
"pipeline:checkpoint",
|
|
37
|
+
"pipeline:removed",
|
|
38
|
+
"pipeline:init",
|
|
39
|
+
"error",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const eventName of events) {
|
|
43
|
+
es.addEventListener(eventName, (e: MessageEvent) => {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(e.data);
|
|
46
|
+
onEventRef.current(eventName, data);
|
|
47
|
+
} catch {
|
|
48
|
+
// Ignore parse errors
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
es.close();
|
|
55
|
+
eventSourceRef.current = null;
|
|
56
|
+
setConnected(false);
|
|
57
|
+
};
|
|
58
|
+
}, [url]);
|
|
59
|
+
|
|
60
|
+
return { connected };
|
|
61
|
+
}
|
|
@@ -2,10 +2,8 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import type { PipelineState, PipelineSummary } from "@/types/pipeline";
|
|
4
4
|
|
|
5
|
-
const PIPELINES_DIR = process.env.PIPELINES_DIR
|
|
6
|
-
|
|
7
|
-
throw new Error("PIPELINES_DIR 환경변수가 설정되지 않았습니다. npx create-claude-pipeline으로 실행해주세요.");
|
|
8
|
-
}
|
|
5
|
+
const PIPELINES_DIR = process.env.PIPELINES_DIR
|
|
6
|
+
|| path.resolve(process.cwd(), "..", "pipelines");
|
|
9
7
|
|
|
10
8
|
export function getPipelinesDir(): string {
|
|
11
9
|
return path.resolve(PIPELINES_DIR);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) utility for Next.js API routes.
|
|
3
|
+
* Replaces WebSocket for server→client push.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SSEWriter {
|
|
7
|
+
write(event: string, data: unknown): void;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createSSEResponse(
|
|
12
|
+
handler: (writer: SSEWriter, signal: AbortSignal) => void | Promise<void>,
|
|
13
|
+
): Response {
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
let controllerRef: ReadableStreamDefaultController | null = null;
|
|
16
|
+
const abortController = new AbortController();
|
|
17
|
+
|
|
18
|
+
const stream = new ReadableStream({
|
|
19
|
+
start(controller) {
|
|
20
|
+
controllerRef = controller;
|
|
21
|
+
|
|
22
|
+
const writer: SSEWriter = {
|
|
23
|
+
write(event: string, data: unknown) {
|
|
24
|
+
try {
|
|
25
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
26
|
+
controller.enqueue(encoder.encode(payload));
|
|
27
|
+
} catch {
|
|
28
|
+
// Stream may be closed
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
close() {
|
|
32
|
+
try {
|
|
33
|
+
controller.close();
|
|
34
|
+
} catch {
|
|
35
|
+
// Already closed
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
handler(writer, abortController.signal);
|
|
41
|
+
},
|
|
42
|
+
cancel() {
|
|
43
|
+
abortController.abort();
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return new Response(stream, {
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "text/event-stream",
|
|
50
|
+
"Cache-Control": "no-cache, no-transform",
|
|
51
|
+
Connection: "keep-alive",
|
|
52
|
+
"X-Accel-Buffering": "no",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -46,16 +46,10 @@ export interface PipelineSummary {
|
|
|
46
46
|
agents: Record<string, AgentState>;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
//
|
|
49
|
+
// SSE event data types (server → client only)
|
|
50
50
|
export type ServerMessage =
|
|
51
51
|
| { type: "pipeline:updated"; id: string; state: PipelineState }
|
|
52
52
|
| { type: "pipeline:activity"; id: string; activity: Activity }
|
|
53
53
|
| { type: "pipeline:checkpoint"; id: string; checkpoint: CheckpointInfo }
|
|
54
54
|
| { type: "pipeline:removed"; id: string }
|
|
55
55
|
| { type: "error"; message: string };
|
|
56
|
-
|
|
57
|
-
export type ClientMessage =
|
|
58
|
-
| { type: "subscribe:all" }
|
|
59
|
-
| { type: "subscribe"; pipelineId: string }
|
|
60
|
-
| { type: "unsubscribe"; pipelineId: string }
|
|
61
|
-
| { type: "checkpoint:respond"; pipelineId: string; action: "approve" | "reject"; message?: string };
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useEffect, useRef, useCallback, useState } from "react";
|
|
4
|
-
import type { ServerMessage, ClientMessage } from "@/types/pipeline";
|
|
5
|
-
|
|
6
|
-
export function useWebSocket(onMessage: (msg: ServerMessage) => void, onReconnect?: () => void) {
|
|
7
|
-
const wsRef = useRef<WebSocket | null>(null);
|
|
8
|
-
const reconnectDelay = useRef(1000);
|
|
9
|
-
const [connected, setConnected] = useState(false);
|
|
10
|
-
|
|
11
|
-
const connect = useCallback(() => {
|
|
12
|
-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
13
|
-
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
14
|
-
|
|
15
|
-
ws.onopen = () => {
|
|
16
|
-
const wasDisconnected = !connected;
|
|
17
|
-
setConnected(true);
|
|
18
|
-
reconnectDelay.current = 1000;
|
|
19
|
-
// Signal reconnect so data hooks can re-fetch
|
|
20
|
-
if (wasDisconnected && onReconnect) onReconnect();
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
ws.onmessage = (event) => {
|
|
24
|
-
try {
|
|
25
|
-
const msg = JSON.parse(event.data) as ServerMessage;
|
|
26
|
-
onMessage(msg);
|
|
27
|
-
} catch {
|
|
28
|
-
// Ignore parse errors
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
ws.onclose = () => {
|
|
33
|
-
setConnected(false);
|
|
34
|
-
wsRef.current = null;
|
|
35
|
-
// Exponential backoff reconnection
|
|
36
|
-
const delay = reconnectDelay.current;
|
|
37
|
-
reconnectDelay.current = Math.min(delay * 2, 30000);
|
|
38
|
-
setTimeout(connect, delay);
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
wsRef.current = ws;
|
|
42
|
-
}, [onMessage]);
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
connect();
|
|
46
|
-
return () => {
|
|
47
|
-
wsRef.current?.close();
|
|
48
|
-
};
|
|
49
|
-
}, [connect]);
|
|
50
|
-
|
|
51
|
-
const send = useCallback((msg: ClientMessage) => {
|
|
52
|
-
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
53
|
-
wsRef.current.send(JSON.stringify(msg));
|
|
54
|
-
}
|
|
55
|
-
}, []);
|
|
56
|
-
|
|
57
|
-
return { send, connected };
|
|
58
|
-
}
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import { getPipelinesDir, readPipelineState } from "./pipelines";
|
|
4
|
-
import type { PipelineState } from "@/types/pipeline";
|
|
5
|
-
|
|
6
|
-
type WatchCallback = (id: string, state: PipelineState | null) => void;
|
|
7
|
-
|
|
8
|
-
export class PipelineWatcher {
|
|
9
|
-
private watchers = new Map<string, fs.FSWatcher>();
|
|
10
|
-
private debounceTimers = new Map<string, NodeJS.Timeout>();
|
|
11
|
-
private dirWatcher: fs.FSWatcher | null = null;
|
|
12
|
-
private callback: WatchCallback;
|
|
13
|
-
|
|
14
|
-
constructor(callback: WatchCallback) {
|
|
15
|
-
this.callback = callback;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
start() {
|
|
19
|
-
const dir = getPipelinesDir();
|
|
20
|
-
if (!fs.existsSync(dir)) {
|
|
21
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
this.dirWatcher = fs.watch(dir, (_, filename) => {
|
|
25
|
-
if (!filename) return;
|
|
26
|
-
const fullPath = path.join(dir, filename);
|
|
27
|
-
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
28
|
-
this.watchPipeline(filename);
|
|
29
|
-
} else {
|
|
30
|
-
this.unwatchPipeline(filename);
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
35
|
-
for (const entry of entries) {
|
|
36
|
-
if (entry.isDirectory()) {
|
|
37
|
-
this.watchPipeline(entry.name);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
private watchPipeline(id: string) {
|
|
43
|
-
if (this.watchers.has(id)) return;
|
|
44
|
-
|
|
45
|
-
const stateFile = path.join(getPipelinesDir(), id, "state.json");
|
|
46
|
-
if (!fs.existsSync(stateFile)) return;
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
const watcher = fs.watch(stateFile, () => {
|
|
50
|
-
const existing = this.debounceTimers.get(id);
|
|
51
|
-
if (existing) clearTimeout(existing);
|
|
52
|
-
|
|
53
|
-
this.debounceTimers.set(id, setTimeout(() => {
|
|
54
|
-
this.debounceTimers.delete(id);
|
|
55
|
-
const state = readPipelineState(id);
|
|
56
|
-
this.callback(id, state);
|
|
57
|
-
}, 100));
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
watcher.on("error", () => {
|
|
61
|
-
this.unwatchPipeline(id);
|
|
62
|
-
this.callback(id, null);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
this.watchers.set(id, watcher);
|
|
66
|
-
} catch {
|
|
67
|
-
// Ignore watch errors
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private unwatchPipeline(id: string) {
|
|
72
|
-
const watcher = this.watchers.get(id);
|
|
73
|
-
if (watcher) {
|
|
74
|
-
watcher.close();
|
|
75
|
-
this.watchers.delete(id);
|
|
76
|
-
}
|
|
77
|
-
const timer = this.debounceTimers.get(id);
|
|
78
|
-
if (timer) {
|
|
79
|
-
clearTimeout(timer);
|
|
80
|
-
this.debounceTimers.delete(id);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
stop() {
|
|
85
|
-
this.dirWatcher?.close();
|
|
86
|
-
this.watchers.forEach((_, id) => {
|
|
87
|
-
this.unwatchPipeline(id);
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
-
import type { Server } from "http";
|
|
3
|
-
import type { PipelineState, ServerMessage, ClientMessage } from "@/types/pipeline";
|
|
4
|
-
import { PipelineWatcher } from "./watcher";
|
|
5
|
-
import { readPipelineState, writeCheckpointResponse, getPipelineDir } from "./pipelines";
|
|
6
|
-
import { detectCheckpoint } from "./checkpoint";
|
|
7
|
-
import fs from "fs";
|
|
8
|
-
|
|
9
|
-
interface ClientState {
|
|
10
|
-
ws: WebSocket;
|
|
11
|
-
mode: "none" | "all" | "single";
|
|
12
|
-
pipelineId?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function createWSServer(server: Server) {
|
|
16
|
-
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
17
|
-
const clients = new Map<WebSocket, ClientState>();
|
|
18
|
-
const prevActivitiesCount = new Map<string, number>();
|
|
19
|
-
|
|
20
|
-
const watcher = new PipelineWatcher((id, state) => {
|
|
21
|
-
if (state === null) {
|
|
22
|
-
broadcast({ type: "pipeline:removed", id }, (c) => c.mode !== "none");
|
|
23
|
-
prevActivitiesCount.delete(id);
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
broadcastToAll({ type: "pipeline:updated", id, state });
|
|
28
|
-
|
|
29
|
-
const prevCount = prevActivitiesCount.get(id) || 0;
|
|
30
|
-
const newActivities = state.activities.slice(prevCount);
|
|
31
|
-
prevActivitiesCount.set(id, state.activities.length);
|
|
32
|
-
|
|
33
|
-
for (const activity of newActivities) {
|
|
34
|
-
broadcastToSingle(id, { type: "pipeline:activity", id, activity });
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const checkpoint = detectCheckpoint(state.activities);
|
|
38
|
-
if (checkpoint) {
|
|
39
|
-
broadcastToSingle(id, { type: "pipeline:checkpoint", id, checkpoint });
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
broadcastToSingle(id, { type: "pipeline:updated", id, state });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
watcher.start();
|
|
46
|
-
|
|
47
|
-
function broadcast(msg: ServerMessage, filter: (c: ClientState) => boolean) {
|
|
48
|
-
const data = JSON.stringify(msg);
|
|
49
|
-
clients.forEach((client) => {
|
|
50
|
-
if (client.ws.readyState === WebSocket.OPEN && filter(client)) {
|
|
51
|
-
client.ws.send(data);
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function broadcastToAll(msg: ServerMessage) {
|
|
57
|
-
broadcast(msg, (c) => c.mode === "all");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function broadcastToSingle(pipelineId: string, msg: ServerMessage) {
|
|
61
|
-
broadcast(msg, (c) => c.mode === "single" && c.pipelineId === pipelineId);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
wss.on("connection", (ws) => {
|
|
65
|
-
const clientState: ClientState = { ws, mode: "none" };
|
|
66
|
-
clients.set(ws, clientState);
|
|
67
|
-
|
|
68
|
-
ws.on("message", (raw) => {
|
|
69
|
-
try {
|
|
70
|
-
const msg = JSON.parse(raw.toString()) as ClientMessage;
|
|
71
|
-
handleMessage(clientState, msg);
|
|
72
|
-
} catch {
|
|
73
|
-
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }));
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
ws.on("close", () => {
|
|
78
|
-
clients.delete(ws);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
function handleMessage(client: ClientState, msg: ClientMessage) {
|
|
83
|
-
switch (msg.type) {
|
|
84
|
-
case "subscribe:all":
|
|
85
|
-
client.mode = "all";
|
|
86
|
-
client.pipelineId = undefined;
|
|
87
|
-
break;
|
|
88
|
-
|
|
89
|
-
case "subscribe": {
|
|
90
|
-
const dir = getPipelineDir(msg.pipelineId);
|
|
91
|
-
if (!fs.existsSync(dir)) {
|
|
92
|
-
client.ws.send(JSON.stringify({ type: "error", message: "Pipeline not found" }));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
client.mode = "single";
|
|
96
|
-
client.pipelineId = msg.pipelineId;
|
|
97
|
-
|
|
98
|
-
const state = readPipelineState(msg.pipelineId);
|
|
99
|
-
if (state) {
|
|
100
|
-
prevActivitiesCount.set(msg.pipelineId, state.activities.length);
|
|
101
|
-
client.ws.send(JSON.stringify({ type: "pipeline:updated", id: msg.pipelineId, state }));
|
|
102
|
-
const checkpoint = detectCheckpoint(state.activities);
|
|
103
|
-
if (checkpoint) {
|
|
104
|
-
client.ws.send(JSON.stringify({ type: "pipeline:checkpoint", id: msg.pipelineId, checkpoint }));
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
case "unsubscribe":
|
|
111
|
-
client.mode = "none";
|
|
112
|
-
client.pipelineId = undefined;
|
|
113
|
-
break;
|
|
114
|
-
|
|
115
|
-
case "checkpoint:respond": {
|
|
116
|
-
writeCheckpointResponse(msg.pipelineId, msg.action, msg.message);
|
|
117
|
-
break;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return { wss, watcher };
|
|
123
|
-
}
|