@openspecui/server 2.1.0 → 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.
Files changed (3) hide show
  1. package/dist/index.mjs +984 -854
  2. package/package.json +15 -11
  3. 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, contextStorage, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, reactiveReadDir, reactiveReadFile, sniffGlobalCli } from "@openspecui/core";
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/pty-manager.ts
60
- const DEFAULT_SCROLLBACK = 1e3;
61
- const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
62
- function detectPtyPlatform() {
63
- if (process.platform === "win32") return "windows";
64
- if (process.platform === "darwin") return "macos";
65
- return "common";
66
- }
67
- function resolveDefaultShell(platform, env) {
68
- if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
69
- return env.SHELL?.trim() || "/bin/sh";
70
- }
71
- function resolvePtyCommand(opts) {
72
- const command = opts.command?.trim();
73
- if (command) return {
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
- appendBuffer(data) {
153
- let chunk = data;
154
- if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
155
- this.buffer.push(chunk);
156
- this.bufferByteLength += chunk.length;
157
- while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
158
- const removed = this.buffer.shift();
159
- this.bufferByteLength -= removed.length;
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
- getBuffer() {
167
- return this.buffer.join("");
86
+ async getCurrent() {
87
+ if (this.current) return this.current;
88
+ return this.init();
168
89
  }
169
- write(data) {
170
- if (!this.isExited) this.process.write(data);
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
- resize(cols, rows) {
173
- if (!this.isExited) this.process.resize(cols, rows);
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
- close() {
176
- if (this.titleInterval) {
177
- clearInterval(this.titleInterval);
178
- this.titleInterval = null;
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.process.kill();
182
- } catch {}
183
- this.removeAllListeners();
184
- }
185
- toInfo() {
186
- return {
187
- id: this.id,
188
- title: this.lastTitle,
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
- closeAll() {
247
- for (const session of this.sessions.values()) session.close();
248
- this.sessions.clear();
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/pty-websocket.ts
254
- function createPtyWebSocketHandler(ptyManager) {
255
- return (ws) => {
256
- const cleanups = /* @__PURE__ */ new Map();
257
- const send = (msg) => {
258
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
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
- const sendError = (code, message, opts) => {
261
- send({
262
- type: "error",
263
- code,
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
- if (reducer === "sum-cumulative") {
817
- cumulative += sums[index];
818
- return {
819
- ts,
820
- value: cumulative
821
- };
822
- }
823
- if (counts[index] > 0) carry = sums[index] / counts[index];
824
- return {
825
- ts,
826
- value: carry
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$1();
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 fetchDashboardOverview(ctx, "dashboard.get");
2210
+ return ctx.dashboardOverviewService.getCurrent();
2115
2211
  }),
2116
2212
  subscribe: publicProcedure.subscription(({ ctx }) => {
2117
- return createReactiveSubscription(async () => {
2118
- return fetchDashboardOverview(ctx, "dashboard.subscribe");
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 handler = (status) => {
2251
+ const unsubscribe = subscribeDashboardGitTaskStatus((status) => {
2133
2252
  emit.next(status);
2134
- };
2135
- dashboardGitTaskStatusEmitter.on("change", handler);
2253
+ });
2136
2254
  return () => {
2137
- dashboardGitTaskStatusEmitter.off("change", handler);
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,