@openspecui/server 2.0.2 → 2.1.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/dist/index.mjs +999 -875
- package/package.json +15 -11
- package/LICENSE +0 -21
package/dist/index.mjs
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
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";
|
|
7
7
|
import { cors } from "hono/cors";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
8
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";
|
|
9
16
|
import * as pty from "@lydell/node-pty";
|
|
10
|
-
import { EventEmitter } from "events";
|
|
17
|
+
import { EventEmitter as EventEmitter$1 } from "events";
|
|
11
18
|
import { SearchQuerySchema } from "@openspecui/search";
|
|
12
19
|
import { initTRPC } from "@trpc/server";
|
|
13
20
|
import { observable } from "@trpc/server/observable";
|
|
14
|
-
import { execFile } from "node:child_process";
|
|
15
|
-
import { EventEmitter as EventEmitter$1 } from "node:events";
|
|
16
|
-
import { mkdir, rm, stat, writeFile } from "node:fs/promises";
|
|
17
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
18
|
-
import { promisify } from "node:util";
|
|
19
21
|
import { z } from "zod";
|
|
20
22
|
import { NodeWorkerSearchProvider } from "@openspecui/search/node";
|
|
21
23
|
|
|
@@ -54,454 +56,112 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
//#endregion
|
|
57
|
-
//#region src/
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
command,
|
|
73
|
-
args: opts.args ?? []
|
|
74
|
-
};
|
|
75
|
-
return {
|
|
76
|
-
command: resolveDefaultShell(opts.platform, opts.env),
|
|
77
|
-
args: []
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
var PtySession = class extends EventEmitter {
|
|
81
|
-
id;
|
|
82
|
-
command;
|
|
83
|
-
args;
|
|
84
|
-
platform;
|
|
85
|
-
closeTip;
|
|
86
|
-
closeCallbackUrl;
|
|
87
|
-
createdAt;
|
|
88
|
-
process;
|
|
89
|
-
titleInterval = null;
|
|
90
|
-
lastTitle = "";
|
|
91
|
-
buffer = [];
|
|
92
|
-
bufferByteLength = 0;
|
|
93
|
-
maxBufferLines;
|
|
94
|
-
maxBufferBytes;
|
|
95
|
-
isExited = false;
|
|
96
|
-
exitCode = null;
|
|
97
|
-
constructor(id, opts) {
|
|
98
|
-
super();
|
|
99
|
-
this.id = id;
|
|
100
|
-
this.createdAt = Date.now();
|
|
101
|
-
const resolvedCommand = resolvePtyCommand({
|
|
102
|
-
platform: opts.platform,
|
|
103
|
-
command: opts.command,
|
|
104
|
-
args: opts.args,
|
|
105
|
-
env: process.env
|
|
106
|
-
});
|
|
107
|
-
this.command = resolvedCommand.command;
|
|
108
|
-
this.args = resolvedCommand.args;
|
|
109
|
-
this.platform = opts.platform;
|
|
110
|
-
this.closeTip = opts.closeTip;
|
|
111
|
-
this.closeCallbackUrl = opts.closeCallbackUrl;
|
|
112
|
-
this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
|
|
113
|
-
this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
|
|
114
|
-
this.process = pty.spawn(this.command, this.args, {
|
|
115
|
-
name: "xterm-256color",
|
|
116
|
-
cols: opts.cols ?? 80,
|
|
117
|
-
rows: opts.rows ?? 24,
|
|
118
|
-
cwd: opts.cwd,
|
|
119
|
-
env: {
|
|
120
|
-
...process.env,
|
|
121
|
-
TERM: "xterm-256color"
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
this.process.onData((data) => {
|
|
125
|
-
this.appendBuffer(data);
|
|
126
|
-
this.emit("data", data);
|
|
127
|
-
});
|
|
128
|
-
this.process.onExit(({ exitCode }) => {
|
|
129
|
-
if (this.titleInterval) {
|
|
130
|
-
clearInterval(this.titleInterval);
|
|
131
|
-
this.titleInterval = null;
|
|
132
|
-
}
|
|
133
|
-
this.isExited = true;
|
|
134
|
-
this.exitCode = exitCode;
|
|
135
|
-
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");
|
|
136
74
|
});
|
|
137
|
-
this.titleInterval = setInterval(() => {
|
|
138
|
-
try {
|
|
139
|
-
const title = this.process.process;
|
|
140
|
-
if (title && title !== this.lastTitle) {
|
|
141
|
-
this.lastTitle = title;
|
|
142
|
-
this.emit("title", title);
|
|
143
|
-
}
|
|
144
|
-
} catch {}
|
|
145
|
-
}, 1e3);
|
|
146
|
-
}
|
|
147
|
-
get title() {
|
|
148
|
-
return this.lastTitle;
|
|
149
75
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
this.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.
|
|
158
|
-
}
|
|
159
|
-
while (this.buffer.length > this.maxBufferLines) {
|
|
160
|
-
const removed = this.buffer.shift();
|
|
161
|
-
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;
|
|
162
84
|
}
|
|
163
85
|
}
|
|
164
|
-
|
|
165
|
-
return this.
|
|
86
|
+
async getCurrent() {
|
|
87
|
+
if (this.current) return this.current;
|
|
88
|
+
return this.init();
|
|
166
89
|
}
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
};
|
|
169
99
|
}
|
|
170
|
-
|
|
171
|
-
|
|
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);
|
|
172
106
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.
|
|
107
|
+
async refresh(reason = "manual-refresh") {
|
|
108
|
+
this.cancelScheduledRefresh();
|
|
109
|
+
if (this.refreshPromise) {
|
|
110
|
+
this.pendingRefreshReason = reason;
|
|
111
|
+
return this.refreshPromise;
|
|
177
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
|
+
})();
|
|
178
120
|
try {
|
|
179
|
-
this.
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
command: this.command,
|
|
188
|
-
args: this.args,
|
|
189
|
-
platform: this.platform,
|
|
190
|
-
isExited: this.isExited,
|
|
191
|
-
exitCode: this.exitCode,
|
|
192
|
-
closeTip: this.closeTip,
|
|
193
|
-
closeCallbackUrl: this.closeCallbackUrl,
|
|
194
|
-
createdAt: this.createdAt
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
var PtyManager = class {
|
|
199
|
-
sessions = /* @__PURE__ */ new Map();
|
|
200
|
-
idCounter = 0;
|
|
201
|
-
platform;
|
|
202
|
-
constructor(defaultCwd) {
|
|
203
|
-
this.defaultCwd = defaultCwd;
|
|
204
|
-
this.platform = detectPtyPlatform();
|
|
205
|
-
}
|
|
206
|
-
create(opts) {
|
|
207
|
-
const id = `pty-${++this.idCounter}`;
|
|
208
|
-
const session = new PtySession(id, {
|
|
209
|
-
cols: opts.cols,
|
|
210
|
-
rows: opts.rows,
|
|
211
|
-
command: opts.command,
|
|
212
|
-
args: opts.args,
|
|
213
|
-
closeTip: opts.closeTip,
|
|
214
|
-
closeCallbackUrl: opts.closeCallbackUrl,
|
|
215
|
-
cwd: this.defaultCwd,
|
|
216
|
-
scrollback: opts.scrollback,
|
|
217
|
-
maxBufferBytes: opts.maxBufferBytes,
|
|
218
|
-
platform: this.platform
|
|
219
|
-
});
|
|
220
|
-
this.sessions.set(id, session);
|
|
221
|
-
return session;
|
|
222
|
-
}
|
|
223
|
-
get(id) {
|
|
224
|
-
return this.sessions.get(id);
|
|
225
|
-
}
|
|
226
|
-
list() {
|
|
227
|
-
const result = [];
|
|
228
|
-
for (const session of this.sessions.values()) result.push(session.toInfo());
|
|
229
|
-
return result;
|
|
230
|
-
}
|
|
231
|
-
write(id, data) {
|
|
232
|
-
this.sessions.get(id)?.write(data);
|
|
233
|
-
}
|
|
234
|
-
resize(id, cols, rows) {
|
|
235
|
-
this.sessions.get(id)?.resize(cols, rows);
|
|
236
|
-
}
|
|
237
|
-
close(id) {
|
|
238
|
-
const session = this.sessions.get(id);
|
|
239
|
-
if (session) {
|
|
240
|
-
session.close();
|
|
241
|
-
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
|
+
}
|
|
242
129
|
}
|
|
243
130
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
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;
|
|
247
139
|
}
|
|
248
140
|
};
|
|
249
141
|
|
|
250
142
|
//#endregion
|
|
251
|
-
//#region src/
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
257
160
|
};
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
message,
|
|
263
|
-
sessionId: opts?.sessionId
|
|
264
|
-
});
|
|
265
|
-
};
|
|
266
|
-
const attachToSession = (session, opts) => {
|
|
267
|
-
const sessionId = session.id;
|
|
268
|
-
cleanups.get(sessionId)?.();
|
|
269
|
-
if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
|
|
270
|
-
const onData = (data) => {
|
|
271
|
-
send({
|
|
272
|
-
type: "output",
|
|
273
|
-
sessionId,
|
|
274
|
-
data
|
|
275
|
-
});
|
|
276
|
-
};
|
|
277
|
-
const onExit = (exitCode) => {
|
|
278
|
-
send({
|
|
279
|
-
type: "exit",
|
|
280
|
-
sessionId,
|
|
281
|
-
exitCode
|
|
282
|
-
});
|
|
283
|
-
};
|
|
284
|
-
const onTitle = (title) => {
|
|
285
|
-
send({
|
|
286
|
-
type: "title",
|
|
287
|
-
sessionId,
|
|
288
|
-
title
|
|
289
|
-
});
|
|
290
|
-
};
|
|
291
|
-
session.on("data", onData);
|
|
292
|
-
session.on("exit", onExit);
|
|
293
|
-
session.on("title", onTitle);
|
|
294
|
-
cleanups.set(sessionId, () => {
|
|
295
|
-
session.removeListener("data", onData);
|
|
296
|
-
session.removeListener("exit", onExit);
|
|
297
|
-
session.removeListener("title", onTitle);
|
|
298
|
-
cleanups.delete(sessionId);
|
|
299
|
-
});
|
|
300
|
-
};
|
|
301
|
-
ws.on("message", (raw) => {
|
|
302
|
-
let parsed;
|
|
303
|
-
try {
|
|
304
|
-
parsed = JSON.parse(String(raw));
|
|
305
|
-
} catch {
|
|
306
|
-
sendError("INVALID_JSON", "Invalid JSON payload");
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
|
|
310
|
-
if (!parsedMessage.success) {
|
|
311
|
-
const firstIssue = parsedMessage.error.issues[0]?.message;
|
|
312
|
-
sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
const msg = parsedMessage.data;
|
|
316
|
-
switch (msg.type) {
|
|
317
|
-
case "create":
|
|
318
|
-
try {
|
|
319
|
-
const createMessage = msg;
|
|
320
|
-
const session = ptyManager.create({
|
|
321
|
-
cols: msg.cols,
|
|
322
|
-
rows: msg.rows,
|
|
323
|
-
command: msg.command,
|
|
324
|
-
args: msg.args,
|
|
325
|
-
closeTip: createMessage.closeTip,
|
|
326
|
-
closeCallbackUrl: createMessage.closeCallbackUrl
|
|
327
|
-
});
|
|
328
|
-
send({
|
|
329
|
-
type: "created",
|
|
330
|
-
requestId: msg.requestId,
|
|
331
|
-
sessionId: session.id,
|
|
332
|
-
platform: session.platform
|
|
333
|
-
});
|
|
334
|
-
attachToSession(session);
|
|
335
|
-
} catch (err) {
|
|
336
|
-
sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
|
|
337
|
-
}
|
|
338
|
-
break;
|
|
339
|
-
case "attach": {
|
|
340
|
-
const session = ptyManager.get(msg.sessionId);
|
|
341
|
-
if (!session) {
|
|
342
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
343
|
-
send({
|
|
344
|
-
type: "exit",
|
|
345
|
-
sessionId: msg.sessionId,
|
|
346
|
-
exitCode: -1
|
|
347
|
-
});
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
attachToSession(session, {
|
|
351
|
-
cols: msg.cols,
|
|
352
|
-
rows: msg.rows
|
|
353
|
-
});
|
|
354
|
-
const buffer = session.getBuffer();
|
|
355
|
-
if (buffer) send({
|
|
356
|
-
type: "buffer",
|
|
357
|
-
sessionId: session.id,
|
|
358
|
-
data: buffer
|
|
359
|
-
});
|
|
360
|
-
if (session.title) send({
|
|
361
|
-
type: "title",
|
|
362
|
-
sessionId: session.id,
|
|
363
|
-
title: session.title
|
|
364
|
-
});
|
|
365
|
-
if (session.isExited) send({
|
|
366
|
-
type: "exit",
|
|
367
|
-
sessionId: session.id,
|
|
368
|
-
exitCode: session.exitCode ?? -1
|
|
369
|
-
});
|
|
370
|
-
break;
|
|
371
|
-
}
|
|
372
|
-
case "list":
|
|
373
|
-
send({
|
|
374
|
-
type: "list",
|
|
375
|
-
sessions: ptyManager.list().map((s) => ({
|
|
376
|
-
id: s.id,
|
|
377
|
-
title: s.title,
|
|
378
|
-
command: s.command,
|
|
379
|
-
args: s.args,
|
|
380
|
-
platform: s.platform,
|
|
381
|
-
isExited: s.isExited,
|
|
382
|
-
exitCode: s.exitCode,
|
|
383
|
-
closeTip: s.closeTip,
|
|
384
|
-
closeCallbackUrl: s.closeCallbackUrl
|
|
385
|
-
}))
|
|
386
|
-
});
|
|
387
|
-
break;
|
|
388
|
-
case "input": {
|
|
389
|
-
const session = ptyManager.get(msg.sessionId);
|
|
390
|
-
if (!session) {
|
|
391
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
392
|
-
break;
|
|
393
|
-
}
|
|
394
|
-
session.write(msg.data);
|
|
395
|
-
break;
|
|
396
|
-
}
|
|
397
|
-
case "resize": {
|
|
398
|
-
const session = ptyManager.get(msg.sessionId);
|
|
399
|
-
if (!session) {
|
|
400
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
401
|
-
break;
|
|
402
|
-
}
|
|
403
|
-
session.resize(msg.cols, msg.rows);
|
|
404
|
-
break;
|
|
405
|
-
}
|
|
406
|
-
case "close": {
|
|
407
|
-
const session = ptyManager.get(msg.sessionId);
|
|
408
|
-
if (!session) {
|
|
409
|
-
sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
|
-
cleanups.get(msg.sessionId)?.();
|
|
413
|
-
ptyManager.close(session.id);
|
|
414
|
-
break;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
});
|
|
418
|
-
ws.on("close", () => {
|
|
419
|
-
for (const cleanup of cleanups.values()) cleanup();
|
|
420
|
-
cleanups.clear();
|
|
421
|
-
});
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
//#endregion
|
|
426
|
-
//#region src/cli-stream-observable.ts
|
|
427
|
-
/**
|
|
428
|
-
* 创建安全的 CLI 流式 observable
|
|
429
|
-
*
|
|
430
|
-
* 解决的问题:
|
|
431
|
-
* 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
|
|
432
|
-
* 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
|
|
433
|
-
* 3. 确保取消时正确清理资源
|
|
434
|
-
*
|
|
435
|
-
* @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
|
|
436
|
-
*/
|
|
437
|
-
function createCliStreamObservable(startStream) {
|
|
438
|
-
return observable((emit) => {
|
|
439
|
-
let cancel;
|
|
440
|
-
let completed = false;
|
|
441
|
-
/**
|
|
442
|
-
* 安全的事件处理器
|
|
443
|
-
* - 检查是否已完成,防止重复调用
|
|
444
|
-
* - 使用 try-catch 防止异常导致服务器崩溃
|
|
445
|
-
*/
|
|
446
|
-
const safeEventHandler = (event) => {
|
|
447
|
-
if (completed) return;
|
|
448
|
-
try {
|
|
449
|
-
emit.next(event);
|
|
450
|
-
if (event.type === "exit") {
|
|
451
|
-
completed = true;
|
|
452
|
-
emit.complete();
|
|
453
|
-
}
|
|
454
|
-
} catch (err) {
|
|
455
|
-
console.error("[CLI Stream] Error emitting event:", err);
|
|
456
|
-
if (!completed) {
|
|
457
|
-
completed = true;
|
|
458
|
-
try {
|
|
459
|
-
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
460
|
-
} catch {}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
};
|
|
464
|
-
startStream(safeEventHandler).then((cancelFn) => {
|
|
465
|
-
cancel = cancelFn;
|
|
466
|
-
}).catch((err) => {
|
|
467
|
-
console.error("[CLI Stream] Error starting stream:", err);
|
|
468
|
-
if (!completed) {
|
|
469
|
-
completed = true;
|
|
470
|
-
try {
|
|
471
|
-
emit.error(err instanceof Error ? err : new Error(String(err)));
|
|
472
|
-
} catch {}
|
|
473
|
-
}
|
|
474
|
-
});
|
|
475
|
-
return () => {
|
|
476
|
-
completed = true;
|
|
477
|
-
cancel?.();
|
|
478
|
-
};
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
//#endregion
|
|
483
|
-
//#region src/dashboard-git-snapshot.ts
|
|
484
|
-
const execFileAsync$1 = promisify(execFile);
|
|
485
|
-
const EMPTY_DIFF = {
|
|
486
|
-
files: 0,
|
|
487
|
-
insertions: 0,
|
|
488
|
-
deletions: 0
|
|
489
|
-
};
|
|
490
|
-
async function defaultRunGit(cwd, args) {
|
|
491
|
-
try {
|
|
492
|
-
const { stdout } = await execFileAsync$1("git", args, {
|
|
493
|
-
cwd,
|
|
494
|
-
encoding: "utf8",
|
|
495
|
-
maxBuffer: 8 * 1024 * 1024
|
|
496
|
-
});
|
|
497
|
-
return {
|
|
498
|
-
ok: true,
|
|
499
|
-
stdout
|
|
500
|
-
};
|
|
501
|
-
} catch {
|
|
502
|
-
return {
|
|
503
|
-
ok: false,
|
|
504
|
-
stdout: ""
|
|
161
|
+
} catch {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
stdout: ""
|
|
505
165
|
};
|
|
506
166
|
}
|
|
507
167
|
}
|
|
@@ -710,6 +370,7 @@ async function collectWorktree(options) {
|
|
|
710
370
|
path: worktreePath,
|
|
711
371
|
relativePath: relativePath(resolvedProjectDir, worktreePath),
|
|
712
372
|
branchName: parseBranchName(worktree.branchRef, worktree.detached),
|
|
373
|
+
detached: worktree.detached,
|
|
713
374
|
isCurrent: resolvedProjectDir === worktreePath,
|
|
714
375
|
ahead,
|
|
715
376
|
behind,
|
|
@@ -717,6 +378,27 @@ async function collectWorktree(options) {
|
|
|
717
378
|
entries
|
|
718
379
|
};
|
|
719
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
|
+
}
|
|
720
402
|
async function buildDashboardGitSnapshot(options) {
|
|
721
403
|
const runGit = options.runGit ?? defaultRunGit;
|
|
722
404
|
const maxCommitEntries = options.maxCommitEntries ?? 8;
|
|
@@ -751,95 +433,783 @@ async function buildDashboardGitSnapshot(options) {
|
|
|
751
433
|
}
|
|
752
434
|
|
|
753
435
|
//#endregion
|
|
754
|
-
//#region src/dashboard-time-trends.ts
|
|
755
|
-
const MIN_TREND_POINT_LIMIT = 20;
|
|
756
|
-
const MAX_TREND_POINT_LIMIT = 500;
|
|
757
|
-
const DEFAULT_TREND_POINT_LIMIT = 100;
|
|
758
|
-
const TARGET_TREND_BARS = 20;
|
|
759
|
-
const DAY_MS = 1440 * 60 * 1e3;
|
|
760
|
-
function clampPointLimit(pointLimit) {
|
|
761
|
-
if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
|
|
762
|
-
return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
|
|
763
|
-
}
|
|
764
|
-
function createEmptyTrendSeries() {
|
|
765
|
-
return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
|
|
766
|
-
}
|
|
767
|
-
function normalizeEvents(events, pointLimit) {
|
|
768
|
-
return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
|
|
769
|
-
}
|
|
770
|
-
function buildTimeWindow(options) {
|
|
771
|
-
const { probeEvents, targetBars, rightEdgeTs } = options;
|
|
772
|
-
if (probeEvents.length === 0) return null;
|
|
773
|
-
const probeEnd = probeEvents[probeEvents.length - 1].ts;
|
|
774
|
-
const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
|
|
775
|
-
const probeStart = probeEvents[0].ts;
|
|
776
|
-
const rangeMs = Math.max(1, end - probeStart);
|
|
777
|
-
const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
|
|
778
|
-
const windowStart = end - bucketMs * targetBars;
|
|
779
|
-
return {
|
|
780
|
-
windowStart,
|
|
781
|
-
bucketMs,
|
|
782
|
-
bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
|
|
783
|
-
};
|
|
784
|
-
}
|
|
785
|
-
function bucketizeTrend(events, reducer, rightEdgeTs) {
|
|
786
|
-
if (events.length === 0) return [];
|
|
787
|
-
const timeWindow = buildTimeWindow({
|
|
788
|
-
probeEvents: events,
|
|
789
|
-
targetBars: TARGET_TREND_BARS,
|
|
790
|
-
rightEdgeTs
|
|
791
|
-
});
|
|
792
|
-
if (!timeWindow) return [];
|
|
793
|
-
const { windowStart, bucketMs, bucketEnds } = timeWindow;
|
|
794
|
-
const sums = Array.from({ length: bucketEnds.length }, () => 0);
|
|
795
|
-
const counts = Array.from({ length: bucketEnds.length }, () => 0);
|
|
796
|
-
let baseline = 0;
|
|
797
|
-
for (const event of events) {
|
|
798
|
-
if (event.ts <= windowStart) {
|
|
799
|
-
if (reducer === "sum-cumulative") baseline += event.value;
|
|
800
|
-
continue;
|
|
801
|
-
}
|
|
802
|
-
const offset = event.ts - windowStart;
|
|
803
|
-
const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
|
|
804
|
-
sums[index] += event.value;
|
|
805
|
-
counts[index] += 1;
|
|
806
|
-
}
|
|
807
|
-
let cumulative = baseline;
|
|
808
|
-
let carry = baseline !== 0 ? baseline : events[0].value;
|
|
809
|
-
return bucketEnds.map((ts, index) => {
|
|
810
|
-
if (reducer === "sum") return {
|
|
811
|
-
ts,
|
|
812
|
-
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
|
+
}
|
|
813
1195
|
};
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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?.();
|
|
825
1210
|
};
|
|
826
1211
|
});
|
|
827
1212
|
}
|
|
828
|
-
function buildDashboardTimeTrends(options) {
|
|
829
|
-
const pointLimit = clampPointLimit(options.pointLimit);
|
|
830
|
-
const trends = createEmptyTrendSeries();
|
|
831
|
-
for (const metric of DASHBOARD_METRIC_KEYS) {
|
|
832
|
-
if (options.availability[metric].state !== "ok") continue;
|
|
833
|
-
trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
|
|
834
|
-
}
|
|
835
|
-
return {
|
|
836
|
-
trends,
|
|
837
|
-
trendMeta: {
|
|
838
|
-
pointLimit,
|
|
839
|
-
lastUpdatedAt: options.timestamp
|
|
840
|
-
}
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
1213
|
|
|
844
1214
|
//#endregion
|
|
845
1215
|
//#region src/reactive-kv.ts
|
|
@@ -853,7 +1223,7 @@ function buildDashboardTimeTrends(options) {
|
|
|
853
1223
|
*/
|
|
854
1224
|
var ReactiveKV = class {
|
|
855
1225
|
store = /* @__PURE__ */ new Map();
|
|
856
|
-
emitter = new EventEmitter
|
|
1226
|
+
emitter = new EventEmitter();
|
|
857
1227
|
constructor() {
|
|
858
1228
|
this.emitter.setMaxListeners(200);
|
|
859
1229
|
}
|
|
@@ -921,130 +1291,52 @@ function createReactiveSubscription(task) {
|
|
|
921
1291
|
const context = new ReactiveContext();
|
|
922
1292
|
const controller = new AbortController();
|
|
923
1293
|
(async () => {
|
|
924
|
-
try {
|
|
925
|
-
for await (const data of context.stream(task, controller.signal)) emit.next(data);
|
|
926
|
-
} catch (err) {
|
|
927
|
-
if (!controller.signal.aborted) emit.error(err);
|
|
928
|
-
}
|
|
929
|
-
})();
|
|
930
|
-
return () => {
|
|
931
|
-
controller.abort();
|
|
932
|
-
};
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* 创建带输入参数的响应式订阅
|
|
937
|
-
*
|
|
938
|
-
* @param task 接收输入参数的异步任务
|
|
939
|
-
* @returns 返回一个函数,接收输入参数并返回 tRPC observable
|
|
940
|
-
*
|
|
941
|
-
* @example
|
|
942
|
-
* ```typescript
|
|
943
|
-
* // 在 router 中使用
|
|
944
|
-
* subscribeOne: publicProcedure
|
|
945
|
-
* .input(z.object({ id: z.string() }))
|
|
946
|
-
* .subscription(({ ctx, input }) => {
|
|
947
|
-
* return createReactiveSubscriptionWithInput(
|
|
948
|
-
* (id: string) => ctx.adapter.readSpec(id)
|
|
949
|
-
* )(input.id)
|
|
950
|
-
* })
|
|
951
|
-
* ```
|
|
952
|
-
*/
|
|
953
|
-
function createReactiveSubscriptionWithInput(task) {
|
|
954
|
-
return (input) => {
|
|
955
|
-
return createReactiveSubscription(() => task(input));
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
//#endregion
|
|
960
|
-
//#region src/router.ts
|
|
961
|
-
const t = initTRPC.context().create();
|
|
962
|
-
const router = t.router;
|
|
963
|
-
const publicProcedure = t.procedure;
|
|
964
|
-
const execFileAsync = promisify(execFile);
|
|
965
|
-
const OPSX_CORE_PROFILE_WORKFLOWS = [
|
|
966
|
-
"propose",
|
|
967
|
-
"explore",
|
|
968
|
-
"apply",
|
|
969
|
-
"archive"
|
|
970
|
-
];
|
|
971
|
-
const dashboardGitTaskStatusEmitter = new EventEmitter$1();
|
|
972
|
-
dashboardGitTaskStatusEmitter.setMaxListeners(200);
|
|
973
|
-
const dashboardGitTaskStatus = {
|
|
974
|
-
running: false,
|
|
975
|
-
inFlight: 0,
|
|
976
|
-
lastStartedAt: null,
|
|
977
|
-
lastFinishedAt: null,
|
|
978
|
-
lastReason: null,
|
|
979
|
-
lastError: null
|
|
980
|
-
};
|
|
981
|
-
function getDashboardGitTaskStatus() {
|
|
982
|
-
return { ...dashboardGitTaskStatus };
|
|
983
|
-
}
|
|
984
|
-
function emitDashboardGitTaskStatus() {
|
|
985
|
-
dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
|
|
986
|
-
}
|
|
987
|
-
function beginDashboardGitTask(reason) {
|
|
988
|
-
dashboardGitTaskStatus.inFlight += 1;
|
|
989
|
-
dashboardGitTaskStatus.running = true;
|
|
990
|
-
dashboardGitTaskStatus.lastStartedAt = Date.now();
|
|
991
|
-
dashboardGitTaskStatus.lastReason = reason;
|
|
992
|
-
dashboardGitTaskStatus.lastError = null;
|
|
993
|
-
emitDashboardGitTaskStatus();
|
|
994
|
-
}
|
|
995
|
-
function endDashboardGitTask(error) {
|
|
996
|
-
dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
|
|
997
|
-
dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
|
|
998
|
-
dashboardGitTaskStatus.lastFinishedAt = Date.now();
|
|
999
|
-
if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
|
|
1000
|
-
emitDashboardGitTaskStatus();
|
|
1001
|
-
}
|
|
1002
|
-
const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
|
|
1003
|
-
async function resolveGitMetadataDir(projectDir) {
|
|
1004
|
-
try {
|
|
1005
|
-
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
|
|
1006
|
-
cwd: projectDir,
|
|
1007
|
-
maxBuffer: 1024 * 1024,
|
|
1008
|
-
encoding: "utf8"
|
|
1009
|
-
});
|
|
1010
|
-
const gitDirRaw = stdout.trim();
|
|
1011
|
-
if (!gitDirRaw) return null;
|
|
1012
|
-
const gitDirPath = resolve(projectDir, gitDirRaw);
|
|
1013
|
-
if (!(await stat(gitDirPath)).isDirectory()) return null;
|
|
1014
|
-
return gitDirPath;
|
|
1015
|
-
} catch {
|
|
1016
|
-
return null;
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
async function resolveGitMetadataDirReactive(projectDir) {
|
|
1020
|
-
const gitMetadataDir = await resolveGitMetadataDir(projectDir);
|
|
1021
|
-
if (!gitMetadataDir) return null;
|
|
1022
|
-
await reactiveReadDir(gitMetadataDir, { includeHidden: true });
|
|
1023
|
-
return gitMetadataDir;
|
|
1024
|
-
}
|
|
1025
|
-
function getDashboardGitRefreshStampPath(gitMetadataDir) {
|
|
1026
|
-
return join(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
|
|
1027
|
-
}
|
|
1028
|
-
async function touchDashboardGitRefreshStamp(projectDir, reason) {
|
|
1029
|
-
const gitMetadataDir = await resolveGitMetadataDir(projectDir);
|
|
1030
|
-
if (!gitMetadataDir) return { skipped: true };
|
|
1031
|
-
const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
|
|
1032
|
-
await mkdir(dirname(stampPath), { recursive: true });
|
|
1033
|
-
await writeFile(stampPath, `${Date.now()} ${reason}\n`, "utf8");
|
|
1034
|
-
return { skipped: false };
|
|
1035
|
-
}
|
|
1036
|
-
async function registerDashboardGitReactiveDeps(projectDir) {
|
|
1037
|
-
await reactiveReadDir(projectDir, {
|
|
1038
|
-
includeHidden: true,
|
|
1039
|
-
exclude: ["node_modules"]
|
|
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
|
+
};
|
|
1040
1303
|
});
|
|
1041
|
-
const gitMetadataDir = await resolveGitMetadataDirReactive(projectDir);
|
|
1042
|
-
if (!gitMetadataDir) return;
|
|
1043
|
-
await reactiveReadFile(getDashboardGitRefreshStampPath(gitMetadataDir));
|
|
1044
|
-
await reactiveReadFile(join(gitMetadataDir, "HEAD"));
|
|
1045
|
-
await reactiveReadFile(join(gitMetadataDir, "index"));
|
|
1046
|
-
await reactiveReadFile(join(gitMetadataDir, "packed-refs"));
|
|
1047
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
|
+
];
|
|
1048
1340
|
function requireChangeId(changeId) {
|
|
1049
1341
|
if (!changeId) throw new Error("change is required");
|
|
1050
1342
|
return changeId;
|
|
@@ -1214,223 +1506,6 @@ function buildSystemStatus(ctx) {
|
|
|
1214
1506
|
watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
|
|
1215
1507
|
};
|
|
1216
1508
|
}
|
|
1217
|
-
function resolveTrendTimestamp(primary, secondary) {
|
|
1218
|
-
if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
|
|
1219
|
-
if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
|
|
1220
|
-
return null;
|
|
1221
|
-
}
|
|
1222
|
-
function parseDatedIdTimestamp(id) {
|
|
1223
|
-
const match = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
|
|
1224
|
-
if (!match) return null;
|
|
1225
|
-
const year = Number(match[1]);
|
|
1226
|
-
const month = Number(match[2]);
|
|
1227
|
-
const day = Number(match[3]);
|
|
1228
|
-
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
|
|
1229
|
-
if (month < 1 || month > 12) return null;
|
|
1230
|
-
if (day < 1 || day > 31) return null;
|
|
1231
|
-
const ts = Date.UTC(year, month - 1, day);
|
|
1232
|
-
return Number.isFinite(ts) ? ts : null;
|
|
1233
|
-
}
|
|
1234
|
-
function createEmptyTriColorTrends() {
|
|
1235
|
-
return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
|
|
1236
|
-
}
|
|
1237
|
-
async function readLatestCommitTimestamp(projectDir) {
|
|
1238
|
-
try {
|
|
1239
|
-
const { stdout } = await execFileAsync("git", [
|
|
1240
|
-
"log",
|
|
1241
|
-
"-1",
|
|
1242
|
-
"--format=%ct"
|
|
1243
|
-
], {
|
|
1244
|
-
cwd: projectDir,
|
|
1245
|
-
maxBuffer: 1024 * 1024,
|
|
1246
|
-
encoding: "utf8"
|
|
1247
|
-
});
|
|
1248
|
-
const seconds = Number(stdout.trim());
|
|
1249
|
-
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
|
|
1250
|
-
} catch {
|
|
1251
|
-
return null;
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
async function fetchDashboardOverview(ctx, reason = "dashboard-refresh") {
|
|
1255
|
-
if (contextStorage.getStore()) await registerDashboardGitReactiveDeps(ctx.projectDir);
|
|
1256
|
-
const now = Date.now();
|
|
1257
|
-
const [specMetas, changeMetas, archiveMetas] = await Promise.all([
|
|
1258
|
-
ctx.adapter.listSpecsWithMeta(),
|
|
1259
|
-
ctx.adapter.listChangesWithMeta(),
|
|
1260
|
-
ctx.adapter.listArchivedChangesWithMeta()
|
|
1261
|
-
]);
|
|
1262
|
-
await ctx.kernel.waitForWarmup();
|
|
1263
|
-
await ctx.kernel.ensureStatusList();
|
|
1264
|
-
const statusList = ctx.kernel.getStatusList();
|
|
1265
|
-
const changeMetaMap = new Map(changeMetas.map((change) => [change.id, change]));
|
|
1266
|
-
const activeChangeIds = new Set([...changeMetas.map((change) => change.id), ...statusList.map((status) => status.changeName)]);
|
|
1267
|
-
const statusByChange = new Map(statusList.map((status) => [status.changeName, status]));
|
|
1268
|
-
const activeChanges = (await Promise.all([...activeChangeIds].map(async (changeId) => {
|
|
1269
|
-
const status = statusByChange.get(changeId);
|
|
1270
|
-
const changeMeta = changeMetaMap.get(changeId);
|
|
1271
|
-
const statInfo = await reactiveStat(join(ctx.projectDir, "openspec", "changes", changeId));
|
|
1272
|
-
let progress = changeMeta?.progress ?? {
|
|
1273
|
-
total: 0,
|
|
1274
|
-
completed: 0
|
|
1275
|
-
};
|
|
1276
|
-
if (status) try {
|
|
1277
|
-
await ctx.kernel.ensureApplyInstructions(changeId, status.schemaName);
|
|
1278
|
-
const apply = ctx.kernel.getApplyInstructions(changeId, status.schemaName);
|
|
1279
|
-
progress = {
|
|
1280
|
-
total: apply.progress.total,
|
|
1281
|
-
completed: apply.progress.complete
|
|
1282
|
-
};
|
|
1283
|
-
} catch {}
|
|
1284
|
-
return {
|
|
1285
|
-
id: changeId,
|
|
1286
|
-
name: changeMeta?.name ?? changeId,
|
|
1287
|
-
progress,
|
|
1288
|
-
updatedAt: changeMeta?.updatedAt ?? statInfo?.mtime ?? 0
|
|
1289
|
-
};
|
|
1290
|
-
}))).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1291
|
-
const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
|
|
1292
|
-
const change = await ctx.adapter.readArchivedChange(meta.id);
|
|
1293
|
-
if (!change) return null;
|
|
1294
|
-
return {
|
|
1295
|
-
id: meta.id,
|
|
1296
|
-
createdAt: meta.createdAt,
|
|
1297
|
-
updatedAt: meta.updatedAt,
|
|
1298
|
-
tasksCompleted: change.tasks.filter((task) => task.completed).length
|
|
1299
|
-
};
|
|
1300
|
-
}))).filter((item) => item !== null);
|
|
1301
|
-
const specifications = (await Promise.all(specMetas.map(async (meta) => {
|
|
1302
|
-
const spec = await ctx.adapter.readSpec(meta.id);
|
|
1303
|
-
if (!spec) return null;
|
|
1304
|
-
return {
|
|
1305
|
-
id: meta.id,
|
|
1306
|
-
name: meta.name,
|
|
1307
|
-
requirements: spec.requirements.length,
|
|
1308
|
-
updatedAt: meta.updatedAt
|
|
1309
|
-
};
|
|
1310
|
-
}))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
|
|
1311
|
-
const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
|
|
1312
|
-
const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
|
|
1313
|
-
const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
|
|
1314
|
-
const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
|
|
1315
|
-
const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
|
|
1316
|
-
const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
|
|
1317
|
-
const specificationTrendEvents = specMetas.flatMap((spec) => {
|
|
1318
|
-
const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
|
|
1319
|
-
return ts === null ? [] : [{
|
|
1320
|
-
ts,
|
|
1321
|
-
value: 1
|
|
1322
|
-
}];
|
|
1323
|
-
});
|
|
1324
|
-
const completedTrendEvents = archivedChanges.flatMap((archive) => {
|
|
1325
|
-
const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
|
|
1326
|
-
return ts === null ? [] : [{
|
|
1327
|
-
ts,
|
|
1328
|
-
value: archive.tasksCompleted
|
|
1329
|
-
}];
|
|
1330
|
-
});
|
|
1331
|
-
const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
|
|
1332
|
-
const requirementTrendEvents = specifications.flatMap((spec) => {
|
|
1333
|
-
const meta = specMetaById.get(spec.id);
|
|
1334
|
-
const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
|
|
1335
|
-
return ts === null ? [] : [{
|
|
1336
|
-
ts,
|
|
1337
|
-
value: spec.requirements
|
|
1338
|
-
}];
|
|
1339
|
-
});
|
|
1340
|
-
const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
|
|
1341
|
-
const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
|
|
1342
|
-
const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
|
|
1343
|
-
const config = await ctx.configManager.readConfig();
|
|
1344
|
-
beginDashboardGitTask(reason);
|
|
1345
|
-
let latestCommitTs = null;
|
|
1346
|
-
let git;
|
|
1347
|
-
try {
|
|
1348
|
-
const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
|
|
1349
|
-
defaultBranch: "main",
|
|
1350
|
-
worktrees: []
|
|
1351
|
-
}));
|
|
1352
|
-
latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
|
|
1353
|
-
git = await gitSnapshotPromise;
|
|
1354
|
-
} catch (error) {
|
|
1355
|
-
endDashboardGitTask(error);
|
|
1356
|
-
throw error;
|
|
1357
|
-
}
|
|
1358
|
-
endDashboardGitTask(null);
|
|
1359
|
-
const cardAvailability = {
|
|
1360
|
-
specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
|
|
1361
|
-
state: "invalid",
|
|
1362
|
-
reason: "objective-history-unavailable"
|
|
1363
|
-
},
|
|
1364
|
-
requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
|
|
1365
|
-
state: "invalid",
|
|
1366
|
-
reason: "objective-history-unavailable"
|
|
1367
|
-
},
|
|
1368
|
-
activeChanges: {
|
|
1369
|
-
state: "invalid",
|
|
1370
|
-
reason: "objective-history-unavailable"
|
|
1371
|
-
},
|
|
1372
|
-
inProgressChanges: {
|
|
1373
|
-
state: "invalid",
|
|
1374
|
-
reason: "objective-history-unavailable"
|
|
1375
|
-
},
|
|
1376
|
-
completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
|
|
1377
|
-
state: "invalid",
|
|
1378
|
-
reason: "objective-history-unavailable"
|
|
1379
|
-
},
|
|
1380
|
-
taskCompletionPercent: {
|
|
1381
|
-
state: "invalid",
|
|
1382
|
-
reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
|
|
1383
|
-
}
|
|
1384
|
-
};
|
|
1385
|
-
const trendKinds = {
|
|
1386
|
-
specifications: "monotonic",
|
|
1387
|
-
requirements: "monotonic",
|
|
1388
|
-
activeChanges: "bidirectional",
|
|
1389
|
-
inProgressChanges: "bidirectional",
|
|
1390
|
-
completedChanges: "monotonic",
|
|
1391
|
-
taskCompletionPercent: "bidirectional"
|
|
1392
|
-
};
|
|
1393
|
-
const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
|
|
1394
|
-
pointLimit: config.dashboard.trendPointLimit,
|
|
1395
|
-
timestamp: now,
|
|
1396
|
-
rightEdgeTs: latestCommitTs,
|
|
1397
|
-
availability: cardAvailability,
|
|
1398
|
-
events: {
|
|
1399
|
-
specifications: specificationTrendEvents,
|
|
1400
|
-
requirements: requirementTrendEvents,
|
|
1401
|
-
activeChanges: [],
|
|
1402
|
-
inProgressChanges: [],
|
|
1403
|
-
completedChanges: completedTrendEvents,
|
|
1404
|
-
taskCompletionPercent: []
|
|
1405
|
-
},
|
|
1406
|
-
reducers: {
|
|
1407
|
-
specifications: "sum",
|
|
1408
|
-
requirements: "sum",
|
|
1409
|
-
completedChanges: "sum"
|
|
1410
|
-
}
|
|
1411
|
-
});
|
|
1412
|
-
return {
|
|
1413
|
-
summary: {
|
|
1414
|
-
specifications: specifications.length,
|
|
1415
|
-
requirements,
|
|
1416
|
-
activeChanges: activeChanges.length,
|
|
1417
|
-
inProgressChanges,
|
|
1418
|
-
completedChanges: archiveMetas.length,
|
|
1419
|
-
archivedTasksCompleted,
|
|
1420
|
-
tasksTotal,
|
|
1421
|
-
tasksCompleted,
|
|
1422
|
-
taskCompletionPercent
|
|
1423
|
-
},
|
|
1424
|
-
trends: baselineTrends,
|
|
1425
|
-
triColorTrends: createEmptyTriColorTrends(),
|
|
1426
|
-
trendKinds,
|
|
1427
|
-
cardAvailability,
|
|
1428
|
-
trendMeta,
|
|
1429
|
-
specifications,
|
|
1430
|
-
activeChanges,
|
|
1431
|
-
git
|
|
1432
|
-
};
|
|
1433
|
-
}
|
|
1434
1509
|
/**
|
|
1435
1510
|
* Spec router - spec CRUD operations
|
|
1436
1511
|
*/
|
|
@@ -1639,6 +1714,7 @@ const configRouter = router({
|
|
|
1639
1714
|
"system"
|
|
1640
1715
|
]).optional(),
|
|
1641
1716
|
codeEditor: z.object({ theme: CodeEditorThemeSchema.optional() }).optional(),
|
|
1717
|
+
appBaseUrl: z.string().optional(),
|
|
1642
1718
|
terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional(),
|
|
1643
1719
|
dashboard: DashboardConfigSchema.partial().optional()
|
|
1644
1720
|
})).mutation(async ({ ctx, input }) => {
|
|
@@ -1646,9 +1722,10 @@ const configRouter = router({
|
|
|
1646
1722
|
const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
|
|
1647
1723
|
if (hasCliCommand && !hasCliArgs) {
|
|
1648
1724
|
await ctx.configManager.setCliCommand(input.cli?.command ?? "");
|
|
1649
|
-
if (input.theme !== void 0 || input.codeEditor !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
|
|
1725
|
+
if (input.theme !== void 0 || input.codeEditor !== void 0 || input.appBaseUrl !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
|
|
1650
1726
|
theme: input.theme,
|
|
1651
1727
|
codeEditor: input.codeEditor,
|
|
1728
|
+
appBaseUrl: input.appBaseUrl,
|
|
1652
1729
|
terminal: input.terminal,
|
|
1653
1730
|
dashboard: input.dashboard
|
|
1654
1731
|
});
|
|
@@ -2130,30 +2207,52 @@ const systemRouter = router({
|
|
|
2130
2207
|
*/
|
|
2131
2208
|
const dashboardRouter = router({
|
|
2132
2209
|
get: publicProcedure.query(async ({ ctx }) => {
|
|
2133
|
-
return
|
|
2210
|
+
return ctx.dashboardOverviewService.getCurrent();
|
|
2134
2211
|
}),
|
|
2135
2212
|
subscribe: publicProcedure.subscription(({ ctx }) => {
|
|
2136
|
-
return
|
|
2137
|
-
|
|
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
|
+
};
|
|
2138
2225
|
});
|
|
2139
2226
|
}),
|
|
2140
2227
|
refreshGitSnapshot: publicProcedure.input(z.object({ reason: z.string().optional() }).optional()).mutation(async ({ ctx, input }) => {
|
|
2141
2228
|
const reason = input?.reason?.trim() || "manual-refresh";
|
|
2142
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");
|
|
2143
2241
|
return { success: true };
|
|
2144
2242
|
}),
|
|
2145
|
-
gitTaskStatus: publicProcedure.query(() => {
|
|
2243
|
+
gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
|
|
2244
|
+
await ctx.dashboardOverviewService.getCurrent();
|
|
2146
2245
|
return getDashboardGitTaskStatus();
|
|
2147
2246
|
}),
|
|
2148
|
-
subscribeGitTaskStatus: publicProcedure.subscription(() => {
|
|
2247
|
+
subscribeGitTaskStatus: publicProcedure.subscription(({ ctx }) => {
|
|
2149
2248
|
return observable((emit) => {
|
|
2249
|
+
ctx.dashboardOverviewService.getCurrent().catch(() => {});
|
|
2150
2250
|
emit.next(getDashboardGitTaskStatus());
|
|
2151
|
-
const
|
|
2251
|
+
const unsubscribe = subscribeDashboardGitTaskStatus((status) => {
|
|
2152
2252
|
emit.next(status);
|
|
2153
|
-
};
|
|
2154
|
-
dashboardGitTaskStatusEmitter.on("change", handler);
|
|
2253
|
+
});
|
|
2155
2254
|
return () => {
|
|
2156
|
-
|
|
2255
|
+
unsubscribe();
|
|
2157
2256
|
};
|
|
2158
2257
|
});
|
|
2159
2258
|
})
|
|
@@ -2323,6 +2422,17 @@ var SearchService = class {
|
|
|
2323
2422
|
*
|
|
2324
2423
|
* @module server
|
|
2325
2424
|
*/
|
|
2425
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
2426
|
+
function getServerPackageVersion() {
|
|
2427
|
+
try {
|
|
2428
|
+
const packageJsonPath = join(__dirname, "..", "package.json");
|
|
2429
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
2430
|
+
return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
|
|
2431
|
+
} catch {
|
|
2432
|
+
return "0.0.0";
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
const SERVER_PACKAGE_VERSION = getServerPackageVersion();
|
|
2326
2436
|
/**
|
|
2327
2437
|
* Create an OpenSpecUI HTTP server with optional WebSocket support
|
|
2328
2438
|
*/
|
|
@@ -2333,6 +2443,11 @@ function createServer(config) {
|
|
|
2333
2443
|
const kernel = config.kernel;
|
|
2334
2444
|
const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
|
|
2335
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);
|
|
2336
2451
|
const app = new Hono();
|
|
2337
2452
|
const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
|
|
2338
2453
|
app.use("*", cors({
|
|
@@ -2343,7 +2458,9 @@ function createServer(config) {
|
|
|
2343
2458
|
return c.json({
|
|
2344
2459
|
status: "ok",
|
|
2345
2460
|
projectDir: config.projectDir,
|
|
2346
|
-
|
|
2461
|
+
projectName: basename(config.projectDir) || config.projectDir,
|
|
2462
|
+
watcherEnabled: !!watcher,
|
|
2463
|
+
openspecuiVersion: SERVER_PACKAGE_VERSION
|
|
2347
2464
|
});
|
|
2348
2465
|
});
|
|
2349
2466
|
app.use("/trpc/*", async (c) => {
|
|
@@ -2357,6 +2474,7 @@ function createServer(config) {
|
|
|
2357
2474
|
cliExecutor,
|
|
2358
2475
|
kernel,
|
|
2359
2476
|
searchService,
|
|
2477
|
+
dashboardOverviewService,
|
|
2360
2478
|
watcher,
|
|
2361
2479
|
projectDir: config.projectDir
|
|
2362
2480
|
})
|
|
@@ -2368,6 +2486,7 @@ function createServer(config) {
|
|
|
2368
2486
|
cliExecutor,
|
|
2369
2487
|
kernel,
|
|
2370
2488
|
searchService,
|
|
2489
|
+
dashboardOverviewService,
|
|
2371
2490
|
watcher,
|
|
2372
2491
|
projectDir: config.projectDir
|
|
2373
2492
|
});
|
|
@@ -2378,6 +2497,7 @@ function createServer(config) {
|
|
|
2378
2497
|
cliExecutor,
|
|
2379
2498
|
kernel,
|
|
2380
2499
|
searchService,
|
|
2500
|
+
dashboardOverviewService,
|
|
2381
2501
|
watcher,
|
|
2382
2502
|
createContext,
|
|
2383
2503
|
port: config.port ?? 3100
|
|
@@ -2425,6 +2545,7 @@ async function createWebSocketServer(server, httpServer, config) {
|
|
|
2425
2545
|
wss.close();
|
|
2426
2546
|
server.watcher?.stop();
|
|
2427
2547
|
server.searchService.dispose().catch(() => {});
|
|
2548
|
+
server.dashboardOverviewService.dispose();
|
|
2428
2549
|
}
|
|
2429
2550
|
};
|
|
2430
2551
|
}
|
|
@@ -2460,6 +2581,9 @@ async function startServer(config, setupApp) {
|
|
|
2460
2581
|
server.searchService.init().catch((err) => {
|
|
2461
2582
|
console.error("Search service warmup failed:", err);
|
|
2462
2583
|
});
|
|
2584
|
+
server.dashboardOverviewService.init().catch((err) => {
|
|
2585
|
+
console.error("Dashboard overview warmup failed:", err);
|
|
2586
|
+
});
|
|
2463
2587
|
return {
|
|
2464
2588
|
url,
|
|
2465
2589
|
port,
|