@ai-setting/roy-plugin-task-show 0.4.0 → 0.5.1
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/README.md +299 -216
- package/dist/collector.d.ts +11 -0
- package/dist/collector.d.ts.map +1 -1
- package/dist/collector.js +15 -0
- package/dist/collector.js.map +1 -1
- package/dist/event-bus.d.ts +98 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +210 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/plugin.d.ts +67 -64
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +234 -148
- package/dist/plugin.js.map +1 -1
- package/dist/server.d.ts +66 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +294 -99
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +78 -60
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +14 -21
- package/dist/types.js.map +1 -1
- package/dist/url-injector.d.ts +13 -48
- package/dist/url-injector.d.ts.map +1 -1
- package/dist/url-injector.js +13 -58
- package/dist/url-injector.js.map +1 -1
- package/package.json +1 -1
- package/plugin.json +33 -11
- package/public/app.js +409 -57
- package/public/index.html +28 -10
- package/public/style.css +49 -1
package/dist/server.d.ts
CHANGED
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
* GET /task/:taskId → detail page (mermaid flow + tables)
|
|
8
8
|
* GET /api/sessions → JSON list of sessions
|
|
9
9
|
* GET /api/sessions/:taskId → JSON detail of one session
|
|
10
|
+
* GET /api/events → Server-Sent Events stream (real-time push)
|
|
10
11
|
* GET /static/* → static frontend assets (CSS/JS)
|
|
11
12
|
*
|
|
12
13
|
* The server is started/stopped via `TaskShowServer.start()` /
|
|
13
|
-
* `.stop()`.
|
|
14
|
-
*
|
|
15
|
-
* port.
|
|
14
|
+
* `.stop()`. When the configured port is busy we probe `port + 1`,
|
|
15
|
+
* `port + 2`, ... in ascending order until a free TCP port is found —
|
|
16
|
+
* never falling back to an OS-assigned ephemeral port (`listen(0)`).
|
|
17
|
+
* That keeps the bound port predictable for documentation, fire-and-forget
|
|
18
|
+
* scripts and ad-hoc coordination between processes.
|
|
16
19
|
*/
|
|
17
20
|
import * as http from "node:http";
|
|
18
21
|
import type { TaskShowConfig } from "./types.js";
|
|
19
22
|
import type { ToolCallCollector } from "./collector.js";
|
|
23
|
+
import type { EventBus } from "./event-bus.js";
|
|
20
24
|
/** Public-facing server info (returned to whoever called `start`). */
|
|
21
25
|
export interface ServerInfo {
|
|
22
26
|
host: string;
|
|
@@ -26,15 +30,57 @@ export interface ServerInfo {
|
|
|
26
30
|
/** True if the original port was busy and we had to pick a free one. */
|
|
27
31
|
portRewritten: boolean;
|
|
28
32
|
}
|
|
33
|
+
/** Upper bound of the TCP user port range. Probing never wraps past this. */
|
|
34
|
+
export declare const MAX_TCP_PORT = 65535;
|
|
35
|
+
/** Smallest legal TCP user port (avoid binding to well-known 0..1023). */
|
|
36
|
+
export declare const MIN_TCP_PORT = 1024;
|
|
37
|
+
/** Minimal logger interface used by `listenWithProbing`. */
|
|
38
|
+
export type PortProbingLogger = {
|
|
39
|
+
info: (m: string) => void;
|
|
40
|
+
warn: (m: string) => void;
|
|
41
|
+
error: (m: string) => void;
|
|
42
|
+
};
|
|
43
|
+
/** Outcome of `listenWithProbing`. */
|
|
44
|
+
export interface PortProbingResult {
|
|
45
|
+
/** The port that was actually bound. */
|
|
46
|
+
port: number;
|
|
47
|
+
/** `true` iff we ended up on a port other than the requested start. */
|
|
48
|
+
portRewritten: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Try `server.listen(port, host, …)` starting at `startPort`. If the OS
|
|
52
|
+
* reports `EADDRINUSE`, increment the port and retry. Stops at 65535 —
|
|
53
|
+
* never wraps, never falls back to an OS-assigned ephemeral port.
|
|
54
|
+
*
|
|
55
|
+
* Only `EADDRINUSE` is retried. Any other listen error (e.g. `EACCES`,
|
|
56
|
+
* `ENOTSUP`) is surfaced as a normal rejection — the caller is
|
|
57
|
+
* responsible for closing the server in that case (this helper does
|
|
58
|
+
* not own the server's lifecycle).
|
|
59
|
+
*
|
|
60
|
+
* Listener hygiene: each attempt attaches a fresh one-shot `error`
|
|
61
|
+
* listener and detaches it as soon as we move on (success, permanent
|
|
62
|
+
* failure, or exhaustion). This guarantees no duplicate resolve /
|
|
63
|
+
* reject calls and no listener leaks even when the helper is called
|
|
64
|
+
* many times on the same server.
|
|
65
|
+
*/
|
|
66
|
+
export declare function listenWithProbing(args: {
|
|
67
|
+
server: http.Server;
|
|
68
|
+
host: string;
|
|
69
|
+
startPort: number;
|
|
70
|
+
logger?: PortProbingLogger;
|
|
71
|
+
}): Promise<PortProbingResult>;
|
|
29
72
|
export declare class TaskShowServer {
|
|
30
73
|
private server;
|
|
31
74
|
private readonly cfg;
|
|
32
75
|
private readonly collector;
|
|
76
|
+
private readonly eventBus;
|
|
33
77
|
private readonly publicRoot;
|
|
34
78
|
private readonly logger;
|
|
35
79
|
constructor(opts: {
|
|
36
80
|
cfg: TaskShowConfig;
|
|
37
81
|
collector: ToolCallCollector;
|
|
82
|
+
/** EventBus for SSE broadcasts. Defaults to a new EventBus if omitted. */
|
|
83
|
+
eventBus?: EventBus;
|
|
38
84
|
/** Absolute or relative path to the directory with public/index.html. */
|
|
39
85
|
publicDir?: string;
|
|
40
86
|
logger?: {
|
|
@@ -44,9 +90,19 @@ export declare class TaskShowServer {
|
|
|
44
90
|
};
|
|
45
91
|
metaUrl?: string;
|
|
46
92
|
});
|
|
93
|
+
/**
|
|
94
|
+
* Get the SSE event bus (for tests / health checks).
|
|
95
|
+
*/
|
|
96
|
+
getEventBus(): EventBus;
|
|
47
97
|
/**
|
|
48
98
|
* Start the listener. The promise resolves once the server is bound — but
|
|
49
99
|
* the caller can race against `getInfo()` immediately afterwards.
|
|
100
|
+
*
|
|
101
|
+
* Port handling: we attempt to bind on `cfg.port` first; if the OS reports
|
|
102
|
+
* `EADDRINUSE` we probe `cfg.port + 1`, `cfg.port + 2`, ... in ascending
|
|
103
|
+
* order until a free port is found. The probing never wraps past 65535
|
|
104
|
+
* and never falls back to `listen(0)`. `ServerInfo.portRewritten` is
|
|
105
|
+
* `true` whenever the bound port differs from `cfg.port`.
|
|
50
106
|
*/
|
|
51
107
|
start(): Promise<ServerInfo>;
|
|
52
108
|
/**
|
|
@@ -63,6 +119,13 @@ export declare class TaskShowServer {
|
|
|
63
119
|
*/
|
|
64
120
|
stop(): Promise<void>;
|
|
65
121
|
private handle;
|
|
122
|
+
/**
|
|
123
|
+
* SSE handler — sets the SSE headers, sends an initial snapshot, and
|
|
124
|
+
* subscribes the response to the event bus. The bus will write frames
|
|
125
|
+
* until the socket closes; the `res.on("close", ...)` listener removes
|
|
126
|
+
* the subscriber.
|
|
127
|
+
*/
|
|
128
|
+
private handleSSE;
|
|
66
129
|
/**
|
|
67
130
|
* Render the index page (lists known tasks).
|
|
68
131
|
*/
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/dist/server.js
CHANGED
|
@@ -7,18 +7,156 @@
|
|
|
7
7
|
* GET /task/:taskId → detail page (mermaid flow + tables)
|
|
8
8
|
* GET /api/sessions → JSON list of sessions
|
|
9
9
|
* GET /api/sessions/:taskId → JSON detail of one session
|
|
10
|
+
* GET /api/events → Server-Sent Events stream (real-time push)
|
|
10
11
|
* GET /static/* → static frontend assets (CSS/JS)
|
|
11
12
|
*
|
|
12
13
|
* The server is started/stopped via `TaskShowServer.start()` /
|
|
13
|
-
* `.stop()`.
|
|
14
|
-
*
|
|
15
|
-
* port.
|
|
14
|
+
* `.stop()`. When the configured port is busy we probe `port + 1`,
|
|
15
|
+
* `port + 2`, ... in ascending order until a free TCP port is found —
|
|
16
|
+
* never falling back to an OS-assigned ephemeral port (`listen(0)`).
|
|
17
|
+
* That keeps the bound port predictable for documentation, fire-and-forget
|
|
18
|
+
* scripts and ad-hoc coordination between processes.
|
|
16
19
|
*/
|
|
17
20
|
import * as fs from "node:fs";
|
|
18
21
|
import * as path from "node:path";
|
|
19
22
|
import * as http from "node:http";
|
|
20
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { EventBus as DefaultEventBus, writeSSEHeaders } from "./event-bus.js";
|
|
25
|
+
/** Upper bound of the TCP user port range. Probing never wraps past this. */
|
|
26
|
+
export const MAX_TCP_PORT = 65535;
|
|
27
|
+
/** Smallest legal TCP user port (avoid binding to well-known 0..1023). */
|
|
28
|
+
export const MIN_TCP_PORT = 1024;
|
|
21
29
|
const PUBLIC_DIR = "public";
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// listenWithProbing — sequential port probing helper
|
|
32
|
+
// ============================================================================
|
|
33
|
+
/**
|
|
34
|
+
* Try `server.listen(port, host, …)` starting at `startPort`. If the OS
|
|
35
|
+
* reports `EADDRINUSE`, increment the port and retry. Stops at 65535 —
|
|
36
|
+
* never wraps, never falls back to an OS-assigned ephemeral port.
|
|
37
|
+
*
|
|
38
|
+
* Only `EADDRINUSE` is retried. Any other listen error (e.g. `EACCES`,
|
|
39
|
+
* `ENOTSUP`) is surfaced as a normal rejection — the caller is
|
|
40
|
+
* responsible for closing the server in that case (this helper does
|
|
41
|
+
* not own the server's lifecycle).
|
|
42
|
+
*
|
|
43
|
+
* Listener hygiene: each attempt attaches a fresh one-shot `error`
|
|
44
|
+
* listener and detaches it as soon as we move on (success, permanent
|
|
45
|
+
* failure, or exhaustion). This guarantees no duplicate resolve /
|
|
46
|
+
* reject calls and no listener leaks even when the helper is called
|
|
47
|
+
* many times on the same server.
|
|
48
|
+
*/
|
|
49
|
+
export function listenWithProbing(args) {
|
|
50
|
+
const { server, host, startPort } = args;
|
|
51
|
+
const logger = args.logger ?? console;
|
|
52
|
+
// Port 0 is a special sentinel: the caller is asking the OS to assign
|
|
53
|
+
// an ephemeral port. Honor that directly — no probing needed, no
|
|
54
|
+
// portRewritten. Any other value must be a valid user-config port.
|
|
55
|
+
if (startPort === 0) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
let settled = false;
|
|
58
|
+
const onError = (err) => {
|
|
59
|
+
if (settled)
|
|
60
|
+
return;
|
|
61
|
+
settled = true;
|
|
62
|
+
reject(err);
|
|
63
|
+
};
|
|
64
|
+
server.once("error", onError);
|
|
65
|
+
server.listen(0, host, () => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
server.removeListener("error", onError);
|
|
69
|
+
settled = true;
|
|
70
|
+
const addr = server.address();
|
|
71
|
+
if (typeof addr === "string" || addr === null) {
|
|
72
|
+
reject(new Error("Failed to determine assigned port after listen(0)"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
resolve({ port: addr.port, portRewritten: false });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// Reject impossible start ports up-front — no need to touch the
|
|
80
|
+
// server at all. Saves a confusing EADDRINUSE loop when someone
|
|
81
|
+
// passes 70000.
|
|
82
|
+
if (!Number.isInteger(startPort) ||
|
|
83
|
+
startPort < MIN_TCP_PORT ||
|
|
84
|
+
startPort > MAX_TCP_PORT) {
|
|
85
|
+
return Promise.reject(new Error(`Invalid startPort ${startPort}: must be 0 (OS-assigned) or an integer in [${MIN_TCP_PORT}, ${MAX_TCP_PORT}]`));
|
|
86
|
+
}
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let port = startPort;
|
|
89
|
+
let settled = false;
|
|
90
|
+
// Per-attempt error listener — we explicitly removeListener it before
|
|
91
|
+
// attaching a new one, so no listeners accumulate across attempts.
|
|
92
|
+
let onError = null;
|
|
93
|
+
const finishError = (err) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
settled = true;
|
|
97
|
+
if (onError) {
|
|
98
|
+
server.removeListener("error", onError);
|
|
99
|
+
onError = null;
|
|
100
|
+
}
|
|
101
|
+
reject(err);
|
|
102
|
+
};
|
|
103
|
+
const finishSuccess = (boundPort) => {
|
|
104
|
+
if (settled)
|
|
105
|
+
return;
|
|
106
|
+
settled = true;
|
|
107
|
+
if (onError) {
|
|
108
|
+
server.removeListener("error", onError);
|
|
109
|
+
onError = null;
|
|
110
|
+
}
|
|
111
|
+
resolve({
|
|
112
|
+
port: boundPort,
|
|
113
|
+
portRewritten: boundPort !== startPort,
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const tryListen = () => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
if (port > MAX_TCP_PORT) {
|
|
120
|
+
finishError(Object.assign(new Error(`No free TCP port in [${startPort}, ${MAX_TCP_PORT}]: every port busy (EADDRINUSE)`), { code: "EADDRINUSE" }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
onError = (err) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
const code = err && err.code;
|
|
127
|
+
if (code === "EADDRINUSE") {
|
|
128
|
+
logger.warn(`Port ${port} busy — trying ${port + 1}`);
|
|
129
|
+
// Move on. Detach this listener before the next attempt.
|
|
130
|
+
if (onError) {
|
|
131
|
+
server.removeListener("error", onError);
|
|
132
|
+
onError = null;
|
|
133
|
+
}
|
|
134
|
+
port += 1;
|
|
135
|
+
tryListen();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Any other error: stop immediately.
|
|
139
|
+
finishError(err);
|
|
140
|
+
};
|
|
141
|
+
server.once("error", onError);
|
|
142
|
+
server.listen(port, host, () => {
|
|
143
|
+
if (settled)
|
|
144
|
+
return;
|
|
145
|
+
const addr = server.address();
|
|
146
|
+
if (typeof addr === "string" || addr === null) {
|
|
147
|
+
finishError(new Error("Failed to determine assigned port after listen()"));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// `addr.port` is the authoritative bound port. Use it (rather
|
|
151
|
+
// than our local `port` counter) so a stub server that emits
|
|
152
|
+
// 'listening' with a port it picked itself still resolves
|
|
153
|
+
// truthfully.
|
|
154
|
+
finishSuccess(addr.port);
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
tryListen();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
22
160
|
/** Cached __dirname equivalent for ESM. */
|
|
23
161
|
function getDirname(metaUrl) {
|
|
24
162
|
if (metaUrl)
|
|
@@ -32,69 +170,85 @@ export class TaskShowServer {
|
|
|
32
170
|
server = null;
|
|
33
171
|
cfg;
|
|
34
172
|
collector;
|
|
173
|
+
eventBus;
|
|
35
174
|
publicRoot;
|
|
36
175
|
logger;
|
|
37
176
|
constructor(opts) {
|
|
38
177
|
this.cfg = opts.cfg;
|
|
39
178
|
this.collector = opts.collector;
|
|
179
|
+
this.eventBus = opts.eventBus ?? new DefaultEventBus();
|
|
40
180
|
this.publicRoot = resolvePublicDir(opts.publicDir ?? PUBLIC_DIR, opts.metaUrl);
|
|
41
181
|
this.logger = opts.logger ?? console;
|
|
42
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Get the SSE event bus (for tests / health checks).
|
|
185
|
+
*/
|
|
186
|
+
getEventBus() {
|
|
187
|
+
return this.eventBus;
|
|
188
|
+
}
|
|
43
189
|
/**
|
|
44
190
|
* Start the listener. The promise resolves once the server is bound — but
|
|
45
191
|
* the caller can race against `getInfo()` immediately afterwards.
|
|
192
|
+
*
|
|
193
|
+
* Port handling: we attempt to bind on `cfg.port` first; if the OS reports
|
|
194
|
+
* `EADDRINUSE` we probe `cfg.port + 1`, `cfg.port + 2`, ... in ascending
|
|
195
|
+
* order until a free port is found. The probing never wraps past 65535
|
|
196
|
+
* and never falls back to `listen(0)`. `ServerInfo.portRewritten` is
|
|
197
|
+
* `true` whenever the bound port differs from `cfg.port`.
|
|
46
198
|
*/
|
|
47
199
|
start() {
|
|
48
200
|
if (this.server) {
|
|
49
201
|
throw new Error("TaskShowServer already started");
|
|
50
202
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
res.end("Internal Server Error");
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
// ignore — socket may already be closed
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
const desiredPort = this.cfg.port;
|
|
66
|
-
const server = http.createServer(requestListener);
|
|
67
|
-
server.on("error", (err) => {
|
|
68
|
-
if (err.code === "EADDRINUSE") {
|
|
69
|
-
this.logger.warn(`Port ${desiredPort} busy — falling back to an ephemeral port`);
|
|
70
|
-
// Retry on an OS-assigned port.
|
|
71
|
-
server.listen(0, this.cfg.host, () => {
|
|
72
|
-
const addr = server.address();
|
|
73
|
-
if (typeof addr === "string" || addr === null) {
|
|
74
|
-
reject(new Error("Failed to determine assigned port"));
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
finish(server, addr.port, true);
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
reject(err);
|
|
203
|
+
const requestListener = (req, res) => {
|
|
204
|
+
this.handle(req, res).catch((err) => {
|
|
205
|
+
this.logger.error(`Unhandled error in HTTP handler: ${err}`);
|
|
206
|
+
try {
|
|
207
|
+
res.statusCode = 500;
|
|
208
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
209
|
+
res.end("Internal Server Error");
|
|
82
210
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const addr = server.address();
|
|
86
|
-
if (typeof addr === "string" || addr === null) {
|
|
87
|
-
reject(new Error("Failed to determine assigned port"));
|
|
88
|
-
return;
|
|
211
|
+
catch {
|
|
212
|
+
// ignore — socket may already be closed
|
|
89
213
|
}
|
|
90
|
-
finish(server, addr.port, false);
|
|
91
214
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
215
|
+
};
|
|
216
|
+
const desiredPort = this.cfg.port;
|
|
217
|
+
const server = http.createServer(requestListener);
|
|
218
|
+
return listenWithProbing({
|
|
219
|
+
server,
|
|
220
|
+
host: this.cfg.host,
|
|
221
|
+
startPort: desiredPort,
|
|
222
|
+
logger: this.logger,
|
|
223
|
+
}).then((probe) => {
|
|
224
|
+
// success path
|
|
225
|
+
this.server = server;
|
|
226
|
+
const url = `http://${this.cfg.host === "0.0.0.0" ? "localhost" : this.cfg.host}:${probe.port}/`;
|
|
227
|
+
this.logger.info(`HTTP service listening on ${url}` +
|
|
228
|
+
(probe.portRewritten
|
|
229
|
+
? ` (port rewritten from ${desiredPort} due to conflict)`
|
|
230
|
+
: ""));
|
|
231
|
+
const info = {
|
|
232
|
+
host: this.cfg.host,
|
|
233
|
+
port: probe.port,
|
|
234
|
+
url,
|
|
235
|
+
server,
|
|
236
|
+
portRewritten: probe.portRewritten,
|
|
97
237
|
};
|
|
238
|
+
return info;
|
|
239
|
+
}, (err) => {
|
|
240
|
+
// Probing gave up (port range exhausted or a non-EADDRINUSE error
|
|
241
|
+
// bubbled up). Make sure we close the server we just created so we
|
|
242
|
+
// don't leak the http.Server handle.
|
|
243
|
+
try {
|
|
244
|
+
server.close(() => {
|
|
245
|
+
/* swallow — best effort */
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* ignore */
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
98
252
|
});
|
|
99
253
|
}
|
|
100
254
|
/**
|
|
@@ -124,6 +278,8 @@ export class TaskShowServer {
|
|
|
124
278
|
}
|
|
125
279
|
const s = this.server;
|
|
126
280
|
this.server = null;
|
|
281
|
+
// Force-close all SSE clients so the heartbeat loop can exit.
|
|
282
|
+
this.eventBus.closeAll();
|
|
127
283
|
s.close(() => resolve());
|
|
128
284
|
// Best-effort: tell outstanding sockets to close.
|
|
129
285
|
try {
|
|
@@ -148,6 +304,11 @@ export class TaskShowServer {
|
|
|
148
304
|
return;
|
|
149
305
|
}
|
|
150
306
|
const pathname = url.pathname;
|
|
307
|
+
// /api/events — SSE stream
|
|
308
|
+
if (pathname === "/api/events") {
|
|
309
|
+
this.handleSSE(req, res);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
151
312
|
// /api/sessions
|
|
152
313
|
if (pathname === "/api/sessions") {
|
|
153
314
|
this.json(res, 200, this.collector.listSessions());
|
|
@@ -199,6 +360,35 @@ export class TaskShowServer {
|
|
|
199
360
|
}
|
|
200
361
|
this.text(res, 404, "Not Found");
|
|
201
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* SSE handler — sets the SSE headers, sends an initial snapshot, and
|
|
365
|
+
* subscribes the response to the event bus. The bus will write frames
|
|
366
|
+
* until the socket closes; the `res.on("close", ...)` listener removes
|
|
367
|
+
* the subscriber.
|
|
368
|
+
*/
|
|
369
|
+
handleSSE(_req, res) {
|
|
370
|
+
writeSSEHeaders(res);
|
|
371
|
+
// 1. Send an initial snapshot so the frontend can render immediately.
|
|
372
|
+
const sessions = this.collector.listSessions();
|
|
373
|
+
const hello = {
|
|
374
|
+
type: "snapshot",
|
|
375
|
+
taskId: 0,
|
|
376
|
+
timestamp: Date.now(),
|
|
377
|
+
pluginVersion: "0.5.1",
|
|
378
|
+
data: { sessions },
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
res.write(`event: snapshot\ndata: ${JSON.stringify(hello)}\n\n`);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// socket may already be closed
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// 2. Subscribe to the event bus. The bus handles keep-alive pings and
|
|
388
|
+
// auto-removal on socket close.
|
|
389
|
+
this.eventBus.subscribe(res, "sse");
|
|
390
|
+
this.logger.info(`SSE client connected (total: ${this.eventBus.size()})`);
|
|
391
|
+
}
|
|
202
392
|
/**
|
|
203
393
|
* Render the index page (lists known tasks).
|
|
204
394
|
*/
|
|
@@ -302,89 +492,79 @@ function redactArgs(args) {
|
|
|
302
492
|
*/
|
|
303
493
|
function renderTaskPage(session) {
|
|
304
494
|
const statusBadge = session.status;
|
|
305
|
-
const
|
|
306
|
-
const
|
|
495
|
+
const totalMs = session.toolCalls.reduce((a, c) => a + c.durationMs, 0);
|
|
496
|
+
const successCount = session.toolCalls.filter((c) => c.success).length;
|
|
497
|
+
const failCount = session.toolCalls.length - successCount;
|
|
498
|
+
const aggregate = aggregateStats(session.toolCalls);
|
|
499
|
+
const statsRows = Object.entries(aggregate)
|
|
500
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
501
|
+
.map(([tool, s]) => `<tr><td><code>${htmlEscape(tool)}</code></td>` +
|
|
502
|
+
`<td>${s.count}</td><td>${s.success}</td><td>${s.fail}</td><td>${s.totalMs} ms</td></tr>`)
|
|
503
|
+
.join("");
|
|
504
|
+
const toolcallsTable = renderToolcallsTable(session.toolCalls);
|
|
307
505
|
const mermaid = buildMermaidDiagram(session);
|
|
308
|
-
const table = renderToolCallsTable(session);
|
|
309
|
-
const prettyArgs = renderPrettyJson(redactSessionArgs(session.toolCalls));
|
|
310
506
|
return /* html */ `<!DOCTYPE html>
|
|
311
507
|
<html lang="en">
|
|
312
508
|
<head>
|
|
313
509
|
<meta charset="utf-8">
|
|
314
|
-
<title>Task #${session.taskId} —
|
|
510
|
+
<title>Task #${session.taskId} — roy-plugin-task-show</title>
|
|
315
511
|
<link rel="stylesheet" href="/static/style.css">
|
|
316
512
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
317
513
|
</head>
|
|
318
|
-
<body>
|
|
514
|
+
<body data-live-refresh data-task-id="${session.taskId}" data-status="${statusBadge}">
|
|
319
515
|
<header class="topbar">
|
|
320
|
-
<a
|
|
321
|
-
<h1
|
|
516
|
+
<a class="back" href="/">← All tasks</a>
|
|
517
|
+
<h1>📊 Task #${session.taskId}</h1>
|
|
518
|
+
<p class="task-title">${htmlEscape(session.title || "(no title)")}</p>
|
|
322
519
|
<p class="meta">
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
· ${
|
|
520
|
+
<span class="badge badge-${statusBadge}">${statusBadge}</span>
|
|
521
|
+
· ${session.toolCalls.length} tool call(s)
|
|
522
|
+
· ${totalMs} ms total
|
|
523
|
+
· ${successCount} ok / ${failCount} fail
|
|
524
|
+
· started ${htmlEscape(fmtTs(session.startedAt))}
|
|
525
|
+
${session.endedAt ? `· ended ${htmlEscape(fmtTs(session.endedAt))}` : ""}
|
|
326
526
|
</p>
|
|
327
|
-
${session.title ? `<p class="task-title">${htmlEscape(session.title)}</p>` : ""}
|
|
328
527
|
</header>
|
|
329
528
|
|
|
330
529
|
<section class="panel">
|
|
331
|
-
<h2
|
|
332
|
-
<
|
|
333
|
-
<thead><tr><th>Tool</th><th>Calls</th><th>Success</th><th>Fail</th><th>Total ms</th><th>Avg ms</th></tr></thead>
|
|
334
|
-
<tbody>
|
|
335
|
-
${Object.entries(toolStats).map(([name, s]) => `
|
|
336
|
-
<tr>
|
|
337
|
-
<td><code>${htmlEscape(name)}</code></td>
|
|
338
|
-
<td>${s.count}</td>
|
|
339
|
-
<td>${s.success}</td>
|
|
340
|
-
<td>${s.fail}</td>
|
|
341
|
-
<td>${s.totalMs}</td>
|
|
342
|
-
<td>${s.count ? Math.round(s.totalMs / s.count) : 0}</td>
|
|
343
|
-
</tr>`).join("")}
|
|
344
|
-
</tbody>
|
|
345
|
-
</table>
|
|
530
|
+
<h2>Execution flow (Mermaid)</h2>
|
|
531
|
+
<div class="mermaid">${mermaid}</div>
|
|
346
532
|
</section>
|
|
347
533
|
|
|
348
534
|
<section class="panel">
|
|
349
|
-
<h2
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
535
|
+
<h2>Tool call stats</h2>
|
|
536
|
+
<table class="stats">
|
|
537
|
+
<thead><tr><th>Tool</th><th>Calls</th><th>OK</th><th>Fail</th><th>Total ms</th></tr></thead>
|
|
538
|
+
<tbody>${statsRows || `<tr><td colspan="5" class="empty">No tool calls recorded.</td></tr>`}</tbody>
|
|
539
|
+
</table>
|
|
354
540
|
</section>
|
|
355
541
|
|
|
356
542
|
<section class="panel">
|
|
357
|
-
<h2
|
|
358
|
-
${
|
|
543
|
+
<h2>Tool calls</h2>
|
|
544
|
+
${toolcallsTable}
|
|
359
545
|
</section>
|
|
360
546
|
|
|
361
547
|
<section class="panel">
|
|
362
|
-
<h2
|
|
548
|
+
<h2>Raw JSON</h2>
|
|
363
549
|
<details>
|
|
364
|
-
<summary>
|
|
365
|
-
<pre class="json">${htmlEscape(
|
|
550
|
+
<summary>Click to expand the raw session payload</summary>
|
|
551
|
+
<pre class="json">${htmlEscape(JSON.stringify(session, null, 2))}</pre>
|
|
366
552
|
</details>
|
|
367
553
|
</section>
|
|
368
554
|
|
|
555
|
+
<script src="/static/app.js"></script>
|
|
369
556
|
<script>
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
window.mermaid.initialize({ startOnLoad: true, securityLevel: 'loose', theme: 'default' });
|
|
374
|
-
}
|
|
375
|
-
});
|
|
557
|
+
if (window.mermaid) {
|
|
558
|
+
window.mermaid.initialize({ startOnLoad: true, theme: 'default', securityLevel: 'loose' });
|
|
559
|
+
}
|
|
376
560
|
</script>
|
|
377
|
-
<script src="/static/app.js"></script>
|
|
378
561
|
</body>
|
|
379
562
|
</html>`;
|
|
380
563
|
}
|
|
381
|
-
function
|
|
382
|
-
|
|
383
|
-
return `<p class="empty">No tool calls have been recorded yet for this task.</p>`;
|
|
384
|
-
}
|
|
385
|
-
const rows = session.toolCalls
|
|
564
|
+
function renderToolcallsTable(toolCalls) {
|
|
565
|
+
const rows = toolCalls
|
|
386
566
|
.map((call) => {
|
|
387
|
-
const argsJson = JSON.stringify(redactArgs(call.args))
|
|
567
|
+
const argsJson = JSON.stringify(redactArgs(call.args));
|
|
388
568
|
const preview = call.error
|
|
389
569
|
? (call.error.slice(0, 200) + (call.error.length > 200 ? "…" : ""))
|
|
390
570
|
: (call.outputPreview.slice(0, 240) + (call.outputPreview.length > 240 ? "…" : ""));
|
|
@@ -436,11 +616,19 @@ function renderIndexPage(sessions) {
|
|
|
436
616
|
<meta charset="utf-8">
|
|
437
617
|
<title>roy-plugin-task-show · index</title>
|
|
438
618
|
<link rel="stylesheet" href="/static/style.css">
|
|
619
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
439
620
|
</head>
|
|
440
|
-
<body>
|
|
621
|
+
<body data-live-refresh>
|
|
441
622
|
<header class="topbar">
|
|
442
623
|
<h1>📊 roy-plugin-task-show</h1>
|
|
443
|
-
<p class="meta"
|
|
624
|
+
<p class="meta">
|
|
625
|
+
${sessions.length} task session(s) collected this run.
|
|
626
|
+
Live updates stream over <code>/api/events</code> (SSE).
|
|
627
|
+
</p>
|
|
628
|
+
<p class="meta connection-status" data-connection>
|
|
629
|
+
<span class="conn-dot conn-disconnected"></span>
|
|
630
|
+
<span class="conn-label">Connecting…</span>
|
|
631
|
+
</p>
|
|
444
632
|
</header>
|
|
445
633
|
<section class="panel">
|
|
446
634
|
<table class="sessions">
|
|
@@ -454,10 +642,17 @@ function renderIndexPage(sessions) {
|
|
|
454
642
|
<h2>How to read this page</h2>
|
|
455
643
|
<ul>
|
|
456
644
|
<li>Click a task ID to open the mermaid flow + tool call table for that task.</li>
|
|
457
|
-
<li>The
|
|
645
|
+
<li>The page subscribes to <code>GET /api/events</code> for real-time updates (created / recorded / completed).</li>
|
|
646
|
+
<li>A 3-second polling fallback keeps the page fresh if SSE is blocked (e.g. behind certain proxies).</li>
|
|
458
647
|
<li>Sessions are kept in memory only. Restart the host process to clear all data.</li>
|
|
459
648
|
</ul>
|
|
460
649
|
</section>
|
|
650
|
+
<script src="/static/app.js"></script>
|
|
651
|
+
<script>
|
|
652
|
+
if (window.mermaid) {
|
|
653
|
+
window.mermaid.initialize({ startOnLoad: true, theme: 'default', securityLevel: 'loose' });
|
|
654
|
+
}
|
|
655
|
+
</script>
|
|
461
656
|
</body>
|
|
462
657
|
</html>`;
|
|
463
658
|
}
|