@openspecui/server 2.1.0 → 2.1.2
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/dist/index.mjs +984 -854
- package/package.json +15 -11
- package/LICENSE +0 -21
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createServer as createServer$1 } from "node:net";
|
|
2
2
|
import { serve } from "@hono/node-server";
|
|
3
|
-
import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema,
|
|
3
|
+
import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli } from "@openspecui/core";
|
|
4
4
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
|
5
5
|
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
|
6
6
|
import { Hono } from "hono";
|
|
@@ -9,15 +9,15 @@ import { readFileSync } from "node:fs";
|
|
|
9
9
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { WebSocketServer } from "ws";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
15
|
+
import { promisify } from "node:util";
|
|
12
16
|
import * as pty from "@lydell/node-pty";
|
|
13
|
-
import { EventEmitter } from "events";
|
|
17
|
+
import { EventEmitter as EventEmitter$1 } from "events";
|
|
14
18
|
import { SearchQuerySchema } from "@openspecui/search";
|
|
15
19
|
import { initTRPC } from "@trpc/server";
|
|
16
20
|
import { observable } from "@trpc/server/observable";
|
|
17
|
-
import { execFile } from "node:child_process";
|
|
18
|
-
import { EventEmitter as EventEmitter$1 } from "node:events";
|
|
19
|
-
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
20
|
-
import { promisify } from "node:util";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { NodeWorkerSearchProvider } from "@openspecui/search/node";
|
|
23
23
|
|
|
@@ -56,454 +56,112 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
//#endregion
|
|
59
|
-
//#region src/
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
command,
|
|
75
|
-
args: opts.args ?? []
|
|
76
|
-
};
|
|
77
|
-
return {
|
|
78
|
-
command: resolveDefaultShell(opts.platform, opts.env),
|
|
79
|
-
args: []
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
var PtySession = class extends EventEmitter {
|
|
83
|
-
id;
|
|
84
|
-
command;
|
|
85
|
-
args;
|
|
86
|
-
platform;
|
|
87
|
-
closeTip;
|
|
88
|
-
closeCallbackUrl;
|
|
89
|
-
createdAt;
|
|
90
|
-
process;
|
|
91
|
-
titleInterval = null;
|
|
92
|
-
lastTitle = "";
|
|
93
|
-
buffer = [];
|
|
94
|
-
bufferByteLength = 0;
|
|
95
|
-
maxBufferLines;
|
|
96
|
-
maxBufferBytes;
|
|
97
|
-
isExited = false;
|
|
98
|
-
exitCode = null;
|
|
99
|
-
constructor(id, opts) {
|
|
100
|
-
super();
|
|
101
|
-
this.id = id;
|
|
102
|
-
this.createdAt = Date.now();
|
|
103
|
-
const resolvedCommand = resolvePtyCommand({
|
|
104
|
-
platform: opts.platform,
|
|
105
|
-
command: opts.command,
|
|
106
|
-
args: opts.args,
|
|
107
|
-
env: process.env
|
|
108
|
-
});
|
|
109
|
-
this.command = resolvedCommand.command;
|
|
110
|
-
this.args = resolvedCommand.args;
|
|
111
|
-
this.platform = opts.platform;
|
|
112
|
-
this.closeTip = opts.closeTip;
|
|
113
|
-
this.closeCallbackUrl = opts.closeCallbackUrl;
|
|
114
|
-
this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
|
|
115
|
-
this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
116
|
-
this.process = pty.spawn(this.command, this.args, {
|
|
117
|
-
name: "xterm-256color",
|
|
118
|
-
cols: opts.cols ?? 80,
|
|
119
|
-
rows: opts.rows ?? 24,
|
|
120
|
-
cwd: opts.cwd,
|
|
121
|
-
env: {
|
|
122
|
-
...process.env,
|
|
123
|
-
TERM: "xterm-256color"
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
this.process.onData((data) => {
|
|
127
|
-
this.appendBuffer(data);
|
|
128
|
-
this.emit("data", data);
|
|
129
|
-
});
|
|
130
|
-
this.process.onExit(({ exitCode }) => {
|
|
131
|
-
if (this.titleInterval) {
|
|
132
|
-
clearInterval(this.titleInterval);
|
|
133
|
-
this.titleInterval = null;
|
|
134
|
-
}
|
|
135
|
-
this.isExited = true;
|
|
136
|
-
this.exitCode = exitCode;
|
|
137
|
-
this.emit("exit", exitCode);
|
|
59
|
+
//#region src/dashboard-overview-service.ts
|
|
60
|
+
const REBUILD_DEBOUNCE_MS$1 = 250;
|
|
61
|
+
var DashboardOverviewService = class {
|
|
62
|
+
current = null;
|
|
63
|
+
initialized = false;
|
|
64
|
+
initPromise = null;
|
|
65
|
+
refreshPromise = null;
|
|
66
|
+
refreshTimer = null;
|
|
67
|
+
pendingRefreshReason = null;
|
|
68
|
+
emitter = new EventEmitter();
|
|
69
|
+
constructor(loadOverview, watcher) {
|
|
70
|
+
this.loadOverview = loadOverview;
|
|
71
|
+
this.emitter.setMaxListeners(200);
|
|
72
|
+
watcher?.on("change", () => {
|
|
73
|
+
this.scheduleRefresh("watcher-change");
|
|
138
74
|
});
|
|
139
|
-
this.titleInterval = setInterval(() => {
|
|
140
|
-
try {
|
|
141
|
-
const title = this.process.process;
|
|
142
|
-
if (title && title !== this.lastTitle) {
|
|
143
|
-
this.lastTitle = title;
|
|
144
|
-
this.emit("title", title);
|
|
145
|
-
}
|
|
146
|
-
} catch {}
|
|
147
|
-
}, 1e3);
|
|
148
|
-
}
|
|
149
|
-
get title() {
|
|
150
|
-
return this.lastTitle;
|
|
151
75
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
this.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
this.
|
|
160
|
-
}
|
|
161
|
-
while (this.buffer.length > this.maxBufferLines) {
|
|
162
|
-
const removed = this.buffer.shift();
|
|
163
|
-
this.bufferByteLength -= removed.length;
|
|
76
|
+
async init() {
|
|
77
|
+
if (this.initialized && this.current) return this.current;
|
|
78
|
+
if (this.initPromise) return this.initPromise;
|
|
79
|
+
this.initPromise = this.refresh("init");
|
|
80
|
+
try {
|
|
81
|
+
return await this.initPromise;
|
|
82
|
+
} finally {
|
|
83
|
+
this.initPromise = null;
|
|
164
84
|
}
|
|
165
85
|
}
|
|
166
|
-
|
|
167
|
-
return this.
|
|
86
|
+
async getCurrent() {
|
|
87
|
+
if (this.current) return this.current;
|
|
88
|
+
return this.init();
|
|
168
89
|
}
|
|
169
|
-
|
|
170
|
-
|
|
90
|
+
subscribe(listener, options) {
|
|
91
|
+
this.emitter.on("change", listener);
|
|
92
|
+
if (options?.emitCurrent) if (this.current) listener(this.current);
|
|
93
|
+
else this.init().catch((error) => {
|
|
94
|
+
options?.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
95
|
+
});
|
|
96
|
+
return () => {
|
|
97
|
+
this.emitter.off("change", listener);
|
|
98
|
+
};
|
|
171
99
|
}
|
|
172
|
-
|
|
173
|
-
|
|
100
|
+
scheduleRefresh(reason = "scheduled-refresh") {
|
|
101
|
+
this.cancelScheduledRefresh();
|
|
102
|
+
this.refreshTimer = setTimeout(() => {
|
|
103
|
+
this.refreshTimer = null;
|
|
104
|
+
this.refresh(reason).catch(() => {});
|
|
105
|
+
}, REBUILD_DEBOUNCE_MS$1);
|
|
174
106
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
this.
|
|
107
|
+
async refresh(reason = "manual-refresh") {
|
|
108
|
+
this.cancelScheduledRefresh();
|
|
109
|
+
if (this.refreshPromise) {
|
|
110
|
+
this.pendingRefreshReason = reason;
|
|
111
|
+
return this.refreshPromise;
|
|
179
112
|
}
|
|
113
|
+
this.refreshPromise = (async () => {
|
|
114
|
+
const next = await this.loadOverview(reason);
|
|
115
|
+
this.current = next;
|
|
116
|
+
this.initialized = true;
|
|
117
|
+
this.emitter.emit("change", next);
|
|
118
|
+
return next;
|
|
119
|
+
})();
|
|
180
120
|
try {
|
|
181
|
-
this.
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
command: this.command,
|
|
190
|
-
args: this.args,
|
|
191
|
-
platform: this.platform,
|
|
192
|
-
isExited: this.isExited,
|
|
193
|
-
exitCode: this.exitCode,
|
|
194
|
-
closeTip: this.closeTip,
|
|
195
|
-
closeCallbackUrl: this.closeCallbackUrl,
|
|
196
|
-
createdAt: this.createdAt
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
};
|
|
200
|
-
var PtyManager = class {
|
|
201
|
-
sessions = /* @__PURE__ */ new Map();
|
|
202
|
-
idCounter = 0;
|
|
203
|
-
platform;
|
|
204
|
-
constructor(defaultCwd) {
|
|
205
|
-
this.defaultCwd = defaultCwd;
|
|
206
|
-
this.platform = detectPtyPlatform();
|
|
207
|
-
}
|
|
208
|
-
create(opts) {
|
|
209
|
-
const id = `pty-${++this.idCounter}`;
|
|
210
|
-
const session = new PtySession(id, {
|
|
211
|
-
cols: opts.cols,
|
|
212
|
-
rows: opts.rows,
|
|
213
|
-
command: opts.command,
|
|
214
|
-
args: opts.args,
|
|
215
|
-
closeTip: opts.closeTip,
|
|
216
|
-
closeCallbackUrl: opts.closeCallbackUrl,
|
|
217
|
-
cwd: this.defaultCwd,
|
|
218
|
-
scrollback: opts.scrollback,
|
|
219
|
-
maxBufferBytes: opts.maxBufferBytes,
|
|
220
|
-
platform: this.platform
|
|
221
|
-
});
|
|
222
|
-
this.sessions.set(id, session);
|
|
223
|
-
return session;
|
|
224
|
-
}
|
|
225
|
-
get(id) {
|
|
226
|
-
return this.sessions.get(id);
|
|
227
|
-
}
|
|
228
|
-
list() {
|
|
229
|
-
const result = [];
|
|
230
|
-
for (const session of this.sessions.values()) result.push(session.toInfo());
|
|
231
|
-
return result;
|
|
232
|
-
}
|
|
233
|
-
write(id, data) {
|
|
234
|
-
this.sessions.get(id)?.write(data);
|
|
235
|
-
}
|
|
236
|
-
resize(id, cols, rows) {
|
|
237
|
-
this.sessions.get(id)?.resize(cols, rows);
|
|
238
|
-
}
|
|
239
|
-
close(id) {
|
|
240
|
-
const session = this.sessions.get(id);
|
|
241
|
-
if (session) {
|
|
242
|
-
session.close();
|
|
243
|
-
this.sessions.delete(id);
|
|
121
|
+
return await this.refreshPromise;
|
|
122
|
+
} finally {
|
|
123
|
+
this.refreshPromise = null;
|
|
124
|
+
if (this.pendingRefreshReason) {
|
|
125
|
+
const pendingReason = this.pendingRefreshReason;
|
|
126
|
+
this.pendingRefreshReason = null;
|
|
127
|
+
this.refresh(pendingReason).catch(() => {});
|
|
128
|
+
}
|
|
244
129
|
}
|
|
245
130
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
this.
|
|
131
|
+
dispose() {
|
|
132
|
+
this.cancelScheduledRefresh();
|
|
133
|
+
this.emitter.removeAllListeners();
|
|
134
|
+
}
|
|
135
|
+
cancelScheduledRefresh() {
|
|
136
|
+
if (!this.refreshTimer) return;
|
|
137
|
+
clearTimeout(this.refreshTimer);
|
|
138
|
+
this.refreshTimer = null;
|
|
249
139
|
}
|
|
250
140
|
};
|
|
251
141
|
|
|
252
142
|
//#endregion
|
|
253
|
-
//#region src/
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
143
|
+
//#region src/dashboard-git-snapshot.ts
|
|
144
|
+
const execFileAsync$1 = promisify(execFile);
|
|
145
|
+
const EMPTY_DIFF = {
|
|
146
|
+
files: 0,
|
|
147
|
+
insertions: 0,
|
|
148
|
+
deletions: 0
|
|
149
|
+
};
|
|
150
|
+
async function defaultRunGit(cwd, args) {
|
|
151
|
+
try {
|
|
152
|
+
const { stdout } = await execFileAsync$1("git", args, {
|
|
153
|
+
cwd,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
maxBuffer: 8 * 1024 * 1024
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
stdout
|
|
259
160
|
};
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
message,
|
|
265
|
-
sessionId: opts?.sessionId
|
|
266
|
-
});
|
|
267
|
-
};
|
|
268
|
-
const attachToSession = (session, opts) => {
|
|
269
|
-
const sessionId = session.id;
|
|
270
|
-
cleanups.get(sessionId)?.();
|
|
271
|
-
if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
|
|
272
|
-
const onData = (data) => {
|
|
273
|
-
send({
|
|
274
|
-
type: "output",
|
|
275
|
-
sessionId,
|
|
276
|
-
data
|
|
277
|
-
});
|
|
278
|
-
};
|
|
279
|
-
const onExit = (exitCode) => {
|
|
280
|
-
send({
|
|
281
|
-
type: "exit",
|
|
282
|
-
sessionId,
|
|
283
|
-
exitCode
|
|
284
|
-
});
|
|
285
|
-
};
|
|
286
|
-
const onTitle = (title) => {
|
|
287
|
-
send({
|
|
288
|
-
type: "title",
|
|
289
|
-
sessionId,
|
|
290
|
-
title
|
|
291
|
-
});
|
|
292
|
-
};
|
|
293
|
-
session.on("data", onData);
|
|
294
|
-
session.on("exit", onExit);
|
|
295
|
-
session.on("title", onTitle);
|
|
296
|
-
cleanups.set(sessionId, () => {
|
|
297
|
-
session.removeListener("data", onData);
|
|
298
|
-
session.removeListener("exit", onExit);
|
|
299
|
-
session.removeListener("title", onTitle);
|
|
300
|
-
cleanups.delete(sessionId);
|
|
301
|
-
});
|
|
302
|
-
};
|
|
303
|
-
ws.on("message", (raw) => {
|
|
304
|
-
let parsed;
|
|
305
|
-
try {
|
|
306
|
-
parsed = JSON.parse(String(raw));
|
|
307
|
-
} catch {
|
|
308
|
-
sendError("INVALID_JSON", "Invalid JSON payload");
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
|
|
312
|
-
if (!parsedMessage.success) {
|
|
313
|
-
const firstIssue = parsedMessage.error.issues[0]?.message;
|
|
314
|
-
sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
const msg = parsedMessage.data;
|
|
318
|
-
switch (msg.type) {
|
|
319
|
-
case "create":
|
|
320
|
-
try {
|
|
321
|
-
const createMessage = msg;
|
|
322
|
-
const session = ptyManager.create({
|
|
323
|
-
cols: msg.cols,
|
|
324
|
-
rows: msg.rows,
|
|
325
|
-
command: msg.command,
|
|
326
|
-
args: msg.args,
|
|
327
|
-
closeTip: createMessage.closeTip,
|
|
328
|
-
closeCallbackUrl: createMessage.closeCallbackUrl
|
|
329
|
-
});
|
|
330
|
-
send({
|
|
331
|
-
type: "created",
|
|
332
|
-
requestId: msg.requestId,
|
|
333
|
-
sessionId: session.id,
|
|
334
|
-
platform: session.platform
|
|
335
|
-
});
|
|
336
|
-
attachToSession(session);
|
|
337
|
-
} catch (err) {
|
|
338
|
-
sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
|
|
339
|
-
}
|
|
340
|
-
break;
|
|
341
|
-
case "attach": {
|
|
342
|
-
const session = ptyManager.get(msg.sessionId);
|
|
343
|
-
if (!session) {
|
|
344
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
345
|
-
send({
|
|
346
|
-
type: "exit",
|
|
347
|
-
sessionId: msg.sessionId,
|
|
348
|
-
exitCode: -1
|
|
349
|
-
});
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
attachToSession(session, {
|
|
353
|
-
cols: msg.cols,
|
|
354
|
-
rows: msg.rows
|
|
355
|
-
});
|
|
356
|
-
const buffer = session.getBuffer();
|
|
357
|
-
if (buffer) send({
|
|
358
|
-
type: "buffer",
|
|
359
|
-
sessionId: session.id,
|
|
360
|
-
data: buffer
|
|
361
|
-
});
|
|
362
|
-
if (session.title) send({
|
|
363
|
-
type: "title",
|
|
364
|
-
sessionId: session.id,
|
|
365
|
-
title: session.title
|
|
366
|
-
});
|
|
367
|
-
if (session.isExited) send({
|
|
368
|
-
type: "exit",
|
|
369
|
-
sessionId: session.id,
|
|
370
|
-
exitCode: session.exitCode ?? -1
|
|
371
|
-
});
|
|
372
|
-
break;
|
|
373
|
-
}
|
|
374
|
-
case "list":
|
|
375
|
-
send({
|
|
376
|
-
type: "list",
|
|
377
|
-
sessions: ptyManager.list().map((s) => ({
|
|
378
|
-
id: s.id,
|
|
379
|
-
title: s.title,
|
|
380
|
-
command: s.command,
|
|
381
|
-
args: s.args,
|
|
382
|
-
platform: s.platform,
|
|
383
|
-
isExited: s.isExited,
|
|
384
|
-
exitCode: s.exitCode,
|
|
385
|
-
closeTip: s.closeTip,
|
|
386
|
-
closeCallbackUrl: s.closeCallbackUrl
|
|
387
|
-
}))
|
|
388
|
-
});
|
|
389
|
-
break;
|
|
390
|
-
case "input": {
|
|
391
|
-
const session = ptyManager.get(msg.sessionId);
|
|
392
|
-
if (!session) {
|
|
393
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
394
|
-
break;
|
|
395
|
-
}
|
|
396
|
-
session.write(msg.data);
|
|
397
|
-
break;
|
|
398
|
-
}
|
|
399
|
-
case "resize": {
|
|
400
|
-
const session = ptyManager.get(msg.sessionId);
|
|
401
|
-
if (!session) {
|
|
402
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
405
|
-
session.resize(msg.cols, msg.rows);
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
case "close": {
|
|
409
|
-
const session = ptyManager.get(msg.sessionId);
|
|
410
|
-
if (!session) {
|
|
411
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
412
|
-
break;
|
|
413
|
-
}
|
|
414
|
-
cleanups.get(msg.sessionId)?.();
|
|
415
|
-
ptyManager.close(session.id);
|
|
416
|
-
break;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
ws.on("close", () => {
|
|
421
|
-
for (const cleanup of cleanups.values()) cleanup();
|
|
422
|
-
cleanups.clear();
|
|
423
|
-
});
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
//#endregion
|
|
428
|
-
//#region src/cli-stream-observable.ts
|
|
429
|
-
/**
|
|
430
|
-
* 创建安全的 CLI 流式 observable
|
|
431
|
-
*
|
|
432
|
-
* 解决的问题:
|
|
433
|
-
* 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
|
|
434
|
-
* 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
|
|
435
|
-
* 3. 确保取消时正确清理资源
|
|
436
|
-
*
|
|
437
|
-
* @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
|
|
438
|
-
*/
|
|
439
|
-
function createCliStreamObservable(startStream) {
|
|
440
|
-
return observable((emit) => {
|
|
441
|
-
let cancel;
|
|
442
|
-
let completed = false;
|
|
443
|
-
/**
|
|
444
|
-
* 安全的事件处理器
|
|
445
|
-
* - 检查是否已完成,防止重复调用
|
|
446
|
-
* - 使用 try-catch 防止异常导致服务器崩溃
|
|
447
|
-
*/
|
|
448
|
-
const safeEventHandler = (event) => {
|
|
449
|
-
if (completed) return;
|
|
450
|
-
try {
|
|
451
|
-
emit.next(event);
|
|
452
|
-
if (event.type === "exit") {
|
|
453
|
-
completed = true;
|
|
454
|
-
emit.complete();
|
|
455
|
-
}
|
|
456
|
-
} catch (err) {
|
|
457
|
-
console.error("[CLI Stream] Error emitting event:", err);
|
|
458
|
-
if (!completed) {
|
|
459
|
-
completed = true;
|
|
460
|
-
try {
|
|
461
|
-
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
462
|
-
} catch {}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
};
|
|
466
|
-
startStream(safeEventHandler).then((cancelFn) => {
|
|
467
|
-
cancel = cancelFn;
|
|
468
|
-
}).catch((err) => {
|
|
469
|
-
console.error("[CLI Stream] Error starting stream:", err);
|
|
470
|
-
if (!completed) {
|
|
471
|
-
completed = true;
|
|
472
|
-
try {
|
|
473
|
-
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
474
|
-
} catch {}
|
|
475
|
-
}
|
|
476
|
-
});
|
|
477
|
-
return () => {
|
|
478
|
-
completed = true;
|
|
479
|
-
cancel?.();
|
|
480
|
-
};
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
//#endregion
|
|
485
|
-
//#region src/dashboard-git-snapshot.ts
|
|
486
|
-
const execFileAsync$1 = promisify(execFile);
|
|
487
|
-
const EMPTY_DIFF = {
|
|
488
|
-
files: 0,
|
|
489
|
-
insertions: 0,
|
|
490
|
-
deletions: 0
|
|
491
|
-
};
|
|
492
|
-
async function defaultRunGit(cwd, args) {
|
|
493
|
-
try {
|
|
494
|
-
const { stdout } = await execFileAsync$1("git", args, {
|
|
495
|
-
cwd,
|
|
496
|
-
encoding: "utf8",
|
|
497
|
-
maxBuffer: 8 * 1024 * 1024
|
|
498
|
-
});
|
|
499
|
-
return {
|
|
500
|
-
ok: true,
|
|
501
|
-
stdout
|
|
502
|
-
};
|
|
503
|
-
} catch {
|
|
504
|
-
return {
|
|
505
|
-
ok: false,
|
|
506
|
-
stdout: ""
|
|
161
|
+
} catch {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
stdout: ""
|
|
507
165
|
};
|
|
508
166
|
}
|
|
509
167
|
}
|
|
@@ -712,6 +370,7 @@ async function collectWorktree(options) {
|
|
|
712
370
|
path: worktreePath,
|
|
713
371
|
relativePath: relativePath(resolvedProjectDir, worktreePath),
|
|
714
372
|
branchName: parseBranchName(worktree.branchRef, worktree.detached),
|
|
373
|
+
detached: worktree.detached,
|
|
715
374
|
isCurrent: resolvedProjectDir === worktreePath,
|
|
716
375
|
ahead,
|
|
717
376
|
behind,
|
|
@@ -719,6 +378,27 @@ async function collectWorktree(options) {
|
|
|
719
378
|
entries
|
|
720
379
|
};
|
|
721
380
|
}
|
|
381
|
+
async function removeDetachedDashboardGitWorktree(options) {
|
|
382
|
+
const runGit = options.runGit ?? defaultRunGit;
|
|
383
|
+
const resolvedProjectDir = resolve(options.projectDir);
|
|
384
|
+
const resolvedTargetPath = resolve(options.targetPath);
|
|
385
|
+
if (resolvedTargetPath === resolvedProjectDir) throw new Error("Cannot remove the current worktree.");
|
|
386
|
+
const worktreeResult = await runGit(resolvedProjectDir, [
|
|
387
|
+
"worktree",
|
|
388
|
+
"list",
|
|
389
|
+
"--porcelain"
|
|
390
|
+
]);
|
|
391
|
+
if (!worktreeResult.ok) throw new Error("Failed to inspect git worktrees.");
|
|
392
|
+
const matched = parseWorktreeList(worktreeResult.stdout).find((worktree) => resolve(worktree.path) === resolvedTargetPath);
|
|
393
|
+
if (!matched) throw new Error("Worktree not found.");
|
|
394
|
+
if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
|
|
395
|
+
if (!(await runGit(resolvedProjectDir, [
|
|
396
|
+
"worktree",
|
|
397
|
+
"remove",
|
|
398
|
+
"--force",
|
|
399
|
+
resolvedTargetPath
|
|
400
|
+
])).ok) throw new Error("Failed to remove detached worktree.");
|
|
401
|
+
}
|
|
722
402
|
async function buildDashboardGitSnapshot(options) {
|
|
723
403
|
const runGit = options.runGit ?? defaultRunGit;
|
|
724
404
|
const maxCommitEntries = options.maxCommitEntries ?? 8;
|
|
@@ -753,95 +433,783 @@ async function buildDashboardGitSnapshot(options) {
|
|
|
753
433
|
}
|
|
754
434
|
|
|
755
435
|
//#endregion
|
|
756
|
-
//#region src/dashboard-time-trends.ts
|
|
757
|
-
const MIN_TREND_POINT_LIMIT = 20;
|
|
758
|
-
const MAX_TREND_POINT_LIMIT = 500;
|
|
759
|
-
const DEFAULT_TREND_POINT_LIMIT = 100;
|
|
760
|
-
const TARGET_TREND_BARS = 20;
|
|
761
|
-
const DAY_MS = 1440 * 60 * 1e3;
|
|
762
|
-
function clampPointLimit(pointLimit) {
|
|
763
|
-
if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
|
|
764
|
-
return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
|
|
765
|
-
}
|
|
766
|
-
function createEmptyTrendSeries() {
|
|
767
|
-
return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
|
|
768
|
-
}
|
|
769
|
-
function normalizeEvents(events, pointLimit) {
|
|
770
|
-
return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
|
|
771
|
-
}
|
|
772
|
-
function buildTimeWindow(options) {
|
|
773
|
-
const { probeEvents, targetBars, rightEdgeTs } = options;
|
|
774
|
-
if (probeEvents.length === 0) return null;
|
|
775
|
-
const probeEnd = probeEvents[probeEvents.length - 1].ts;
|
|
776
|
-
const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
|
|
777
|
-
const probeStart = probeEvents[0].ts;
|
|
778
|
-
const rangeMs = Math.max(1, end - probeStart);
|
|
779
|
-
const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
|
|
780
|
-
const windowStart = end - bucketMs * targetBars;
|
|
781
|
-
return {
|
|
782
|
-
windowStart,
|
|
783
|
-
bucketMs,
|
|
784
|
-
bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
|
|
785
|
-
};
|
|
786
|
-
}
|
|
787
|
-
function bucketizeTrend(events, reducer, rightEdgeTs) {
|
|
788
|
-
if (events.length === 0) return [];
|
|
789
|
-
const timeWindow = buildTimeWindow({
|
|
790
|
-
probeEvents: events,
|
|
791
|
-
targetBars: TARGET_TREND_BARS,
|
|
792
|
-
rightEdgeTs
|
|
793
|
-
});
|
|
794
|
-
if (!timeWindow) return [];
|
|
795
|
-
const { windowStart, bucketMs, bucketEnds } = timeWindow;
|
|
796
|
-
const sums = Array.from({ length: bucketEnds.length }, () => 0);
|
|
797
|
-
const counts = Array.from({ length: bucketEnds.length }, () => 0);
|
|
798
|
-
let baseline = 0;
|
|
799
|
-
for (const event of events) {
|
|
800
|
-
if (event.ts <= windowStart) {
|
|
801
|
-
if (reducer === "sum-cumulative") baseline += event.value;
|
|
802
|
-
continue;
|
|
803
|
-
}
|
|
804
|
-
const offset = event.ts - windowStart;
|
|
805
|
-
const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
|
|
806
|
-
sums[index] += event.value;
|
|
807
|
-
counts[index] += 1;
|
|
808
|
-
}
|
|
809
|
-
let cumulative = baseline;
|
|
810
|
-
let carry = baseline !== 0 ? baseline : events[0].value;
|
|
811
|
-
return bucketEnds.map((ts, index) => {
|
|
812
|
-
if (reducer === "sum") return {
|
|
813
|
-
ts,
|
|
814
|
-
value: sums[index]
|
|
436
|
+
//#region src/dashboard-time-trends.ts
|
|
437
|
+
const MIN_TREND_POINT_LIMIT = 20;
|
|
438
|
+
const MAX_TREND_POINT_LIMIT = 500;
|
|
439
|
+
const DEFAULT_TREND_POINT_LIMIT = 100;
|
|
440
|
+
const TARGET_TREND_BARS = 20;
|
|
441
|
+
const DAY_MS = 1440 * 60 * 1e3;
|
|
442
|
+
function clampPointLimit(pointLimit) {
|
|
443
|
+
if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
|
|
444
|
+
return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
|
|
445
|
+
}
|
|
446
|
+
function createEmptyTrendSeries() {
|
|
447
|
+
return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
|
|
448
|
+
}
|
|
449
|
+
function normalizeEvents(events, pointLimit) {
|
|
450
|
+
return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
|
|
451
|
+
}
|
|
452
|
+
function buildTimeWindow(options) {
|
|
453
|
+
const { probeEvents, targetBars, rightEdgeTs } = options;
|
|
454
|
+
if (probeEvents.length === 0) return null;
|
|
455
|
+
const probeEnd = probeEvents[probeEvents.length - 1].ts;
|
|
456
|
+
const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
|
|
457
|
+
const probeStart = probeEvents[0].ts;
|
|
458
|
+
const rangeMs = Math.max(1, end - probeStart);
|
|
459
|
+
const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
|
|
460
|
+
const windowStart = end - bucketMs * targetBars;
|
|
461
|
+
return {
|
|
462
|
+
windowStart,
|
|
463
|
+
bucketMs,
|
|
464
|
+
bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function bucketizeTrend(events, reducer, rightEdgeTs) {
|
|
468
|
+
if (events.length === 0) return [];
|
|
469
|
+
const timeWindow = buildTimeWindow({
|
|
470
|
+
probeEvents: events,
|
|
471
|
+
targetBars: TARGET_TREND_BARS,
|
|
472
|
+
rightEdgeTs
|
|
473
|
+
});
|
|
474
|
+
if (!timeWindow) return [];
|
|
475
|
+
const { windowStart, bucketMs, bucketEnds } = timeWindow;
|
|
476
|
+
const sums = Array.from({ length: bucketEnds.length }, () => 0);
|
|
477
|
+
const counts = Array.from({ length: bucketEnds.length }, () => 0);
|
|
478
|
+
let baseline = 0;
|
|
479
|
+
for (const event of events) {
|
|
480
|
+
if (event.ts <= windowStart) {
|
|
481
|
+
if (reducer === "sum-cumulative") baseline += event.value;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const offset = event.ts - windowStart;
|
|
485
|
+
const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
|
|
486
|
+
sums[index] += event.value;
|
|
487
|
+
counts[index] += 1;
|
|
488
|
+
}
|
|
489
|
+
let cumulative = baseline;
|
|
490
|
+
let carry = baseline !== 0 ? baseline : events[0].value;
|
|
491
|
+
return bucketEnds.map((ts, index) => {
|
|
492
|
+
if (reducer === "sum") return {
|
|
493
|
+
ts,
|
|
494
|
+
value: sums[index]
|
|
495
|
+
};
|
|
496
|
+
if (reducer === "sum-cumulative") {
|
|
497
|
+
cumulative += sums[index];
|
|
498
|
+
return {
|
|
499
|
+
ts,
|
|
500
|
+
value: cumulative
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (counts[index] > 0) carry = sums[index] / counts[index];
|
|
504
|
+
return {
|
|
505
|
+
ts,
|
|
506
|
+
value: carry
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
function buildDashboardTimeTrends(options) {
|
|
511
|
+
const pointLimit = clampPointLimit(options.pointLimit);
|
|
512
|
+
const trends = createEmptyTrendSeries();
|
|
513
|
+
for (const metric of DASHBOARD_METRIC_KEYS) {
|
|
514
|
+
if (options.availability[metric].state !== "ok") continue;
|
|
515
|
+
trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
trends,
|
|
519
|
+
trendMeta: {
|
|
520
|
+
pointLimit,
|
|
521
|
+
lastUpdatedAt: options.timestamp
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/dashboard-overview.ts
|
|
528
|
+
const execFileAsync = promisify(execFile);
|
|
529
|
+
const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
|
|
530
|
+
const dashboardGitTaskStatusEmitter = new EventEmitter();
|
|
531
|
+
dashboardGitTaskStatusEmitter.setMaxListeners(200);
|
|
532
|
+
const dashboardGitTaskStatus = {
|
|
533
|
+
running: false,
|
|
534
|
+
inFlight: 0,
|
|
535
|
+
lastStartedAt: null,
|
|
536
|
+
lastFinishedAt: null,
|
|
537
|
+
lastReason: null,
|
|
538
|
+
lastError: null
|
|
539
|
+
};
|
|
540
|
+
function createEmptyTriColorTrends() {
|
|
541
|
+
return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
|
|
542
|
+
}
|
|
543
|
+
function resolveTrendTimestamp(primary, secondary) {
|
|
544
|
+
if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
|
|
545
|
+
if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
function parseDatedIdTimestamp(id) {
|
|
549
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
|
|
550
|
+
if (!match) return null;
|
|
551
|
+
const year = Number(match[1]);
|
|
552
|
+
const month = Number(match[2]);
|
|
553
|
+
const day = Number(match[3]);
|
|
554
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
|
|
555
|
+
if (month < 1 || month > 12) return null;
|
|
556
|
+
if (day < 1 || day > 31) return null;
|
|
557
|
+
const ts = Date.UTC(year, month - 1, day);
|
|
558
|
+
return Number.isFinite(ts) ? ts : null;
|
|
559
|
+
}
|
|
560
|
+
async function readLatestCommitTimestamp(projectDir) {
|
|
561
|
+
try {
|
|
562
|
+
const { stdout } = await execFileAsync("git", [
|
|
563
|
+
"log",
|
|
564
|
+
"-1",
|
|
565
|
+
"--format=%ct"
|
|
566
|
+
], {
|
|
567
|
+
cwd: projectDir,
|
|
568
|
+
maxBuffer: 1024 * 1024,
|
|
569
|
+
encoding: "utf8"
|
|
570
|
+
});
|
|
571
|
+
const seconds = Number(stdout.trim());
|
|
572
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function emitDashboardGitTaskStatus() {
|
|
578
|
+
dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
|
|
579
|
+
}
|
|
580
|
+
function beginDashboardGitTask(reason) {
|
|
581
|
+
dashboardGitTaskStatus.inFlight += 1;
|
|
582
|
+
dashboardGitTaskStatus.running = true;
|
|
583
|
+
dashboardGitTaskStatus.lastStartedAt = Date.now();
|
|
584
|
+
dashboardGitTaskStatus.lastReason = reason;
|
|
585
|
+
dashboardGitTaskStatus.lastError = null;
|
|
586
|
+
emitDashboardGitTaskStatus();
|
|
587
|
+
}
|
|
588
|
+
function endDashboardGitTask(error) {
|
|
589
|
+
dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
|
|
590
|
+
dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
|
|
591
|
+
dashboardGitTaskStatus.lastFinishedAt = Date.now();
|
|
592
|
+
if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
|
|
593
|
+
emitDashboardGitTaskStatus();
|
|
594
|
+
}
|
|
595
|
+
function getDashboardGitTaskStatus() {
|
|
596
|
+
return { ...dashboardGitTaskStatus };
|
|
597
|
+
}
|
|
598
|
+
function subscribeDashboardGitTaskStatus(listener) {
|
|
599
|
+
dashboardGitTaskStatusEmitter.on("change", listener);
|
|
600
|
+
return () => {
|
|
601
|
+
dashboardGitTaskStatusEmitter.off("change", listener);
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
async function resolveGitMetadataDir(projectDir) {
|
|
605
|
+
try {
|
|
606
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
|
|
607
|
+
cwd: projectDir,
|
|
608
|
+
maxBuffer: 1024 * 1024,
|
|
609
|
+
encoding: "utf8"
|
|
610
|
+
});
|
|
611
|
+
const gitDirRaw = stdout.trim();
|
|
612
|
+
if (!gitDirRaw) return null;
|
|
613
|
+
const gitDirPath = resolve(projectDir, gitDirRaw);
|
|
614
|
+
if (!(await stat(gitDirPath)).isDirectory()) return null;
|
|
615
|
+
return gitDirPath;
|
|
616
|
+
} catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function getDashboardGitRefreshStampPath(gitMetadataDir) {
|
|
621
|
+
return join(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
|
|
622
|
+
}
|
|
623
|
+
async function touchDashboardGitRefreshStamp(projectDir, reason) {
|
|
624
|
+
const gitMetadataDir = await resolveGitMetadataDir(projectDir);
|
|
625
|
+
if (!gitMetadataDir) return { skipped: true };
|
|
626
|
+
const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
|
|
627
|
+
await mkdir(dirname(stampPath), { recursive: true });
|
|
628
|
+
await writeFile(stampPath, `${Date.now()} ${reason}\n`, "utf8");
|
|
629
|
+
return { skipped: false };
|
|
630
|
+
}
|
|
631
|
+
async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
const [specMetas, changeMetas, archiveMetas] = await Promise.all([
|
|
634
|
+
ctx.adapter.listSpecsWithMeta(),
|
|
635
|
+
ctx.adapter.listChangesWithMeta(),
|
|
636
|
+
ctx.adapter.listArchivedChangesWithMeta()
|
|
637
|
+
]);
|
|
638
|
+
const activeChanges = changeMetas.map((changeMeta) => ({
|
|
639
|
+
id: changeMeta.id,
|
|
640
|
+
name: changeMeta.name ?? changeMeta.id,
|
|
641
|
+
progress: changeMeta.progress,
|
|
642
|
+
updatedAt: changeMeta.updatedAt
|
|
643
|
+
})).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
644
|
+
const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
|
|
645
|
+
const change = await ctx.adapter.readArchivedChange(meta.id);
|
|
646
|
+
if (!change) return null;
|
|
647
|
+
return {
|
|
648
|
+
id: meta.id,
|
|
649
|
+
createdAt: meta.createdAt,
|
|
650
|
+
updatedAt: meta.updatedAt,
|
|
651
|
+
tasksCompleted: change.tasks.filter((task) => task.completed).length
|
|
652
|
+
};
|
|
653
|
+
}))).filter((item) => item !== null);
|
|
654
|
+
const specifications = (await Promise.all(specMetas.map(async (meta) => {
|
|
655
|
+
const spec = await ctx.adapter.readSpec(meta.id);
|
|
656
|
+
if (!spec) return null;
|
|
657
|
+
return {
|
|
658
|
+
id: meta.id,
|
|
659
|
+
name: meta.name,
|
|
660
|
+
requirements: spec.requirements.length,
|
|
661
|
+
updatedAt: meta.updatedAt
|
|
662
|
+
};
|
|
663
|
+
}))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
|
|
664
|
+
const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
|
|
665
|
+
const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
|
|
666
|
+
const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
|
|
667
|
+
const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
|
|
668
|
+
const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
|
|
669
|
+
const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
|
|
670
|
+
const specificationTrendEvents = specMetas.flatMap((spec) => {
|
|
671
|
+
const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
|
|
672
|
+
return ts === null ? [] : [{
|
|
673
|
+
ts,
|
|
674
|
+
value: 1
|
|
675
|
+
}];
|
|
676
|
+
});
|
|
677
|
+
const completedTrendEvents = archivedChanges.flatMap((archive) => {
|
|
678
|
+
const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
|
|
679
|
+
return ts === null ? [] : [{
|
|
680
|
+
ts,
|
|
681
|
+
value: archive.tasksCompleted
|
|
682
|
+
}];
|
|
683
|
+
});
|
|
684
|
+
const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
|
|
685
|
+
const requirementTrendEvents = specifications.flatMap((spec) => {
|
|
686
|
+
const meta = specMetaById.get(spec.id);
|
|
687
|
+
const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
|
|
688
|
+
return ts === null ? [] : [{
|
|
689
|
+
ts,
|
|
690
|
+
value: spec.requirements
|
|
691
|
+
}];
|
|
692
|
+
});
|
|
693
|
+
const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
|
|
694
|
+
const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
|
|
695
|
+
const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
|
|
696
|
+
const config = await ctx.configManager.readConfig();
|
|
697
|
+
beginDashboardGitTask(reason);
|
|
698
|
+
let latestCommitTs = null;
|
|
699
|
+
let git;
|
|
700
|
+
try {
|
|
701
|
+
const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
|
|
702
|
+
defaultBranch: "main",
|
|
703
|
+
worktrees: []
|
|
704
|
+
}));
|
|
705
|
+
latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
|
|
706
|
+
git = await gitSnapshotPromise;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
endDashboardGitTask(error);
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
endDashboardGitTask(null);
|
|
712
|
+
const cardAvailability = {
|
|
713
|
+
specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
|
|
714
|
+
state: "invalid",
|
|
715
|
+
reason: "objective-history-unavailable"
|
|
716
|
+
},
|
|
717
|
+
requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
|
|
718
|
+
state: "invalid",
|
|
719
|
+
reason: "objective-history-unavailable"
|
|
720
|
+
},
|
|
721
|
+
activeChanges: {
|
|
722
|
+
state: "invalid",
|
|
723
|
+
reason: "objective-history-unavailable"
|
|
724
|
+
},
|
|
725
|
+
inProgressChanges: {
|
|
726
|
+
state: "invalid",
|
|
727
|
+
reason: "objective-history-unavailable"
|
|
728
|
+
},
|
|
729
|
+
completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
|
|
730
|
+
state: "invalid",
|
|
731
|
+
reason: "objective-history-unavailable"
|
|
732
|
+
},
|
|
733
|
+
taskCompletionPercent: {
|
|
734
|
+
state: "invalid",
|
|
735
|
+
reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
const trendKinds = {
|
|
739
|
+
specifications: "monotonic",
|
|
740
|
+
requirements: "monotonic",
|
|
741
|
+
activeChanges: "bidirectional",
|
|
742
|
+
inProgressChanges: "bidirectional",
|
|
743
|
+
completedChanges: "monotonic",
|
|
744
|
+
taskCompletionPercent: "bidirectional"
|
|
745
|
+
};
|
|
746
|
+
const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
|
|
747
|
+
pointLimit: config.dashboard.trendPointLimit,
|
|
748
|
+
timestamp: now,
|
|
749
|
+
rightEdgeTs: latestCommitTs,
|
|
750
|
+
availability: cardAvailability,
|
|
751
|
+
events: {
|
|
752
|
+
specifications: specificationTrendEvents,
|
|
753
|
+
requirements: requirementTrendEvents,
|
|
754
|
+
activeChanges: [],
|
|
755
|
+
inProgressChanges: [],
|
|
756
|
+
completedChanges: completedTrendEvents,
|
|
757
|
+
taskCompletionPercent: []
|
|
758
|
+
},
|
|
759
|
+
reducers: {
|
|
760
|
+
specifications: "sum",
|
|
761
|
+
requirements: "sum",
|
|
762
|
+
completedChanges: "sum"
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
return {
|
|
766
|
+
summary: {
|
|
767
|
+
specifications: specifications.length,
|
|
768
|
+
requirements,
|
|
769
|
+
activeChanges: activeChanges.length,
|
|
770
|
+
inProgressChanges,
|
|
771
|
+
completedChanges: archiveMetas.length,
|
|
772
|
+
archivedTasksCompleted,
|
|
773
|
+
tasksTotal,
|
|
774
|
+
tasksCompleted,
|
|
775
|
+
taskCompletionPercent
|
|
776
|
+
},
|
|
777
|
+
trends: baselineTrends,
|
|
778
|
+
triColorTrends: createEmptyTriColorTrends(),
|
|
779
|
+
trendKinds,
|
|
780
|
+
cardAvailability,
|
|
781
|
+
trendMeta,
|
|
782
|
+
specifications,
|
|
783
|
+
activeChanges,
|
|
784
|
+
git
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
//#endregion
|
|
789
|
+
//#region src/pty-manager.ts
|
|
790
|
+
const DEFAULT_SCROLLBACK = 1e3;
|
|
791
|
+
const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
792
|
+
function detectPtyPlatform() {
|
|
793
|
+
if (process.platform === "win32") return "windows";
|
|
794
|
+
if (process.platform === "darwin") return "macos";
|
|
795
|
+
return "common";
|
|
796
|
+
}
|
|
797
|
+
function resolveDefaultShell(platform, env) {
|
|
798
|
+
if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
|
|
799
|
+
return env.SHELL?.trim() || "/bin/sh";
|
|
800
|
+
}
|
|
801
|
+
function resolvePtyCommand(opts) {
|
|
802
|
+
const command = opts.command?.trim();
|
|
803
|
+
if (command) return {
|
|
804
|
+
command,
|
|
805
|
+
args: opts.args ?? []
|
|
806
|
+
};
|
|
807
|
+
return {
|
|
808
|
+
command: resolveDefaultShell(opts.platform, opts.env),
|
|
809
|
+
args: []
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
var PtySession = class extends EventEmitter$1 {
|
|
813
|
+
id;
|
|
814
|
+
command;
|
|
815
|
+
args;
|
|
816
|
+
platform;
|
|
817
|
+
closeTip;
|
|
818
|
+
closeCallbackUrl;
|
|
819
|
+
createdAt;
|
|
820
|
+
process;
|
|
821
|
+
titleInterval = null;
|
|
822
|
+
lastTitle = "";
|
|
823
|
+
buffer = [];
|
|
824
|
+
bufferByteLength = 0;
|
|
825
|
+
maxBufferLines;
|
|
826
|
+
maxBufferBytes;
|
|
827
|
+
isExited = false;
|
|
828
|
+
exitCode = null;
|
|
829
|
+
constructor(id, opts) {
|
|
830
|
+
super();
|
|
831
|
+
this.id = id;
|
|
832
|
+
this.createdAt = Date.now();
|
|
833
|
+
const resolvedCommand = resolvePtyCommand({
|
|
834
|
+
platform: opts.platform,
|
|
835
|
+
command: opts.command,
|
|
836
|
+
args: opts.args,
|
|
837
|
+
env: process.env
|
|
838
|
+
});
|
|
839
|
+
this.command = resolvedCommand.command;
|
|
840
|
+
this.args = resolvedCommand.args;
|
|
841
|
+
this.platform = opts.platform;
|
|
842
|
+
this.closeTip = opts.closeTip;
|
|
843
|
+
this.closeCallbackUrl = opts.closeCallbackUrl;
|
|
844
|
+
this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
|
|
845
|
+
this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
846
|
+
this.process = pty.spawn(this.command, this.args, {
|
|
847
|
+
name: "xterm-256color",
|
|
848
|
+
cols: opts.cols ?? 80,
|
|
849
|
+
rows: opts.rows ?? 24,
|
|
850
|
+
cwd: opts.cwd,
|
|
851
|
+
env: {
|
|
852
|
+
...process.env,
|
|
853
|
+
TERM: "xterm-256color"
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
this.process.onData((data) => {
|
|
857
|
+
this.appendBuffer(data);
|
|
858
|
+
this.emit("data", data);
|
|
859
|
+
});
|
|
860
|
+
this.process.onExit(({ exitCode }) => {
|
|
861
|
+
if (this.titleInterval) {
|
|
862
|
+
clearInterval(this.titleInterval);
|
|
863
|
+
this.titleInterval = null;
|
|
864
|
+
}
|
|
865
|
+
this.isExited = true;
|
|
866
|
+
this.exitCode = exitCode;
|
|
867
|
+
this.emit("exit", exitCode);
|
|
868
|
+
});
|
|
869
|
+
this.titleInterval = setInterval(() => {
|
|
870
|
+
try {
|
|
871
|
+
const title = this.process.process;
|
|
872
|
+
if (title && title !== this.lastTitle) {
|
|
873
|
+
this.lastTitle = title;
|
|
874
|
+
this.emit("title", title);
|
|
875
|
+
}
|
|
876
|
+
} catch {}
|
|
877
|
+
}, 1e3);
|
|
878
|
+
}
|
|
879
|
+
get title() {
|
|
880
|
+
return this.lastTitle;
|
|
881
|
+
}
|
|
882
|
+
appendBuffer(data) {
|
|
883
|
+
let chunk = data;
|
|
884
|
+
if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
|
|
885
|
+
this.buffer.push(chunk);
|
|
886
|
+
this.bufferByteLength += chunk.length;
|
|
887
|
+
while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
|
|
888
|
+
const removed = this.buffer.shift();
|
|
889
|
+
this.bufferByteLength -= removed.length;
|
|
890
|
+
}
|
|
891
|
+
while (this.buffer.length > this.maxBufferLines) {
|
|
892
|
+
const removed = this.buffer.shift();
|
|
893
|
+
this.bufferByteLength -= removed.length;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
getBuffer() {
|
|
897
|
+
return this.buffer.join("");
|
|
898
|
+
}
|
|
899
|
+
write(data) {
|
|
900
|
+
if (!this.isExited) this.process.write(data);
|
|
901
|
+
}
|
|
902
|
+
resize(cols, rows) {
|
|
903
|
+
if (!this.isExited) this.process.resize(cols, rows);
|
|
904
|
+
}
|
|
905
|
+
close() {
|
|
906
|
+
if (this.titleInterval) {
|
|
907
|
+
clearInterval(this.titleInterval);
|
|
908
|
+
this.titleInterval = null;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
this.process.kill();
|
|
912
|
+
} catch {}
|
|
913
|
+
this.removeAllListeners();
|
|
914
|
+
}
|
|
915
|
+
toInfo() {
|
|
916
|
+
return {
|
|
917
|
+
id: this.id,
|
|
918
|
+
title: this.lastTitle,
|
|
919
|
+
command: this.command,
|
|
920
|
+
args: this.args,
|
|
921
|
+
platform: this.platform,
|
|
922
|
+
isExited: this.isExited,
|
|
923
|
+
exitCode: this.exitCode,
|
|
924
|
+
closeTip: this.closeTip,
|
|
925
|
+
closeCallbackUrl: this.closeCallbackUrl,
|
|
926
|
+
createdAt: this.createdAt
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
var PtyManager = class {
|
|
931
|
+
sessions = /* @__PURE__ */ new Map();
|
|
932
|
+
idCounter = 0;
|
|
933
|
+
platform;
|
|
934
|
+
constructor(defaultCwd) {
|
|
935
|
+
this.defaultCwd = defaultCwd;
|
|
936
|
+
this.platform = detectPtyPlatform();
|
|
937
|
+
}
|
|
938
|
+
create(opts) {
|
|
939
|
+
const id = `pty-${++this.idCounter}`;
|
|
940
|
+
const session = new PtySession(id, {
|
|
941
|
+
cols: opts.cols,
|
|
942
|
+
rows: opts.rows,
|
|
943
|
+
command: opts.command,
|
|
944
|
+
args: opts.args,
|
|
945
|
+
closeTip: opts.closeTip,
|
|
946
|
+
closeCallbackUrl: opts.closeCallbackUrl,
|
|
947
|
+
cwd: this.defaultCwd,
|
|
948
|
+
scrollback: opts.scrollback,
|
|
949
|
+
maxBufferBytes: opts.maxBufferBytes,
|
|
950
|
+
platform: this.platform
|
|
951
|
+
});
|
|
952
|
+
this.sessions.set(id, session);
|
|
953
|
+
return session;
|
|
954
|
+
}
|
|
955
|
+
get(id) {
|
|
956
|
+
return this.sessions.get(id);
|
|
957
|
+
}
|
|
958
|
+
list() {
|
|
959
|
+
const result = [];
|
|
960
|
+
for (const session of this.sessions.values()) result.push(session.toInfo());
|
|
961
|
+
return result;
|
|
962
|
+
}
|
|
963
|
+
write(id, data) {
|
|
964
|
+
this.sessions.get(id)?.write(data);
|
|
965
|
+
}
|
|
966
|
+
resize(id, cols, rows) {
|
|
967
|
+
this.sessions.get(id)?.resize(cols, rows);
|
|
968
|
+
}
|
|
969
|
+
close(id) {
|
|
970
|
+
const session = this.sessions.get(id);
|
|
971
|
+
if (session) {
|
|
972
|
+
session.close();
|
|
973
|
+
this.sessions.delete(id);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
closeAll() {
|
|
977
|
+
for (const session of this.sessions.values()) session.close();
|
|
978
|
+
this.sessions.clear();
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
//#endregion
|
|
983
|
+
//#region src/pty-websocket.ts
|
|
984
|
+
function createPtyWebSocketHandler(ptyManager) {
|
|
985
|
+
return (ws) => {
|
|
986
|
+
const cleanups = /* @__PURE__ */ new Map();
|
|
987
|
+
const send = (msg) => {
|
|
988
|
+
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
|
989
|
+
};
|
|
990
|
+
const sendError = (code, message, opts) => {
|
|
991
|
+
send({
|
|
992
|
+
type: "error",
|
|
993
|
+
code,
|
|
994
|
+
message,
|
|
995
|
+
sessionId: opts?.sessionId
|
|
996
|
+
});
|
|
997
|
+
};
|
|
998
|
+
const attachToSession = (session, opts) => {
|
|
999
|
+
const sessionId = session.id;
|
|
1000
|
+
cleanups.get(sessionId)?.();
|
|
1001
|
+
if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
|
|
1002
|
+
const onData = (data) => {
|
|
1003
|
+
send({
|
|
1004
|
+
type: "output",
|
|
1005
|
+
sessionId,
|
|
1006
|
+
data
|
|
1007
|
+
});
|
|
1008
|
+
};
|
|
1009
|
+
const onExit = (exitCode) => {
|
|
1010
|
+
send({
|
|
1011
|
+
type: "exit",
|
|
1012
|
+
sessionId,
|
|
1013
|
+
exitCode
|
|
1014
|
+
});
|
|
1015
|
+
};
|
|
1016
|
+
const onTitle = (title) => {
|
|
1017
|
+
send({
|
|
1018
|
+
type: "title",
|
|
1019
|
+
sessionId,
|
|
1020
|
+
title
|
|
1021
|
+
});
|
|
1022
|
+
};
|
|
1023
|
+
session.on("data", onData);
|
|
1024
|
+
session.on("exit", onExit);
|
|
1025
|
+
session.on("title", onTitle);
|
|
1026
|
+
cleanups.set(sessionId, () => {
|
|
1027
|
+
session.removeListener("data", onData);
|
|
1028
|
+
session.removeListener("exit", onExit);
|
|
1029
|
+
session.removeListener("title", onTitle);
|
|
1030
|
+
cleanups.delete(sessionId);
|
|
1031
|
+
});
|
|
1032
|
+
};
|
|
1033
|
+
ws.on("message", (raw) => {
|
|
1034
|
+
let parsed;
|
|
1035
|
+
try {
|
|
1036
|
+
parsed = JSON.parse(String(raw));
|
|
1037
|
+
} catch {
|
|
1038
|
+
sendError("INVALID_JSON", "Invalid JSON payload");
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
|
|
1042
|
+
if (!parsedMessage.success) {
|
|
1043
|
+
const firstIssue = parsedMessage.error.issues[0]?.message;
|
|
1044
|
+
sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const msg = parsedMessage.data;
|
|
1048
|
+
switch (msg.type) {
|
|
1049
|
+
case "create":
|
|
1050
|
+
try {
|
|
1051
|
+
const createMessage = msg;
|
|
1052
|
+
const session = ptyManager.create({
|
|
1053
|
+
cols: msg.cols,
|
|
1054
|
+
rows: msg.rows,
|
|
1055
|
+
command: msg.command,
|
|
1056
|
+
args: msg.args,
|
|
1057
|
+
closeTip: createMessage.closeTip,
|
|
1058
|
+
closeCallbackUrl: createMessage.closeCallbackUrl
|
|
1059
|
+
});
|
|
1060
|
+
send({
|
|
1061
|
+
type: "created",
|
|
1062
|
+
requestId: msg.requestId,
|
|
1063
|
+
sessionId: session.id,
|
|
1064
|
+
platform: session.platform
|
|
1065
|
+
});
|
|
1066
|
+
attachToSession(session);
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
|
|
1069
|
+
}
|
|
1070
|
+
break;
|
|
1071
|
+
case "attach": {
|
|
1072
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1073
|
+
if (!session) {
|
|
1074
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1075
|
+
send({
|
|
1076
|
+
type: "exit",
|
|
1077
|
+
sessionId: msg.sessionId,
|
|
1078
|
+
exitCode: -1
|
|
1079
|
+
});
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
attachToSession(session, {
|
|
1083
|
+
cols: msg.cols,
|
|
1084
|
+
rows: msg.rows
|
|
1085
|
+
});
|
|
1086
|
+
const buffer = session.getBuffer();
|
|
1087
|
+
if (buffer) send({
|
|
1088
|
+
type: "buffer",
|
|
1089
|
+
sessionId: session.id,
|
|
1090
|
+
data: buffer
|
|
1091
|
+
});
|
|
1092
|
+
if (session.title) send({
|
|
1093
|
+
type: "title",
|
|
1094
|
+
sessionId: session.id,
|
|
1095
|
+
title: session.title
|
|
1096
|
+
});
|
|
1097
|
+
if (session.isExited) send({
|
|
1098
|
+
type: "exit",
|
|
1099
|
+
sessionId: session.id,
|
|
1100
|
+
exitCode: session.exitCode ?? -1
|
|
1101
|
+
});
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
case "list":
|
|
1105
|
+
send({
|
|
1106
|
+
type: "list",
|
|
1107
|
+
sessions: ptyManager.list().map((s) => ({
|
|
1108
|
+
id: s.id,
|
|
1109
|
+
title: s.title,
|
|
1110
|
+
command: s.command,
|
|
1111
|
+
args: s.args,
|
|
1112
|
+
platform: s.platform,
|
|
1113
|
+
isExited: s.isExited,
|
|
1114
|
+
exitCode: s.exitCode,
|
|
1115
|
+
closeTip: s.closeTip,
|
|
1116
|
+
closeCallbackUrl: s.closeCallbackUrl
|
|
1117
|
+
}))
|
|
1118
|
+
});
|
|
1119
|
+
break;
|
|
1120
|
+
case "input": {
|
|
1121
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1122
|
+
if (!session) {
|
|
1123
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1124
|
+
break;
|
|
1125
|
+
}
|
|
1126
|
+
session.write(msg.data);
|
|
1127
|
+
break;
|
|
1128
|
+
}
|
|
1129
|
+
case "resize": {
|
|
1130
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1131
|
+
if (!session) {
|
|
1132
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
session.resize(msg.cols, msg.rows);
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
case "close": {
|
|
1139
|
+
const session = ptyManager.get(msg.sessionId);
|
|
1140
|
+
if (!session) {
|
|
1141
|
+
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
cleanups.get(msg.sessionId)?.();
|
|
1145
|
+
ptyManager.close(session.id);
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
ws.on("close", () => {
|
|
1151
|
+
for (const cleanup of cleanups.values()) cleanup();
|
|
1152
|
+
cleanups.clear();
|
|
1153
|
+
});
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
//#endregion
|
|
1158
|
+
//#region src/cli-stream-observable.ts
|
|
1159
|
+
/**
|
|
1160
|
+
* 创建安全的 CLI 流式 observable
|
|
1161
|
+
*
|
|
1162
|
+
* 解决的问题:
|
|
1163
|
+
* 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
|
|
1164
|
+
* 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
|
|
1165
|
+
* 3. 确保取消时正确清理资源
|
|
1166
|
+
*
|
|
1167
|
+
* @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
|
|
1168
|
+
*/
|
|
1169
|
+
function createCliStreamObservable(startStream) {
|
|
1170
|
+
return observable((emit) => {
|
|
1171
|
+
let cancel;
|
|
1172
|
+
let completed = false;
|
|
1173
|
+
/**
|
|
1174
|
+
* 安全的事件处理器
|
|
1175
|
+
* - 检查是否已完成,防止重复调用
|
|
1176
|
+
* - 使用 try-catch 防止异常导致服务器崩溃
|
|
1177
|
+
*/
|
|
1178
|
+
const safeEventHandler = (event) => {
|
|
1179
|
+
if (completed) return;
|
|
1180
|
+
try {
|
|
1181
|
+
emit.next(event);
|
|
1182
|
+
if (event.type === "exit") {
|
|
1183
|
+
completed = true;
|
|
1184
|
+
emit.complete();
|
|
1185
|
+
}
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
console.error("[CLI Stream] Error emitting event:", err);
|
|
1188
|
+
if (!completed) {
|
|
1189
|
+
completed = true;
|
|
1190
|
+
try {
|
|
1191
|
+
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
1192
|
+
} catch {}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
815
1195
|
};
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1196
|
+
startStream(safeEventHandler).then((cancelFn) => {
|
|
1197
|
+
cancel = cancelFn;
|
|
1198
|
+
}).catch((err) => {
|
|
1199
|
+
console.error("[CLI Stream] Error starting stream:", err);
|
|
1200
|
+
if (!completed) {
|
|
1201
|
+
completed = true;
|
|
1202
|
+
try {
|
|
1203
|
+
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
1204
|
+
} catch {}
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
return () => {
|
|
1208
|
+
completed = true;
|
|
1209
|
+
cancel?.();
|
|
827
1210
|
};
|
|
828
1211
|
});
|
|
829
1212
|
}
|
|
830
|
-
function buildDashboardTimeTrends(options) {
|
|
831
|
-
const pointLimit = clampPointLimit(options.pointLimit);
|
|
832
|
-
const trends = createEmptyTrendSeries();
|
|
833
|
-
for (const metric of DASHBOARD_METRIC_KEYS) {
|
|
834
|
-
if (options.availability[metric].state !== "ok") continue;
|
|
835
|
-
trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
|
|
836
|
-
}
|
|
837
|
-
return {
|
|
838
|
-
trends,
|
|
839
|
-
trendMeta: {
|
|
840
|
-
pointLimit,
|
|
841
|
-
lastUpdatedAt: options.timestamp
|
|
842
|
-
}
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
1213
|
|
|
846
1214
|
//#endregion
|
|
847
1215
|
//#region src/reactive-kv.ts
|
|
@@ -855,7 +1223,7 @@ function buildDashboardTimeTrends(options) {
|
|
|
855
1223
|
*/
|
|
856
1224
|
var ReactiveKV = class {
|
|
857
1225
|
store = /* @__PURE__ */ new Map();
|
|
858
|
-
emitter = new EventEmitter
|
|
1226
|
+
emitter = new EventEmitter();
|
|
859
1227
|
constructor() {
|
|
860
1228
|
this.emitter.setMaxListeners(200);
|
|
861
1229
|
}
|
|
@@ -918,135 +1286,57 @@ const reactiveKV = new ReactiveKV();
|
|
|
918
1286
|
* })
|
|
919
1287
|
* ```
|
|
920
1288
|
*/
|
|
921
|
-
function createReactiveSubscription(task) {
|
|
922
|
-
return observable((emit) => {
|
|
923
|
-
const context = new ReactiveContext();
|
|
924
|
-
const controller = new AbortController();
|
|
925
|
-
(async () => {
|
|
926
|
-
try {
|
|
927
|
-
for await (const data of context.stream(task, controller.signal)) emit.next(data);
|
|
928
|
-
} catch (err) {
|
|
929
|
-
if (!controller.signal.aborted) emit.error(err);
|
|
930
|
-
}
|
|
931
|
-
})();
|
|
932
|
-
return () => {
|
|
933
|
-
controller.abort();
|
|
934
|
-
};
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* 创建带输入参数的响应式订阅
|
|
939
|
-
*
|
|
940
|
-
* @param task 接收输入参数的异步任务
|
|
941
|
-
* @returns 返回一个函数,接收输入参数并返回 tRPC observable
|
|
942
|
-
*
|
|
943
|
-
* @example
|
|
944
|
-
* ```typescript
|
|
945
|
-
* // 在 router 中使用
|
|
946
|
-
* subscribeOne: publicProcedure
|
|
947
|
-
* .input(z.object({ id: z.string() }))
|
|
948
|
-
* .subscription(({ ctx, input }) => {
|
|
949
|
-
* return createReactiveSubscriptionWithInput(
|
|
950
|
-
* (id: string) => ctx.adapter.readSpec(id)
|
|
951
|
-
* )(input.id)
|
|
952
|
-
* })
|
|
953
|
-
* ```
|
|
954
|
-
*/
|
|
955
|
-
function createReactiveSubscriptionWithInput(task) {
|
|
956
|
-
return (input) => {
|
|
957
|
-
return createReactiveSubscription(() => task(input));
|
|
958
|
-
};
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
//#endregion
|
|
962
|
-
//#region src/router.ts
|
|
963
|
-
const t = initTRPC.context().create();
|
|
964
|
-
const router = t.router;
|
|
965
|
-
const publicProcedure = t.procedure;
|
|
966
|
-
const execFileAsync = promisify(execFile);
|
|
967
|
-
const OPSX_CORE_PROFILE_WORKFLOWS = [
|
|
968
|
-
"propose",
|
|
969
|
-
"explore",
|
|
970
|
-
"apply",
|
|
971
|
-
"archive"
|
|
972
|
-
];
|
|
973
|
-
const dashboardGitTaskStatusEmitter = new EventEmitter$1();
|
|
974
|
-
dashboardGitTaskStatusEmitter.setMaxListeners(200);
|
|
975
|
-
const dashboardGitTaskStatus = {
|
|
976
|
-
running: false,
|
|
977
|
-
inFlight: 0,
|
|
978
|
-
lastStartedAt: null,
|
|
979
|
-
lastFinishedAt: null,
|
|
980
|
-
lastReason: null,
|
|
981
|
-
lastError: null
|
|
982
|
-
};
|
|
983
|
-
function getDashboardGitTaskStatus() {
|
|
984
|
-
return { ...dashboardGitTaskStatus };
|
|
985
|
-
}
|
|
986
|
-
function emitDashboardGitTaskStatus() {
|
|
987
|
-
dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
|
|
988
|
-
}
|
|
989
|
-
function beginDashboardGitTask(reason) {
|
|
990
|
-
dashboardGitTaskStatus.inFlight += 1;
|
|
991
|
-
dashboardGitTaskStatus.running = true;
|
|
992
|
-
dashboardGitTaskStatus.lastStartedAt = Date.now();
|
|
993
|
-
dashboardGitTaskStatus.lastReason = reason;
|
|
994
|
-
dashboardGitTaskStatus.lastError = null;
|
|
995
|
-
emitDashboardGitTaskStatus();
|
|
996
|
-
}
|
|
997
|
-
function endDashboardGitTask(error) {
|
|
998
|
-
dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
|
|
999
|
-
dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
|
|
1000
|
-
dashboardGitTaskStatus.lastFinishedAt = Date.now();
|
|
1001
|
-
if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
|
|
1002
|
-
emitDashboardGitTaskStatus();
|
|
1003
|
-
}
|
|
1004
|
-
const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
|
|
1005
|
-
async function resolveGitMetadataDir(projectDir) {
|
|
1006
|
-
try {
|
|
1007
|
-
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
|
|
1008
|
-
cwd: projectDir,
|
|
1009
|
-
maxBuffer: 1024 * 1024,
|
|
1010
|
-
encoding: "utf8"
|
|
1011
|
-
});
|
|
1012
|
-
const gitDirRaw = stdout.trim();
|
|
1013
|
-
if (!gitDirRaw) return null;
|
|
1014
|
-
const gitDirPath = resolve(projectDir, gitDirRaw);
|
|
1015
|
-
if (!(await stat(gitDirPath)).isDirectory()) return null;
|
|
1016
|
-
return gitDirPath;
|
|
1017
|
-
} catch {
|
|
1018
|
-
return null;
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
async function resolveGitMetadataDirReactive(projectDir) {
|
|
1022
|
-
const gitMetadataDir = await resolveGitMetadataDir(projectDir);
|
|
1023
|
-
if (!gitMetadataDir) return null;
|
|
1024
|
-
await reactiveReadDir(gitMetadataDir, { includeHidden: true });
|
|
1025
|
-
return gitMetadataDir;
|
|
1026
|
-
}
|
|
1027
|
-
function getDashboardGitRefreshStampPath(gitMetadataDir) {
|
|
1028
|
-
return join(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
|
|
1029
|
-
}
|
|
1030
|
-
async function touchDashboardGitRefreshStamp(projectDir, reason) {
|
|
1031
|
-
const gitMetadataDir = await resolveGitMetadataDir(projectDir);
|
|
1032
|
-
if (!gitMetadataDir) return { skipped: true };
|
|
1033
|
-
const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
|
|
1034
|
-
await mkdir(dirname(stampPath), { recursive: true });
|
|
1035
|
-
await writeFile(stampPath, `${Date.now()} ${reason}\n`, "utf8");
|
|
1036
|
-
return { skipped: false };
|
|
1037
|
-
}
|
|
1038
|
-
async function registerDashboardGitReactiveDeps(projectDir) {
|
|
1039
|
-
await reactiveReadDir(projectDir, {
|
|
1040
|
-
includeHidden: true,
|
|
1041
|
-
exclude: ["node_modules"]
|
|
1289
|
+
function createReactiveSubscription(task) {
|
|
1290
|
+
return observable((emit) => {
|
|
1291
|
+
const context = new ReactiveContext();
|
|
1292
|
+
const controller = new AbortController();
|
|
1293
|
+
(async () => {
|
|
1294
|
+
try {
|
|
1295
|
+
for await (const data of context.stream(task, controller.signal)) emit.next(data);
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
if (!controller.signal.aborted) emit.error(err);
|
|
1298
|
+
}
|
|
1299
|
+
})();
|
|
1300
|
+
return () => {
|
|
1301
|
+
controller.abort();
|
|
1302
|
+
};
|
|
1042
1303
|
});
|
|
1043
|
-
const gitMetadataDir = await resolveGitMetadataDirReactive(projectDir);
|
|
1044
|
-
if (!gitMetadataDir) return;
|
|
1045
|
-
await reactiveReadFile(getDashboardGitRefreshStampPath(gitMetadataDir));
|
|
1046
|
-
await reactiveReadFile(join(gitMetadataDir, "HEAD"));
|
|
1047
|
-
await reactiveReadFile(join(gitMetadataDir, "index"));
|
|
1048
|
-
await reactiveReadFile(join(gitMetadataDir, "packed-refs"));
|
|
1049
1304
|
}
|
|
1305
|
+
/**
|
|
1306
|
+
* 创建带输入参数的响应式订阅
|
|
1307
|
+
*
|
|
1308
|
+
* @param task 接收输入参数的异步任务
|
|
1309
|
+
* @returns 返回一个函数,接收输入参数并返回 tRPC observable
|
|
1310
|
+
*
|
|
1311
|
+
* @example
|
|
1312
|
+
* ```typescript
|
|
1313
|
+
* // 在 router 中使用
|
|
1314
|
+
* subscribeOne: publicProcedure
|
|
1315
|
+
* .input(z.object({ id: z.string() }))
|
|
1316
|
+
* .subscription(({ ctx, input }) => {
|
|
1317
|
+
* return createReactiveSubscriptionWithInput(
|
|
1318
|
+
* (id: string) => ctx.adapter.readSpec(id)
|
|
1319
|
+
* )(input.id)
|
|
1320
|
+
* })
|
|
1321
|
+
* ```
|
|
1322
|
+
*/
|
|
1323
|
+
function createReactiveSubscriptionWithInput(task) {
|
|
1324
|
+
return (input) => {
|
|
1325
|
+
return createReactiveSubscription(() => task(input));
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
//#endregion
|
|
1330
|
+
//#region src/router.ts
|
|
1331
|
+
const t = initTRPC.context().create();
|
|
1332
|
+
const router = t.router;
|
|
1333
|
+
const publicProcedure = t.procedure;
|
|
1334
|
+
const OPSX_CORE_PROFILE_WORKFLOWS = [
|
|
1335
|
+
"propose",
|
|
1336
|
+
"explore",
|
|
1337
|
+
"apply",
|
|
1338
|
+
"archive"
|
|
1339
|
+
];
|
|
1050
1340
|
function requireChangeId(changeId) {
|
|
1051
1341
|
if (!changeId) throw new Error("change is required");
|
|
1052
1342
|
return changeId;
|
|
@@ -1216,200 +1506,6 @@ function buildSystemStatus(ctx) {
|
|
|
1216
1506
|
watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
|
|
1217
1507
|
};
|
|
1218
1508
|
}
|
|
1219
|
-
function resolveTrendTimestamp(primary, secondary) {
|
|
1220
|
-
if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
|
|
1221
|
-
if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
|
|
1222
|
-
return null;
|
|
1223
|
-
}
|
|
1224
|
-
function parseDatedIdTimestamp(id) {
|
|
1225
|
-
const match = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
|
|
1226
|
-
if (!match) return null;
|
|
1227
|
-
const year = Number(match[1]);
|
|
1228
|
-
const month = Number(match[2]);
|
|
1229
|
-
const day = Number(match[3]);
|
|
1230
|
-
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
|
|
1231
|
-
if (month < 1 || month > 12) return null;
|
|
1232
|
-
if (day < 1 || day > 31) return null;
|
|
1233
|
-
const ts = Date.UTC(year, month - 1, day);
|
|
1234
|
-
return Number.isFinite(ts) ? ts : null;
|
|
1235
|
-
}
|
|
1236
|
-
function createEmptyTriColorTrends() {
|
|
1237
|
-
return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
|
|
1238
|
-
}
|
|
1239
|
-
async function readLatestCommitTimestamp(projectDir) {
|
|
1240
|
-
try {
|
|
1241
|
-
const { stdout } = await execFileAsync("git", [
|
|
1242
|
-
"log",
|
|
1243
|
-
"-1",
|
|
1244
|
-
"--format=%ct"
|
|
1245
|
-
], {
|
|
1246
|
-
cwd: projectDir,
|
|
1247
|
-
maxBuffer: 1024 * 1024,
|
|
1248
|
-
encoding: "utf8"
|
|
1249
|
-
});
|
|
1250
|
-
const seconds = Number(stdout.trim());
|
|
1251
|
-
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
|
|
1252
|
-
} catch {
|
|
1253
|
-
return null;
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
async function fetchDashboardOverview(ctx, reason = "dashboard-refresh") {
|
|
1257
|
-
if (contextStorage.getStore()) await registerDashboardGitReactiveDeps(ctx.projectDir);
|
|
1258
|
-
const now = Date.now();
|
|
1259
|
-
const [specMetas, changeMetas, archiveMetas] = await Promise.all([
|
|
1260
|
-
ctx.adapter.listSpecsWithMeta(),
|
|
1261
|
-
ctx.adapter.listChangesWithMeta(),
|
|
1262
|
-
ctx.adapter.listArchivedChangesWithMeta()
|
|
1263
|
-
]);
|
|
1264
|
-
const activeChanges = changeMetas.map((changeMeta) => ({
|
|
1265
|
-
id: changeMeta.id,
|
|
1266
|
-
name: changeMeta.name ?? changeMeta.id,
|
|
1267
|
-
progress: changeMeta.progress,
|
|
1268
|
-
updatedAt: changeMeta.updatedAt
|
|
1269
|
-
})).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1270
|
-
const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
|
|
1271
|
-
const change = await ctx.adapter.readArchivedChange(meta.id);
|
|
1272
|
-
if (!change) return null;
|
|
1273
|
-
return {
|
|
1274
|
-
id: meta.id,
|
|
1275
|
-
createdAt: meta.createdAt,
|
|
1276
|
-
updatedAt: meta.updatedAt,
|
|
1277
|
-
tasksCompleted: change.tasks.filter((task) => task.completed).length
|
|
1278
|
-
};
|
|
1279
|
-
}))).filter((item) => item !== null);
|
|
1280
|
-
const specifications = (await Promise.all(specMetas.map(async (meta) => {
|
|
1281
|
-
const spec = await ctx.adapter.readSpec(meta.id);
|
|
1282
|
-
if (!spec) return null;
|
|
1283
|
-
return {
|
|
1284
|
-
id: meta.id,
|
|
1285
|
-
name: meta.name,
|
|
1286
|
-
requirements: spec.requirements.length,
|
|
1287
|
-
updatedAt: meta.updatedAt
|
|
1288
|
-
};
|
|
1289
|
-
}))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
|
|
1290
|
-
const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
|
|
1291
|
-
const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
|
|
1292
|
-
const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
|
|
1293
|
-
const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
|
|
1294
|
-
const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
|
|
1295
|
-
const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
|
|
1296
|
-
const specificationTrendEvents = specMetas.flatMap((spec) => {
|
|
1297
|
-
const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
|
|
1298
|
-
return ts === null ? [] : [{
|
|
1299
|
-
ts,
|
|
1300
|
-
value: 1
|
|
1301
|
-
}];
|
|
1302
|
-
});
|
|
1303
|
-
const completedTrendEvents = archivedChanges.flatMap((archive) => {
|
|
1304
|
-
const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
|
|
1305
|
-
return ts === null ? [] : [{
|
|
1306
|
-
ts,
|
|
1307
|
-
value: archive.tasksCompleted
|
|
1308
|
-
}];
|
|
1309
|
-
});
|
|
1310
|
-
const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
|
|
1311
|
-
const requirementTrendEvents = specifications.flatMap((spec) => {
|
|
1312
|
-
const meta = specMetaById.get(spec.id);
|
|
1313
|
-
const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
|
|
1314
|
-
return ts === null ? [] : [{
|
|
1315
|
-
ts,
|
|
1316
|
-
value: spec.requirements
|
|
1317
|
-
}];
|
|
1318
|
-
});
|
|
1319
|
-
const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
|
|
1320
|
-
const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
|
|
1321
|
-
const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
|
|
1322
|
-
const config = await ctx.configManager.readConfig();
|
|
1323
|
-
beginDashboardGitTask(reason);
|
|
1324
|
-
let latestCommitTs = null;
|
|
1325
|
-
let git;
|
|
1326
|
-
try {
|
|
1327
|
-
const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
|
|
1328
|
-
defaultBranch: "main",
|
|
1329
|
-
worktrees: []
|
|
1330
|
-
}));
|
|
1331
|
-
latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
|
|
1332
|
-
git = await gitSnapshotPromise;
|
|
1333
|
-
} catch (error) {
|
|
1334
|
-
endDashboardGitTask(error);
|
|
1335
|
-
throw error;
|
|
1336
|
-
}
|
|
1337
|
-
endDashboardGitTask(null);
|
|
1338
|
-
const cardAvailability = {
|
|
1339
|
-
specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
|
|
1340
|
-
state: "invalid",
|
|
1341
|
-
reason: "objective-history-unavailable"
|
|
1342
|
-
},
|
|
1343
|
-
requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
|
|
1344
|
-
state: "invalid",
|
|
1345
|
-
reason: "objective-history-unavailable"
|
|
1346
|
-
},
|
|
1347
|
-
activeChanges: {
|
|
1348
|
-
state: "invalid",
|
|
1349
|
-
reason: "objective-history-unavailable"
|
|
1350
|
-
},
|
|
1351
|
-
inProgressChanges: {
|
|
1352
|
-
state: "invalid",
|
|
1353
|
-
reason: "objective-history-unavailable"
|
|
1354
|
-
},
|
|
1355
|
-
completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
|
|
1356
|
-
state: "invalid",
|
|
1357
|
-
reason: "objective-history-unavailable"
|
|
1358
|
-
},
|
|
1359
|
-
taskCompletionPercent: {
|
|
1360
|
-
state: "invalid",
|
|
1361
|
-
reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
|
|
1362
|
-
}
|
|
1363
|
-
};
|
|
1364
|
-
const trendKinds = {
|
|
1365
|
-
specifications: "monotonic",
|
|
1366
|
-
requirements: "monotonic",
|
|
1367
|
-
activeChanges: "bidirectional",
|
|
1368
|
-
inProgressChanges: "bidirectional",
|
|
1369
|
-
completedChanges: "monotonic",
|
|
1370
|
-
taskCompletionPercent: "bidirectional"
|
|
1371
|
-
};
|
|
1372
|
-
const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
|
|
1373
|
-
pointLimit: config.dashboard.trendPointLimit,
|
|
1374
|
-
timestamp: now,
|
|
1375
|
-
rightEdgeTs: latestCommitTs,
|
|
1376
|
-
availability: cardAvailability,
|
|
1377
|
-
events: {
|
|
1378
|
-
specifications: specificationTrendEvents,
|
|
1379
|
-
requirements: requirementTrendEvents,
|
|
1380
|
-
activeChanges: [],
|
|
1381
|
-
inProgressChanges: [],
|
|
1382
|
-
completedChanges: completedTrendEvents,
|
|
1383
|
-
taskCompletionPercent: []
|
|
1384
|
-
},
|
|
1385
|
-
reducers: {
|
|
1386
|
-
specifications: "sum",
|
|
1387
|
-
requirements: "sum",
|
|
1388
|
-
completedChanges: "sum"
|
|
1389
|
-
}
|
|
1390
|
-
});
|
|
1391
|
-
return {
|
|
1392
|
-
summary: {
|
|
1393
|
-
specifications: specifications.length,
|
|
1394
|
-
requirements,
|
|
1395
|
-
activeChanges: activeChanges.length,
|
|
1396
|
-
inProgressChanges,
|
|
1397
|
-
completedChanges: archiveMetas.length,
|
|
1398
|
-
archivedTasksCompleted,
|
|
1399
|
-
tasksTotal,
|
|
1400
|
-
tasksCompleted,
|
|
1401
|
-
taskCompletionPercent
|
|
1402
|
-
},
|
|
1403
|
-
trends: baselineTrends,
|
|
1404
|
-
triColorTrends: createEmptyTriColorTrends(),
|
|
1405
|
-
trendKinds,
|
|
1406
|
-
cardAvailability,
|
|
1407
|
-
trendMeta,
|
|
1408
|
-
specifications,
|
|
1409
|
-
activeChanges,
|
|
1410
|
-
git
|
|
1411
|
-
};
|
|
1412
|
-
}
|
|
1413
1509
|
/**
|
|
1414
1510
|
* Spec router - spec CRUD operations
|
|
1415
1511
|
*/
|
|
@@ -2111,30 +2207,52 @@ const systemRouter = router({
|
|
|
2111
2207
|
*/
|
|
2112
2208
|
const dashboardRouter = router({
|
|
2113
2209
|
get: publicProcedure.query(async ({ ctx }) => {
|
|
2114
|
-
return
|
|
2210
|
+
return ctx.dashboardOverviewService.getCurrent();
|
|
2115
2211
|
}),
|
|
2116
2212
|
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
2117
|
-
return
|
|
2118
|
-
|
|
2213
|
+
return observable((emit) => {
|
|
2214
|
+
const unsubscribe = ctx.dashboardOverviewService.subscribe((overview) => {
|
|
2215
|
+
emit.next(overview);
|
|
2216
|
+
}, {
|
|
2217
|
+
emitCurrent: true,
|
|
2218
|
+
onError: (error) => {
|
|
2219
|
+
emit.error(error);
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
return () => {
|
|
2223
|
+
unsubscribe();
|
|
2224
|
+
};
|
|
2119
2225
|
});
|
|
2120
2226
|
}),
|
|
2121
2227
|
refreshGitSnapshot: publicProcedure.input(z.object({ reason: z.string().optional() }).optional()).mutation(async ({ ctx, input }) => {
|
|
2122
2228
|
const reason = input?.reason?.trim() || "manual-refresh";
|
|
2123
2229
|
await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
|
|
2230
|
+
await ctx.dashboardOverviewService.refresh(reason);
|
|
2231
|
+
return { success: true };
|
|
2232
|
+
}),
|
|
2233
|
+
removeDetachedWorktree: publicProcedure.input(z.object({ path: z.string().min(1) })).mutation(async ({ ctx, input }) => {
|
|
2234
|
+
await ctx.dashboardOverviewService.getCurrent();
|
|
2235
|
+
await removeDetachedDashboardGitWorktree({
|
|
2236
|
+
projectDir: ctx.projectDir,
|
|
2237
|
+
targetPath: input.path
|
|
2238
|
+
});
|
|
2239
|
+
await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
|
|
2240
|
+
await ctx.dashboardOverviewService.refresh("remove-detached-worktree");
|
|
2124
2241
|
return { success: true };
|
|
2125
2242
|
}),
|
|
2126
|
-
gitTaskStatus: publicProcedure.query(() => {
|
|
2243
|
+
gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
|
|
2244
|
+
await ctx.dashboardOverviewService.getCurrent();
|
|
2127
2245
|
return getDashboardGitTaskStatus();
|
|
2128
2246
|
}),
|
|
2129
|
-
subscribeGitTaskStatus: publicProcedure.subscription(() => {
|
|
2247
|
+
subscribeGitTaskStatus: publicProcedure.subscription(({ ctx }) => {
|
|
2130
2248
|
return observable((emit) => {
|
|
2249
|
+
ctx.dashboardOverviewService.getCurrent().catch(() => {});
|
|
2131
2250
|
emit.next(getDashboardGitTaskStatus());
|
|
2132
|
-
const
|
|
2251
|
+
const unsubscribe = subscribeDashboardGitTaskStatus((status) => {
|
|
2133
2252
|
emit.next(status);
|
|
2134
|
-
};
|
|
2135
|
-
dashboardGitTaskStatusEmitter.on("change", handler);
|
|
2253
|
+
});
|
|
2136
2254
|
return () => {
|
|
2137
|
-
|
|
2255
|
+
unsubscribe();
|
|
2138
2256
|
};
|
|
2139
2257
|
});
|
|
2140
2258
|
})
|
|
@@ -2325,6 +2443,11 @@ function createServer(config) {
|
|
|
2325
2443
|
const kernel = config.kernel;
|
|
2326
2444
|
const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
|
|
2327
2445
|
const searchService = new SearchService(adapter, watcher);
|
|
2446
|
+
const dashboardOverviewService = new DashboardOverviewService((reason) => loadDashboardOverview({
|
|
2447
|
+
adapter,
|
|
2448
|
+
configManager,
|
|
2449
|
+
projectDir: config.projectDir
|
|
2450
|
+
}, reason), watcher);
|
|
2328
2451
|
const app = new Hono();
|
|
2329
2452
|
const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
|
|
2330
2453
|
app.use("*", cors({
|
|
@@ -2351,6 +2474,7 @@ function createServer(config) {
|
|
|
2351
2474
|
cliExecutor,
|
|
2352
2475
|
kernel,
|
|
2353
2476
|
searchService,
|
|
2477
|
+
dashboardOverviewService,
|
|
2354
2478
|
watcher,
|
|
2355
2479
|
projectDir: config.projectDir
|
|
2356
2480
|
})
|
|
@@ -2362,6 +2486,7 @@ function createServer(config) {
|
|
|
2362
2486
|
cliExecutor,
|
|
2363
2487
|
kernel,
|
|
2364
2488
|
searchService,
|
|
2489
|
+
dashboardOverviewService,
|
|
2365
2490
|
watcher,
|
|
2366
2491
|
projectDir: config.projectDir
|
|
2367
2492
|
});
|
|
@@ -2372,6 +2497,7 @@ function createServer(config) {
|
|
|
2372
2497
|
cliExecutor,
|
|
2373
2498
|
kernel,
|
|
2374
2499
|
searchService,
|
|
2500
|
+
dashboardOverviewService,
|
|
2375
2501
|
watcher,
|
|
2376
2502
|
createContext,
|
|
2377
2503
|
port: config.port ?? 3100
|
|
@@ -2419,6 +2545,7 @@ async function createWebSocketServer(server, httpServer, config) {
|
|
|
2419
2545
|
wss.close();
|
|
2420
2546
|
server.watcher?.stop();
|
|
2421
2547
|
server.searchService.dispose().catch(() => {});
|
|
2548
|
+
server.dashboardOverviewService.dispose();
|
|
2422
2549
|
}
|
|
2423
2550
|
};
|
|
2424
2551
|
}
|
|
@@ -2454,6 +2581,9 @@ async function startServer(config, setupApp) {
|
|
|
2454
2581
|
server.searchService.init().catch((err) => {
|
|
2455
2582
|
console.error("Search service warmup failed:", err);
|
|
2456
2583
|
});
|
|
2584
|
+
server.dashboardOverviewService.init().catch((err) => {
|
|
2585
|
+
console.error("Dashboard overview warmup failed:", err);
|
|
2586
|
+
});
|
|
2457
2587
|
return {
|
|
2458
2588
|
url,
|
|
2459
2589
|
port,
|