openspecui 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 (32) hide show
  1. package/dist/cli.mjs +2 -2
  2. package/dist/index.mjs +1 -1
  3. package/dist/{src-B5XQ-ERE.mjs → src-Nn_MJz41.mjs} +1424 -1294
  4. package/package.json +17 -13
  5. package/web/assets/{BufferResource-WPSrP4iZ.js → BufferResource-CSbEbiLP.js} +1 -1
  6. package/web/assets/{CanvasRenderer-yL0hHLLA.js → CanvasRenderer-CvpAMPzA.js} +1 -1
  7. package/web/assets/{Filter-MPAI5-Oj.js → Filter-BqwVCa6B.js} +1 -1
  8. package/web/assets/{RenderTargetSystem-D4qtRaUW.js → RenderTargetSystem-mD_Y2dUA.js} +1 -1
  9. package/web/assets/{WebGLRenderer-BHfrXEBc.js → WebGLRenderer-DsvDwXyL.js} +1 -1
  10. package/web/assets/{WebGPURenderer-DE5Q1Ilu.js → WebGPURenderer-YcBYXAol.js} +1 -1
  11. package/web/assets/{browserAll-qg0ACMg1.js → browserAll-MbWxSnea.js} +1 -1
  12. package/web/assets/{ghostty-web-D1cPLYOq.js → ghostty-web-cM8zjOdb.js} +1 -1
  13. package/web/assets/{index-BQWvXpvf.js → index-B0lABsCj.js} +1 -1
  14. package/web/assets/{index-D7oe-Hg-.js → index-B1J3yPM2.js} +1 -1
  15. package/web/assets/{index-BEpMDdcu.js → index-BCMJS8eq.js} +1 -1
  16. package/web/assets/{index-C8igR5_C.js → index-BMKX_bGe.js} +1 -1
  17. package/web/assets/{index-BTEmB46j.js → index-BY40EQxT.js} +1 -1
  18. package/web/assets/{index-BLmH2_gh.js → index-Cq4njHD4.js} +183 -182
  19. package/web/assets/{index-DlK0frtF.js → index-Cr4OFm9E.js} +1 -1
  20. package/web/assets/{index-DBI0U-_m.js → index-CskIUxS-.js} +1 -1
  21. package/web/assets/{index-B2AIdjev.js → index-D0sXMPhu.js} +1 -1
  22. package/web/assets/{index-D1IhiEyy.css → index-DHeR2UYq.css} +1 -1
  23. package/web/assets/{index-DfNI0fNY.js → index-DijRYB99.js} +1 -1
  24. package/web/assets/{index-Gknjn7E7.js → index-DuZDcW_P.js} +1 -1
  25. package/web/assets/{index-DYz8XhtF.js → index-DyZwNTgF.js} +1 -1
  26. package/web/assets/{index-BM_5Tg9c.js → index-JHrsE4PU.js} +1 -1
  27. package/web/assets/{index-CQRaC2Gn.js → index-KaodXPu0.js} +1 -1
  28. package/web/assets/{index-DMpt0Kcu.js → index-TGQYcfbH.js} +1 -1
  29. package/web/assets/{index-CSledjj-.js → index-kjHuAdn9.js} +1 -1
  30. package/web/assets/{webworkerAll-iHUuedbL.js → webworkerAll-BE_Ec3Rk.js} +1 -1
  31. package/web/index.html +2 -2
  32. package/LICENSE +0 -21
@@ -15,10 +15,10 @@ import { watch } from "fs";
15
15
  import { exec, spawn } from "child_process";
16
16
  import { promisify } from "util";
17
17
  import { fileURLToPath } from "node:url";
18
- import * as pty from "@lydell/node-pty";
19
- import { execFile } from "node:child_process";
20
18
  import { EventEmitter as EventEmitter$1 } from "node:events";
19
+ import { execFile } from "node:child_process";
21
20
  import { promisify as promisify$1 } from "node:util";
21
+ import * as pty from "@lydell/node-pty";
22
22
  import { Worker as Worker$1 } from "node:worker_threads";
23
23
 
24
24
  //#region rolldown:runtime
@@ -22886,1022 +22886,1390 @@ var import_websocket = /* @__PURE__ */ __toESM$1(require_websocket(), 1);
22886
22886
  var import_websocket_server = /* @__PURE__ */ __toESM$1(require_websocket_server(), 1);
22887
22887
 
22888
22888
  //#endregion
22889
- //#region ../server/src/pty-manager.ts
22890
- const DEFAULT_SCROLLBACK = 1e3;
22891
- const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
22892
- function detectPtyPlatform() {
22893
- if (process.platform === "win32") return "windows";
22894
- if (process.platform === "darwin") return "macos";
22895
- return "common";
22896
- }
22897
- function resolveDefaultShell(platform, env) {
22898
- if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
22899
- return env.SHELL?.trim() || "/bin/sh";
22900
- }
22901
- function resolvePtyCommand(opts) {
22902
- const command = opts.command?.trim();
22903
- if (command) return {
22904
- command,
22905
- args: opts.args ?? []
22906
- };
22907
- return {
22908
- command: resolveDefaultShell(opts.platform, opts.env),
22909
- args: []
22910
- };
22911
- }
22912
- var PtySession = class extends EventEmitter {
22913
- id;
22914
- command;
22915
- args;
22916
- platform;
22917
- closeTip;
22918
- closeCallbackUrl;
22919
- createdAt;
22920
- process;
22921
- titleInterval = null;
22922
- lastTitle = "";
22923
- buffer = [];
22924
- bufferByteLength = 0;
22925
- maxBufferLines;
22926
- maxBufferBytes;
22927
- isExited = false;
22928
- exitCode = null;
22929
- constructor(id, opts) {
22930
- super();
22931
- this.id = id;
22932
- this.createdAt = Date.now();
22933
- const resolvedCommand = resolvePtyCommand({
22934
- platform: opts.platform,
22935
- command: opts.command,
22936
- args: opts.args,
22937
- env: process.env
22938
- });
22939
- this.command = resolvedCommand.command;
22940
- this.args = resolvedCommand.args;
22941
- this.platform = opts.platform;
22942
- this.closeTip = opts.closeTip;
22943
- this.closeCallbackUrl = opts.closeCallbackUrl;
22944
- this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
22945
- this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
22946
- this.process = pty.spawn(this.command, this.args, {
22947
- name: "xterm-256color",
22948
- cols: opts.cols ?? 80,
22949
- rows: opts.rows ?? 24,
22950
- cwd: opts.cwd,
22951
- env: {
22952
- ...process.env,
22953
- TERM: "xterm-256color"
22954
- }
22955
- });
22956
- this.process.onData((data) => {
22957
- this.appendBuffer(data);
22958
- this.emit("data", data);
22959
- });
22960
- this.process.onExit(({ exitCode }) => {
22961
- if (this.titleInterval) {
22962
- clearInterval(this.titleInterval);
22963
- this.titleInterval = null;
22964
- }
22965
- this.isExited = true;
22966
- this.exitCode = exitCode;
22967
- this.emit("exit", exitCode);
22889
+ //#region ../server/src/dashboard-overview-service.ts
22890
+ const REBUILD_DEBOUNCE_MS$1 = 250;
22891
+ var DashboardOverviewService = class {
22892
+ current = null;
22893
+ initialized = false;
22894
+ initPromise = null;
22895
+ refreshPromise = null;
22896
+ refreshTimer = null;
22897
+ pendingRefreshReason = null;
22898
+ emitter = new EventEmitter$1();
22899
+ constructor(loadOverview, watcher) {
22900
+ this.loadOverview = loadOverview;
22901
+ this.emitter.setMaxListeners(200);
22902
+ watcher?.on("change", () => {
22903
+ this.scheduleRefresh("watcher-change");
22968
22904
  });
22969
- this.titleInterval = setInterval(() => {
22970
- try {
22971
- const title = this.process.process;
22972
- if (title && title !== this.lastTitle) {
22973
- this.lastTitle = title;
22974
- this.emit("title", title);
22975
- }
22976
- } catch {}
22977
- }, 1e3);
22978
- }
22979
- get title() {
22980
- return this.lastTitle;
22981
- }
22982
- appendBuffer(data) {
22983
- let chunk = data;
22984
- if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
22985
- this.buffer.push(chunk);
22986
- this.bufferByteLength += chunk.length;
22987
- while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
22988
- const removed = this.buffer.shift();
22989
- this.bufferByteLength -= removed.length;
22990
- }
22991
- while (this.buffer.length > this.maxBufferLines) {
22992
- const removed = this.buffer.shift();
22993
- this.bufferByteLength -= removed.length;
22994
- }
22995
- }
22996
- getBuffer() {
22997
- return this.buffer.join("");
22998
- }
22999
- write(data) {
23000
- if (!this.isExited) this.process.write(data);
23001
22905
  }
23002
- resize(cols, rows) {
23003
- if (!this.isExited) this.process.resize(cols, rows);
23004
- }
23005
- close() {
23006
- if (this.titleInterval) {
23007
- clearInterval(this.titleInterval);
23008
- this.titleInterval = null;
23009
- }
22906
+ async init() {
22907
+ if (this.initialized && this.current) return this.current;
22908
+ if (this.initPromise) return this.initPromise;
22909
+ this.initPromise = this.refresh("init");
23010
22910
  try {
23011
- this.process.kill();
23012
- } catch {}
23013
- this.removeAllListeners();
23014
- }
23015
- toInfo() {
23016
- return {
23017
- id: this.id,
23018
- title: this.lastTitle,
23019
- command: this.command,
23020
- args: this.args,
23021
- platform: this.platform,
23022
- isExited: this.isExited,
23023
- exitCode: this.exitCode,
23024
- closeTip: this.closeTip,
23025
- closeCallbackUrl: this.closeCallbackUrl,
23026
- createdAt: this.createdAt
23027
- };
22911
+ return await this.initPromise;
22912
+ } finally {
22913
+ this.initPromise = null;
22914
+ }
23028
22915
  }
23029
- };
23030
- var PtyManager = class {
23031
- sessions = /* @__PURE__ */ new Map();
23032
- idCounter = 0;
23033
- platform;
23034
- constructor(defaultCwd) {
23035
- this.defaultCwd = defaultCwd;
23036
- this.platform = detectPtyPlatform();
22916
+ async getCurrent() {
22917
+ if (this.current) return this.current;
22918
+ return this.init();
23037
22919
  }
23038
- create(opts) {
23039
- const id = `pty-${++this.idCounter}`;
23040
- const session = new PtySession(id, {
23041
- cols: opts.cols,
23042
- rows: opts.rows,
23043
- command: opts.command,
23044
- args: opts.args,
23045
- closeTip: opts.closeTip,
23046
- closeCallbackUrl: opts.closeCallbackUrl,
23047
- cwd: this.defaultCwd,
23048
- scrollback: opts.scrollback,
23049
- maxBufferBytes: opts.maxBufferBytes,
23050
- platform: this.platform
22920
+ subscribe(listener, options) {
22921
+ this.emitter.on("change", listener);
22922
+ if (options?.emitCurrent) if (this.current) listener(this.current);
22923
+ else this.init().catch((error) => {
22924
+ options?.onError?.(error instanceof Error ? error : new Error(String(error)));
23051
22925
  });
23052
- this.sessions.set(id, session);
23053
- return session;
23054
- }
23055
- get(id) {
23056
- return this.sessions.get(id);
23057
- }
23058
- list() {
23059
- const result = [];
23060
- for (const session of this.sessions.values()) result.push(session.toInfo());
23061
- return result;
23062
- }
23063
- write(id, data) {
23064
- this.sessions.get(id)?.write(data);
23065
- }
23066
- resize(id, cols, rows) {
23067
- this.sessions.get(id)?.resize(cols, rows);
22926
+ return () => {
22927
+ this.emitter.off("change", listener);
22928
+ };
23068
22929
  }
23069
- close(id) {
23070
- const session = this.sessions.get(id);
23071
- if (session) {
23072
- session.close();
23073
- this.sessions.delete(id);
22930
+ scheduleRefresh(reason = "scheduled-refresh") {
22931
+ this.cancelScheduledRefresh();
22932
+ this.refreshTimer = setTimeout(() => {
22933
+ this.refreshTimer = null;
22934
+ this.refresh(reason).catch(() => {});
22935
+ }, REBUILD_DEBOUNCE_MS$1);
22936
+ }
22937
+ async refresh(reason = "manual-refresh") {
22938
+ this.cancelScheduledRefresh();
22939
+ if (this.refreshPromise) {
22940
+ this.pendingRefreshReason = reason;
22941
+ return this.refreshPromise;
22942
+ }
22943
+ this.refreshPromise = (async () => {
22944
+ const next = await this.loadOverview(reason);
22945
+ this.current = next;
22946
+ this.initialized = true;
22947
+ this.emitter.emit("change", next);
22948
+ return next;
22949
+ })();
22950
+ try {
22951
+ return await this.refreshPromise;
22952
+ } finally {
22953
+ this.refreshPromise = null;
22954
+ if (this.pendingRefreshReason) {
22955
+ const pendingReason = this.pendingRefreshReason;
22956
+ this.pendingRefreshReason = null;
22957
+ this.refresh(pendingReason).catch(() => {});
22958
+ }
23074
22959
  }
23075
22960
  }
23076
- closeAll() {
23077
- for (const session of this.sessions.values()) session.close();
23078
- this.sessions.clear();
22961
+ dispose() {
22962
+ this.cancelScheduledRefresh();
22963
+ this.emitter.removeAllListeners();
22964
+ }
22965
+ cancelScheduledRefresh() {
22966
+ if (!this.refreshTimer) return;
22967
+ clearTimeout(this.refreshTimer);
22968
+ this.refreshTimer = null;
23079
22969
  }
23080
22970
  };
23081
22971
 
23082
22972
  //#endregion
23083
- //#region ../server/src/pty-websocket.ts
23084
- function createPtyWebSocketHandler(ptyManager) {
23085
- return (ws) => {
23086
- const cleanups = /* @__PURE__ */ new Map();
23087
- const send = (msg) => {
23088
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
22973
+ //#region ../server/src/dashboard-git-snapshot.ts
22974
+ const execFileAsync$1 = promisify$1(execFile);
22975
+ const EMPTY_DIFF = {
22976
+ files: 0,
22977
+ insertions: 0,
22978
+ deletions: 0
22979
+ };
22980
+ async function defaultRunGit(cwd, args) {
22981
+ try {
22982
+ const { stdout } = await execFileAsync$1("git", args, {
22983
+ cwd,
22984
+ encoding: "utf8",
22985
+ maxBuffer: 8 * 1024 * 1024
22986
+ });
22987
+ return {
22988
+ ok: true,
22989
+ stdout
23089
22990
  };
23090
- const sendError = (code, message, opts) => {
23091
- send({
23092
- type: "error",
23093
- code,
23094
- message,
23095
- sessionId: opts?.sessionId
23096
- });
22991
+ } catch {
22992
+ return {
22993
+ ok: false,
22994
+ stdout: ""
23097
22995
  };
23098
- const attachToSession = (session, opts) => {
23099
- const sessionId = session.id;
23100
- cleanups.get(sessionId)?.();
23101
- if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
23102
- const onData = (data) => {
23103
- send({
23104
- type: "output",
23105
- sessionId,
23106
- data
23107
- });
23108
- };
23109
- const onExit = (exitCode) => {
23110
- send({
23111
- type: "exit",
23112
- sessionId,
23113
- exitCode
23114
- });
23115
- };
23116
- const onTitle = (title) => {
23117
- send({
23118
- type: "title",
23119
- sessionId,
23120
- title
23121
- });
23122
- };
23123
- session.on("data", onData);
23124
- session.on("exit", onExit);
23125
- session.on("title", onTitle);
23126
- cleanups.set(sessionId, () => {
23127
- session.removeListener("data", onData);
23128
- session.removeListener("exit", onExit);
23129
- session.removeListener("title", onTitle);
23130
- cleanups.delete(sessionId);
23131
- });
23132
- };
23133
- ws.on("message", (raw$1) => {
23134
- let parsed;
23135
- try {
23136
- parsed = JSON.parse(String(raw$1));
23137
- } catch {
23138
- sendError("INVALID_JSON", "Invalid JSON payload");
23139
- return;
23140
- }
23141
- const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
23142
- if (!parsedMessage.success) {
23143
- const firstIssue = parsedMessage.error.issues[0]?.message;
23144
- sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
23145
- return;
23146
- }
23147
- const msg = parsedMessage.data;
23148
- switch (msg.type) {
23149
- case "create":
23150
- try {
23151
- const createMessage = msg;
23152
- const session = ptyManager.create({
23153
- cols: msg.cols,
23154
- rows: msg.rows,
23155
- command: msg.command,
23156
- args: msg.args,
23157
- closeTip: createMessage.closeTip,
23158
- closeCallbackUrl: createMessage.closeCallbackUrl
23159
- });
23160
- send({
23161
- type: "created",
23162
- requestId: msg.requestId,
23163
- sessionId: session.id,
23164
- platform: session.platform
23165
- });
23166
- attachToSession(session);
23167
- } catch (err) {
23168
- sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
23169
- }
23170
- break;
23171
- case "attach": {
23172
- const session = ptyManager.get(msg.sessionId);
23173
- if (!session) {
23174
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23175
- send({
23176
- type: "exit",
23177
- sessionId: msg.sessionId,
23178
- exitCode: -1
23179
- });
23180
- break;
23181
- }
23182
- attachToSession(session, {
23183
- cols: msg.cols,
23184
- rows: msg.rows
23185
- });
23186
- const buffer = session.getBuffer();
23187
- if (buffer) send({
23188
- type: "buffer",
23189
- sessionId: session.id,
23190
- data: buffer
23191
- });
23192
- if (session.title) send({
23193
- type: "title",
23194
- sessionId: session.id,
23195
- title: session.title
23196
- });
23197
- if (session.isExited) send({
23198
- type: "exit",
23199
- sessionId: session.id,
23200
- exitCode: session.exitCode ?? -1
23201
- });
23202
- break;
23203
- }
23204
- case "list":
23205
- send({
23206
- type: "list",
23207
- sessions: ptyManager.list().map((s) => ({
23208
- id: s.id,
23209
- title: s.title,
23210
- command: s.command,
23211
- args: s.args,
23212
- platform: s.platform,
23213
- isExited: s.isExited,
23214
- exitCode: s.exitCode,
23215
- closeTip: s.closeTip,
23216
- closeCallbackUrl: s.closeCallbackUrl
23217
- }))
23218
- });
23219
- break;
23220
- case "input": {
23221
- const session = ptyManager.get(msg.sessionId);
23222
- if (!session) {
23223
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23224
- break;
23225
- }
23226
- session.write(msg.data);
23227
- break;
23228
- }
23229
- case "resize": {
23230
- const session = ptyManager.get(msg.sessionId);
23231
- if (!session) {
23232
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23233
- break;
23234
- }
23235
- session.resize(msg.cols, msg.rows);
23236
- break;
23237
- }
23238
- case "close": {
23239
- const session = ptyManager.get(msg.sessionId);
23240
- if (!session) {
23241
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23242
- break;
23243
- }
23244
- cleanups.get(msg.sessionId)?.();
23245
- ptyManager.close(session.id);
23246
- break;
23247
- }
23248
- }
23249
- });
23250
- ws.on("close", () => {
23251
- for (const cleanup of cleanups.values()) cleanup();
23252
- cleanups.clear();
23253
- });
22996
+ }
22997
+ }
22998
+ function parseShortStat(output) {
22999
+ const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
23000
+ const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
23001
+ const deletions = Number(/(\d+)\s+deletions?\(-\)/.exec(output)?.[1] ?? 0);
23002
+ return {
23003
+ files: Number.isFinite(files) ? files : 0,
23004
+ insertions: Number.isFinite(insertions) ? insertions : 0,
23005
+ deletions: Number.isFinite(deletions) ? deletions : 0
23254
23006
  };
23255
23007
  }
23256
-
23257
- //#endregion
23258
- //#region ../search/src/protocol.ts
23259
- const SearchDocumentKindSchema = enumType([
23260
- "spec",
23261
- "change",
23262
- "archive"
23263
- ]);
23264
- const SearchDocumentSchema = objectType({
23265
- id: stringType(),
23266
- kind: SearchDocumentKindSchema,
23267
- title: stringType(),
23268
- href: stringType(),
23269
- path: stringType(),
23270
- content: stringType(),
23271
- updatedAt: numberType()
23272
- });
23273
- const SearchQuerySchema = objectType({
23274
- query: stringType(),
23275
- limit: numberType().int().positive().optional()
23276
- });
23277
- const SearchHitSchema = objectType({
23278
- documentId: stringType(),
23279
- kind: SearchDocumentKindSchema,
23280
- title: stringType(),
23281
- href: stringType(),
23282
- path: stringType(),
23283
- score: numberType(),
23284
- snippet: stringType(),
23285
- updatedAt: numberType()
23286
- });
23287
- const SearchWorkerRequestSchema = discriminatedUnionType("type", [
23288
- objectType({
23289
- id: stringType(),
23290
- type: literalType("init"),
23291
- docs: arrayType(SearchDocumentSchema)
23292
- }),
23293
- objectType({
23294
- id: stringType(),
23295
- type: literalType("replaceAll"),
23296
- docs: arrayType(SearchDocumentSchema)
23297
- }),
23298
- objectType({
23299
- id: stringType(),
23300
- type: literalType("search"),
23301
- query: SearchQuerySchema
23302
- }),
23303
- objectType({
23304
- id: stringType(),
23305
- type: literalType("dispose")
23306
- })
23307
- ]);
23308
- const SearchWorkerResponseSchema = discriminatedUnionType("type", [
23309
- objectType({
23310
- id: stringType(),
23311
- type: literalType("ok")
23312
- }),
23313
- objectType({
23314
- id: stringType(),
23315
- type: literalType("results"),
23316
- hits: arrayType(SearchHitSchema)
23317
- }),
23318
- objectType({
23319
- id: stringType(),
23320
- type: literalType("error"),
23321
- message: stringType()
23322
- })
23323
- ]);
23324
-
23325
- //#endregion
23326
- //#region ../search/src/worker-source.ts
23327
- const sharedRuntimeSource = String.raw`
23328
- const DEFAULT_LIMIT = 50;
23329
- const MAX_LIMIT = 200;
23330
- const SNIPPET_SIZE = 180;
23331
-
23332
- function normalizeText(input) {
23333
- return String(input || '')
23334
- .toLowerCase()
23335
- .replace(/\s+/g, ' ')
23336
- .trim();
23008
+ function parseNumStat(output) {
23009
+ let files = 0;
23010
+ let insertions = 0;
23011
+ let deletions = 0;
23012
+ for (const line of output.split("\n")) {
23013
+ const trimmed = line.trim();
23014
+ if (!trimmed) continue;
23015
+ const [addRaw, deleteRaw] = trimmed.split(" ");
23016
+ if (!addRaw || !deleteRaw) continue;
23017
+ files += 1;
23018
+ if (addRaw !== "-") insertions += Number(addRaw) || 0;
23019
+ if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
23020
+ }
23021
+ return {
23022
+ files,
23023
+ insertions,
23024
+ deletions
23025
+ };
23337
23026
  }
23338
-
23339
- function splitTerms(query) {
23340
- return normalizeText(query)
23341
- .split(' ')
23342
- .map((term) => term.trim())
23343
- .filter((term) => term.length > 0);
23027
+ function normalizeGitPath(path$1) {
23028
+ return path$1.replace(/\\/g, "/").replace(/^\.\//, "");
23344
23029
  }
23345
-
23346
- function toSearchIndexDocument(doc) {
23347
- return {
23348
- id: doc.id,
23349
- kind: doc.kind,
23350
- title: doc.title,
23351
- href: doc.href,
23352
- path: doc.path,
23353
- content: doc.content,
23354
- updatedAt: doc.updatedAt,
23355
- normalizedTitle: normalizeText(doc.title),
23356
- normalizedPath: normalizeText(doc.path),
23357
- normalizedContent: normalizeText(doc.content),
23358
- };
23359
- }
23360
-
23361
- function buildSearchIndex(docs) {
23362
- return {
23363
- documents: Array.isArray(docs) ? docs.map(toSearchIndexDocument) : [],
23364
- };
23030
+ function relativePath(fromDir, target) {
23031
+ const rel = relative$1(fromDir, target);
23032
+ if (!rel || rel.length === 0) return ".";
23033
+ return rel;
23365
23034
  }
23366
-
23367
- function resolveLimit(limit) {
23368
- if (typeof limit !== 'number' || Number.isNaN(limit)) return DEFAULT_LIMIT;
23369
- return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
23035
+ function parseBranchName(branchRef, detached) {
23036
+ if (detached) return "(detached)";
23037
+ if (!branchRef) return "(unknown)";
23038
+ return branchRef.replace(/^refs\/heads\//, "");
23370
23039
  }
23371
-
23372
- function isDocumentMatch(doc, terms) {
23373
- return terms.every(
23374
- (term) =>
23375
- doc.normalizedTitle.includes(term) ||
23376
- doc.normalizedPath.includes(term) ||
23377
- doc.normalizedContent.includes(term)
23378
- );
23040
+ function parseWorktreeList(porcelain) {
23041
+ const entries = [];
23042
+ let current = null;
23043
+ const flush = () => {
23044
+ if (!current) return;
23045
+ entries.push(current);
23046
+ current = null;
23047
+ };
23048
+ for (const line of porcelain.split("\n")) {
23049
+ if (line.startsWith("worktree ")) {
23050
+ flush();
23051
+ current = {
23052
+ path: line.slice(9).trim(),
23053
+ branchRef: null,
23054
+ detached: false
23055
+ };
23056
+ continue;
23057
+ }
23058
+ if (!current) continue;
23059
+ if (line.startsWith("branch ")) {
23060
+ current.branchRef = line.slice(7).trim();
23061
+ continue;
23062
+ }
23063
+ if (line === "detached") {
23064
+ current.detached = true;
23065
+ continue;
23066
+ }
23067
+ }
23068
+ flush();
23069
+ return entries;
23379
23070
  }
23380
-
23381
- function scoreDocument(doc, terms) {
23382
- let score = 0;
23383
-
23384
- for (const term of terms) {
23385
- if (doc.normalizedTitle.includes(term)) score += 30;
23386
- if (doc.normalizedPath.includes(term)) score += 20;
23387
-
23388
- const contentIdx = doc.normalizedContent.indexOf(term);
23389
- if (contentIdx >= 0) {
23390
- score += 8;
23391
- if (contentIdx < 160) score += 4;
23392
- }
23393
- }
23394
-
23395
- return score;
23071
+ function parseRelatedChanges(paths) {
23072
+ const related = /* @__PURE__ */ new Set();
23073
+ for (const path$1 of paths) {
23074
+ const normalized = normalizeGitPath(path$1);
23075
+ const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
23076
+ if (activeMatch?.[1]) {
23077
+ related.add(activeMatch[1]);
23078
+ continue;
23079
+ }
23080
+ const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
23081
+ if (archiveMatch?.[1]) {
23082
+ const fullName = archiveMatch[1];
23083
+ related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
23084
+ }
23085
+ }
23086
+ return [...related].sort((a, b) => a.localeCompare(b));
23396
23087
  }
23397
-
23398
- function createSnippet(content, terms) {
23399
- const source = String(content || '').trim();
23400
- if (!source) return '';
23401
-
23402
- const normalizedSource = normalizeText(source);
23403
- let matchIndex = -1;
23404
-
23405
- for (const term of terms) {
23406
- const idx = normalizedSource.indexOf(term);
23407
- if (idx >= 0 && (matchIndex < 0 || idx < matchIndex)) {
23408
- matchIndex = idx;
23409
- }
23410
- }
23411
-
23412
- if (matchIndex < 0) {
23413
- return source.slice(0, SNIPPET_SIZE);
23414
- }
23415
-
23416
- const start = Math.max(0, matchIndex - Math.floor(SNIPPET_SIZE / 3));
23417
- const end = Math.min(source.length, start + SNIPPET_SIZE);
23418
-
23419
- const prefix = start > 0 ? '...' : '';
23420
- const suffix = end < source.length ? '...' : '';
23421
- return prefix + source.slice(start, end) + suffix;
23088
+ async function resolveDefaultBranch(projectDir, runGit) {
23089
+ const remoteHead = await runGit(projectDir, [
23090
+ "symbolic-ref",
23091
+ "--quiet",
23092
+ "--short",
23093
+ "refs/remotes/origin/HEAD"
23094
+ ]);
23095
+ const remoteRef = remoteHead.stdout.trim();
23096
+ if (remoteHead.ok && remoteRef) return remoteRef;
23097
+ const localHead = await runGit(projectDir, [
23098
+ "rev-parse",
23099
+ "--abbrev-ref",
23100
+ "HEAD"
23101
+ ]);
23102
+ const localRef = localHead.stdout.trim();
23103
+ if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
23104
+ return "main";
23422
23105
  }
23423
-
23424
- function searchIndex(index, query) {
23425
- const terms = splitTerms(query && query.query ? query.query : '');
23426
- if (terms.length === 0) return [];
23427
-
23428
- const hits = [];
23429
-
23430
- for (const doc of index.documents) {
23431
- if (!isDocumentMatch(doc, terms)) continue;
23432
-
23433
- hits.push({
23434
- documentId: doc.id,
23435
- kind: doc.kind,
23436
- title: doc.title,
23437
- href: doc.href,
23438
- path: doc.path,
23439
- score: scoreDocument(doc, terms),
23440
- snippet: createSnippet(doc.content, terms),
23441
- updatedAt: doc.updatedAt,
23442
- });
23443
- }
23444
-
23445
- hits.sort((a, b) => {
23446
- if (b.score !== a.score) return b.score - a.score;
23447
- return b.updatedAt - a.updatedAt;
23448
- });
23449
-
23450
- return hits.slice(0, resolveLimit(query ? query.limit : undefined));
23106
+ async function collectCommitEntries(options) {
23107
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
23108
+ const entries = [];
23109
+ const commits = await runGit(worktreePath, [
23110
+ "log",
23111
+ "--format=%H%x1f%s",
23112
+ `-n${maxCommitEntries}`,
23113
+ `${defaultBranch}..HEAD`
23114
+ ]);
23115
+ if (commits.ok) for (const line of commits.stdout.split("\n")) {
23116
+ if (!line.trim()) continue;
23117
+ const [hash, title = ""] = line.split("");
23118
+ if (!hash) continue;
23119
+ const diffResult = await runGit(worktreePath, [
23120
+ "show",
23121
+ "--numstat",
23122
+ "--format=",
23123
+ hash
23124
+ ]);
23125
+ const changedFiles = (await runGit(worktreePath, [
23126
+ "show",
23127
+ "--name-only",
23128
+ "--format=",
23129
+ hash
23130
+ ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23131
+ entries.push({
23132
+ type: "commit",
23133
+ hash,
23134
+ title: title.trim() || hash.slice(0, 7),
23135
+ relatedChanges: parseRelatedChanges(changedFiles),
23136
+ diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
23137
+ });
23138
+ }
23139
+ const trackedResult = await runGit(worktreePath, [
23140
+ "diff",
23141
+ "--numstat",
23142
+ "HEAD"
23143
+ ]);
23144
+ const trackedFilesResult = await runGit(worktreePath, [
23145
+ "diff",
23146
+ "--name-only",
23147
+ "HEAD"
23148
+ ]);
23149
+ const untrackedResult = await runGit(worktreePath, [
23150
+ "ls-files",
23151
+ "--others",
23152
+ "--exclude-standard"
23153
+ ]);
23154
+ const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23155
+ const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23156
+ const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
23157
+ const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
23158
+ entries.push({
23159
+ type: "uncommitted",
23160
+ title: "Uncommitted",
23161
+ relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
23162
+ diff: {
23163
+ files: allUncommittedFiles.size,
23164
+ insertions: trackedDiff.insertions,
23165
+ deletions: trackedDiff.deletions
23166
+ }
23167
+ });
23168
+ return entries;
23169
+ }
23170
+ async function collectWorktree(options) {
23171
+ const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
23172
+ const worktreePath = resolve$1(worktree.path);
23173
+ const resolvedProjectDir = resolve$1(projectDir);
23174
+ const aheadBehindResult = await runGit(worktreePath, [
23175
+ "rev-list",
23176
+ "--left-right",
23177
+ "--count",
23178
+ `${defaultBranch}...HEAD`
23179
+ ]);
23180
+ let ahead = 0;
23181
+ let behind = 0;
23182
+ if (aheadBehindResult.ok) {
23183
+ const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
23184
+ ahead = Number(aheadRaw) || 0;
23185
+ behind = Number(behindRaw) || 0;
23186
+ }
23187
+ const diffResult = await runGit(worktreePath, [
23188
+ "diff",
23189
+ "--shortstat",
23190
+ `${defaultBranch}...HEAD`
23191
+ ]);
23192
+ const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
23193
+ const entries = await collectCommitEntries({
23194
+ worktreePath,
23195
+ defaultBranch,
23196
+ maxCommitEntries,
23197
+ runGit
23198
+ });
23199
+ return {
23200
+ path: worktreePath,
23201
+ relativePath: relativePath(resolvedProjectDir, worktreePath),
23202
+ branchName: parseBranchName(worktree.branchRef, worktree.detached),
23203
+ detached: worktree.detached,
23204
+ isCurrent: resolvedProjectDir === worktreePath,
23205
+ ahead,
23206
+ behind,
23207
+ diff,
23208
+ entries
23209
+ };
23210
+ }
23211
+ async function removeDetachedDashboardGitWorktree(options) {
23212
+ const runGit = options.runGit ?? defaultRunGit;
23213
+ const resolvedProjectDir = resolve$1(options.projectDir);
23214
+ const resolvedTargetPath = resolve$1(options.targetPath);
23215
+ if (resolvedTargetPath === resolvedProjectDir) throw new Error("Cannot remove the current worktree.");
23216
+ const worktreeResult = await runGit(resolvedProjectDir, [
23217
+ "worktree",
23218
+ "list",
23219
+ "--porcelain"
23220
+ ]);
23221
+ if (!worktreeResult.ok) throw new Error("Failed to inspect git worktrees.");
23222
+ const matched = parseWorktreeList(worktreeResult.stdout).find((worktree) => resolve$1(worktree.path) === resolvedTargetPath);
23223
+ if (!matched) throw new Error("Worktree not found.");
23224
+ if (!matched.detached) throw new Error("Only detached worktrees can be removed from Dashboard.");
23225
+ if (!(await runGit(resolvedProjectDir, [
23226
+ "worktree",
23227
+ "remove",
23228
+ "--force",
23229
+ resolvedTargetPath
23230
+ ])).ok) throw new Error("Failed to remove detached worktree.");
23231
+ }
23232
+ async function buildDashboardGitSnapshot(options) {
23233
+ const runGit = options.runGit ?? defaultRunGit;
23234
+ const maxCommitEntries = options.maxCommitEntries ?? 8;
23235
+ const resolvedProjectDir = resolve$1(options.projectDir);
23236
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
23237
+ const worktreeResult = await runGit(resolvedProjectDir, [
23238
+ "worktree",
23239
+ "list",
23240
+ "--porcelain"
23241
+ ]);
23242
+ const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
23243
+ const baseWorktrees = parsed.length > 0 ? parsed : [{
23244
+ path: resolvedProjectDir,
23245
+ branchRef: null,
23246
+ detached: false
23247
+ }];
23248
+ const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
23249
+ projectDir: resolvedProjectDir,
23250
+ worktree,
23251
+ defaultBranch,
23252
+ runGit,
23253
+ maxCommitEntries
23254
+ })));
23255
+ worktrees.sort((a, b) => {
23256
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
23257
+ return a.branchName.localeCompare(b.branchName);
23258
+ });
23259
+ return {
23260
+ defaultBranch,
23261
+ worktrees
23262
+ };
23451
23263
  }
23452
23264
 
23453
- let index = buildSearchIndex([]);
23454
-
23455
- function handleMessage(payload) {
23456
- try {
23457
- if (!payload || typeof payload !== 'object') {
23458
- throw new Error('Invalid worker request payload');
23459
- }
23460
-
23461
- if (payload.type === 'init' || payload.type === 'replaceAll') {
23462
- index = buildSearchIndex(Array.isArray(payload.docs) ? payload.docs : []);
23463
- return { id: payload.id, type: 'ok' };
23464
- }
23465
-
23466
- if (payload.type === 'search') {
23467
- const hits = searchIndex(index, payload.query || { query: '' });
23468
- return { id: payload.id, type: 'results', hits };
23469
- }
23470
-
23471
- if (payload.type === 'dispose') {
23472
- index = buildSearchIndex([]);
23473
- return { id: payload.id, type: 'ok' };
23474
- }
23475
-
23476
- throw new Error('Unsupported worker request type');
23477
- } catch (error) {
23478
- const message = error instanceof Error ? error.message : String(error);
23479
- return { id: payload?.id ?? 'unknown', type: 'error', message };
23480
- }
23265
+ //#endregion
23266
+ //#region ../server/src/dashboard-time-trends.ts
23267
+ const MIN_TREND_POINT_LIMIT = 20;
23268
+ const MAX_TREND_POINT_LIMIT = 500;
23269
+ const DEFAULT_TREND_POINT_LIMIT = 100;
23270
+ const TARGET_TREND_BARS = 20;
23271
+ const DAY_MS = 1440 * 60 * 1e3;
23272
+ function clampPointLimit(pointLimit) {
23273
+ if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
23274
+ return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
23481
23275
  }
23482
- `;
23483
- function buildNodeWorkerSource() {
23484
- return `${sharedRuntimeSource}\nconst { parentPort } = require('node:worker_threads');\nif (!parentPort) { throw new Error('Missing parentPort'); }\nparentPort.on('message', (payload) => { parentPort.postMessage(handleMessage(payload)); });`;
23276
+ function createEmptyTrendSeries() {
23277
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
23278
+ }
23279
+ function normalizeEvents(events, pointLimit) {
23280
+ return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
23281
+ }
23282
+ function buildTimeWindow(options) {
23283
+ const { probeEvents, targetBars, rightEdgeTs } = options;
23284
+ if (probeEvents.length === 0) return null;
23285
+ const probeEnd = probeEvents[probeEvents.length - 1].ts;
23286
+ const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
23287
+ const probeStart = probeEvents[0].ts;
23288
+ const rangeMs = Math.max(1, end - probeStart);
23289
+ const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
23290
+ const windowStart = end - bucketMs * targetBars;
23291
+ return {
23292
+ windowStart,
23293
+ bucketMs,
23294
+ bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
23295
+ };
23296
+ }
23297
+ function bucketizeTrend(events, reducer, rightEdgeTs) {
23298
+ if (events.length === 0) return [];
23299
+ const timeWindow = buildTimeWindow({
23300
+ probeEvents: events,
23301
+ targetBars: TARGET_TREND_BARS,
23302
+ rightEdgeTs
23303
+ });
23304
+ if (!timeWindow) return [];
23305
+ const { windowStart, bucketMs, bucketEnds } = timeWindow;
23306
+ const sums = Array.from({ length: bucketEnds.length }, () => 0);
23307
+ const counts = Array.from({ length: bucketEnds.length }, () => 0);
23308
+ let baseline = 0;
23309
+ for (const event of events) {
23310
+ if (event.ts <= windowStart) {
23311
+ if (reducer === "sum-cumulative") baseline += event.value;
23312
+ continue;
23313
+ }
23314
+ const offset = event.ts - windowStart;
23315
+ const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
23316
+ sums[index] += event.value;
23317
+ counts[index] += 1;
23318
+ }
23319
+ let cumulative = baseline;
23320
+ let carry = baseline !== 0 ? baseline : events[0].value;
23321
+ return bucketEnds.map((ts, index) => {
23322
+ if (reducer === "sum") return {
23323
+ ts,
23324
+ value: sums[index]
23325
+ };
23326
+ if (reducer === "sum-cumulative") {
23327
+ cumulative += sums[index];
23328
+ return {
23329
+ ts,
23330
+ value: cumulative
23331
+ };
23332
+ }
23333
+ if (counts[index] > 0) carry = sums[index] / counts[index];
23334
+ return {
23335
+ ts,
23336
+ value: carry
23337
+ };
23338
+ });
23339
+ }
23340
+ function buildDashboardTimeTrends(options) {
23341
+ const pointLimit = clampPointLimit(options.pointLimit);
23342
+ const trends = createEmptyTrendSeries();
23343
+ for (const metric of DASHBOARD_METRIC_KEYS) {
23344
+ if (options.availability[metric].state !== "ok") continue;
23345
+ trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
23346
+ }
23347
+ return {
23348
+ trends,
23349
+ trendMeta: {
23350
+ pointLimit,
23351
+ lastUpdatedAt: options.timestamp
23352
+ }
23353
+ };
23485
23354
  }
23486
23355
 
23487
23356
  //#endregion
23488
- //#region ../server/src/cli-stream-observable.ts
23489
- /**
23490
- * 创建安全的 CLI 流式 observable
23491
- *
23492
- * 解决的问题:
23493
- * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
23494
- * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
23495
- * 3. 确保取消时正确清理资源
23496
- *
23497
- * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
23498
- */
23499
- function createCliStreamObservable(startStream) {
23500
- return observable((emit) => {
23501
- let cancel;
23502
- let completed = false;
23503
- /**
23504
- * 安全的事件处理器
23505
- * - 检查是否已完成,防止重复调用
23506
- * - 使用 try-catch 防止异常导致服务器崩溃
23507
- */
23508
- const safeEventHandler = (event) => {
23509
- if (completed) return;
23510
- try {
23511
- emit.next(event);
23512
- if (event.type === "exit") {
23513
- completed = true;
23514
- emit.complete();
23357
+ //#region ../server/src/dashboard-overview.ts
23358
+ const execFileAsync = promisify$1(execFile);
23359
+ const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
23360
+ const dashboardGitTaskStatusEmitter = new EventEmitter$1();
23361
+ dashboardGitTaskStatusEmitter.setMaxListeners(200);
23362
+ const dashboardGitTaskStatus = {
23363
+ running: false,
23364
+ inFlight: 0,
23365
+ lastStartedAt: null,
23366
+ lastFinishedAt: null,
23367
+ lastReason: null,
23368
+ lastError: null
23369
+ };
23370
+ function createEmptyTriColorTrends() {
23371
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
23372
+ }
23373
+ function resolveTrendTimestamp(primary, secondary) {
23374
+ if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
23375
+ if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
23376
+ return null;
23377
+ }
23378
+ function parseDatedIdTimestamp(id) {
23379
+ const match$1 = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
23380
+ if (!match$1) return null;
23381
+ const year = Number(match$1[1]);
23382
+ const month = Number(match$1[2]);
23383
+ const day = Number(match$1[3]);
23384
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
23385
+ if (month < 1 || month > 12) return null;
23386
+ if (day < 1 || day > 31) return null;
23387
+ const ts = Date.UTC(year, month - 1, day);
23388
+ return Number.isFinite(ts) ? ts : null;
23389
+ }
23390
+ async function readLatestCommitTimestamp(projectDir) {
23391
+ try {
23392
+ const { stdout } = await execFileAsync("git", [
23393
+ "log",
23394
+ "-1",
23395
+ "--format=%ct"
23396
+ ], {
23397
+ cwd: projectDir,
23398
+ maxBuffer: 1024 * 1024,
23399
+ encoding: "utf8"
23400
+ });
23401
+ const seconds = Number(stdout.trim());
23402
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
23403
+ } catch {
23404
+ return null;
23405
+ }
23406
+ }
23407
+ function emitDashboardGitTaskStatus() {
23408
+ dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
23409
+ }
23410
+ function beginDashboardGitTask(reason) {
23411
+ dashboardGitTaskStatus.inFlight += 1;
23412
+ dashboardGitTaskStatus.running = true;
23413
+ dashboardGitTaskStatus.lastStartedAt = Date.now();
23414
+ dashboardGitTaskStatus.lastReason = reason;
23415
+ dashboardGitTaskStatus.lastError = null;
23416
+ emitDashboardGitTaskStatus();
23417
+ }
23418
+ function endDashboardGitTask(error) {
23419
+ dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
23420
+ dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
23421
+ dashboardGitTaskStatus.lastFinishedAt = Date.now();
23422
+ if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
23423
+ emitDashboardGitTaskStatus();
23424
+ }
23425
+ function getDashboardGitTaskStatus() {
23426
+ return { ...dashboardGitTaskStatus };
23427
+ }
23428
+ function subscribeDashboardGitTaskStatus(listener) {
23429
+ dashboardGitTaskStatusEmitter.on("change", listener);
23430
+ return () => {
23431
+ dashboardGitTaskStatusEmitter.off("change", listener);
23432
+ };
23433
+ }
23434
+ async function resolveGitMetadataDir(projectDir) {
23435
+ try {
23436
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
23437
+ cwd: projectDir,
23438
+ maxBuffer: 1024 * 1024,
23439
+ encoding: "utf8"
23440
+ });
23441
+ const gitDirRaw = stdout.trim();
23442
+ if (!gitDirRaw) return null;
23443
+ const gitDirPath = resolve$1(projectDir, gitDirRaw);
23444
+ if (!(await stat(gitDirPath)).isDirectory()) return null;
23445
+ return gitDirPath;
23446
+ } catch {
23447
+ return null;
23448
+ }
23449
+ }
23450
+ function getDashboardGitRefreshStampPath(gitMetadataDir) {
23451
+ return join$1(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
23452
+ }
23453
+ async function touchDashboardGitRefreshStamp(projectDir, reason) {
23454
+ const gitMetadataDir = await resolveGitMetadataDir(projectDir);
23455
+ if (!gitMetadataDir) return { skipped: true };
23456
+ const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
23457
+ await mkdir$1(dirname$1(stampPath), { recursive: true });
23458
+ await writeFile$1(stampPath, `${Date.now()} ${reason}\n`, "utf8");
23459
+ return { skipped: false };
23460
+ }
23461
+ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
23462
+ const now = Date.now();
23463
+ const [specMetas, changeMetas, archiveMetas] = await Promise.all([
23464
+ ctx.adapter.listSpecsWithMeta(),
23465
+ ctx.adapter.listChangesWithMeta(),
23466
+ ctx.adapter.listArchivedChangesWithMeta()
23467
+ ]);
23468
+ const activeChanges = changeMetas.map((changeMeta) => ({
23469
+ id: changeMeta.id,
23470
+ name: changeMeta.name ?? changeMeta.id,
23471
+ progress: changeMeta.progress,
23472
+ updatedAt: changeMeta.updatedAt
23473
+ })).sort((a, b) => b.updatedAt - a.updatedAt);
23474
+ const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
23475
+ const change = await ctx.adapter.readArchivedChange(meta.id);
23476
+ if (!change) return null;
23477
+ return {
23478
+ id: meta.id,
23479
+ createdAt: meta.createdAt,
23480
+ updatedAt: meta.updatedAt,
23481
+ tasksCompleted: change.tasks.filter((task) => task.completed).length
23482
+ };
23483
+ }))).filter((item) => item !== null);
23484
+ const specifications = (await Promise.all(specMetas.map(async (meta) => {
23485
+ const spec = await ctx.adapter.readSpec(meta.id);
23486
+ if (!spec) return null;
23487
+ return {
23488
+ id: meta.id,
23489
+ name: meta.name,
23490
+ requirements: spec.requirements.length,
23491
+ updatedAt: meta.updatedAt
23492
+ };
23493
+ }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
23494
+ const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
23495
+ const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
23496
+ const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
23497
+ const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
23498
+ const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
23499
+ const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
23500
+ const specificationTrendEvents = specMetas.flatMap((spec) => {
23501
+ const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
23502
+ return ts === null ? [] : [{
23503
+ ts,
23504
+ value: 1
23505
+ }];
23506
+ });
23507
+ const completedTrendEvents = archivedChanges.flatMap((archive) => {
23508
+ const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
23509
+ return ts === null ? [] : [{
23510
+ ts,
23511
+ value: archive.tasksCompleted
23512
+ }];
23513
+ });
23514
+ const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
23515
+ const requirementTrendEvents = specifications.flatMap((spec) => {
23516
+ const meta = specMetaById.get(spec.id);
23517
+ const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
23518
+ return ts === null ? [] : [{
23519
+ ts,
23520
+ value: spec.requirements
23521
+ }];
23522
+ });
23523
+ const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
23524
+ const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
23525
+ const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
23526
+ const config = await ctx.configManager.readConfig();
23527
+ beginDashboardGitTask(reason);
23528
+ let latestCommitTs = null;
23529
+ let git;
23530
+ try {
23531
+ const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
23532
+ defaultBranch: "main",
23533
+ worktrees: []
23534
+ }));
23535
+ latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
23536
+ git = await gitSnapshotPromise;
23537
+ } catch (error) {
23538
+ endDashboardGitTask(error);
23539
+ throw error;
23540
+ }
23541
+ endDashboardGitTask(null);
23542
+ const cardAvailability = {
23543
+ specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
23544
+ state: "invalid",
23545
+ reason: "objective-history-unavailable"
23546
+ },
23547
+ requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
23548
+ state: "invalid",
23549
+ reason: "objective-history-unavailable"
23550
+ },
23551
+ activeChanges: {
23552
+ state: "invalid",
23553
+ reason: "objective-history-unavailable"
23554
+ },
23555
+ inProgressChanges: {
23556
+ state: "invalid",
23557
+ reason: "objective-history-unavailable"
23558
+ },
23559
+ completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
23560
+ state: "invalid",
23561
+ reason: "objective-history-unavailable"
23562
+ },
23563
+ taskCompletionPercent: {
23564
+ state: "invalid",
23565
+ reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
23566
+ }
23567
+ };
23568
+ const trendKinds = {
23569
+ specifications: "monotonic",
23570
+ requirements: "monotonic",
23571
+ activeChanges: "bidirectional",
23572
+ inProgressChanges: "bidirectional",
23573
+ completedChanges: "monotonic",
23574
+ taskCompletionPercent: "bidirectional"
23575
+ };
23576
+ const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
23577
+ pointLimit: config.dashboard.trendPointLimit,
23578
+ timestamp: now,
23579
+ rightEdgeTs: latestCommitTs,
23580
+ availability: cardAvailability,
23581
+ events: {
23582
+ specifications: specificationTrendEvents,
23583
+ requirements: requirementTrendEvents,
23584
+ activeChanges: [],
23585
+ inProgressChanges: [],
23586
+ completedChanges: completedTrendEvents,
23587
+ taskCompletionPercent: []
23588
+ },
23589
+ reducers: {
23590
+ specifications: "sum",
23591
+ requirements: "sum",
23592
+ completedChanges: "sum"
23593
+ }
23594
+ });
23595
+ return {
23596
+ summary: {
23597
+ specifications: specifications.length,
23598
+ requirements,
23599
+ activeChanges: activeChanges.length,
23600
+ inProgressChanges,
23601
+ completedChanges: archiveMetas.length,
23602
+ archivedTasksCompleted,
23603
+ tasksTotal,
23604
+ tasksCompleted,
23605
+ taskCompletionPercent
23606
+ },
23607
+ trends: baselineTrends,
23608
+ triColorTrends: createEmptyTriColorTrends(),
23609
+ trendKinds,
23610
+ cardAvailability,
23611
+ trendMeta,
23612
+ specifications,
23613
+ activeChanges,
23614
+ git
23615
+ };
23616
+ }
23617
+
23618
+ //#endregion
23619
+ //#region ../server/src/pty-manager.ts
23620
+ const DEFAULT_SCROLLBACK = 1e3;
23621
+ const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
23622
+ function detectPtyPlatform() {
23623
+ if (process.platform === "win32") return "windows";
23624
+ if (process.platform === "darwin") return "macos";
23625
+ return "common";
23626
+ }
23627
+ function resolveDefaultShell(platform, env) {
23628
+ if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
23629
+ return env.SHELL?.trim() || "/bin/sh";
23630
+ }
23631
+ function resolvePtyCommand(opts) {
23632
+ const command = opts.command?.trim();
23633
+ if (command) return {
23634
+ command,
23635
+ args: opts.args ?? []
23636
+ };
23637
+ return {
23638
+ command: resolveDefaultShell(opts.platform, opts.env),
23639
+ args: []
23640
+ };
23641
+ }
23642
+ var PtySession = class extends EventEmitter {
23643
+ id;
23644
+ command;
23645
+ args;
23646
+ platform;
23647
+ closeTip;
23648
+ closeCallbackUrl;
23649
+ createdAt;
23650
+ process;
23651
+ titleInterval = null;
23652
+ lastTitle = "";
23653
+ buffer = [];
23654
+ bufferByteLength = 0;
23655
+ maxBufferLines;
23656
+ maxBufferBytes;
23657
+ isExited = false;
23658
+ exitCode = null;
23659
+ constructor(id, opts) {
23660
+ super();
23661
+ this.id = id;
23662
+ this.createdAt = Date.now();
23663
+ const resolvedCommand = resolvePtyCommand({
23664
+ platform: opts.platform,
23665
+ command: opts.command,
23666
+ args: opts.args,
23667
+ env: process.env
23668
+ });
23669
+ this.command = resolvedCommand.command;
23670
+ this.args = resolvedCommand.args;
23671
+ this.platform = opts.platform;
23672
+ this.closeTip = opts.closeTip;
23673
+ this.closeCallbackUrl = opts.closeCallbackUrl;
23674
+ this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
23675
+ this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
23676
+ this.process = pty.spawn(this.command, this.args, {
23677
+ name: "xterm-256color",
23678
+ cols: opts.cols ?? 80,
23679
+ rows: opts.rows ?? 24,
23680
+ cwd: opts.cwd,
23681
+ env: {
23682
+ ...process.env,
23683
+ TERM: "xterm-256color"
23684
+ }
23685
+ });
23686
+ this.process.onData((data) => {
23687
+ this.appendBuffer(data);
23688
+ this.emit("data", data);
23689
+ });
23690
+ this.process.onExit(({ exitCode }) => {
23691
+ if (this.titleInterval) {
23692
+ clearInterval(this.titleInterval);
23693
+ this.titleInterval = null;
23694
+ }
23695
+ this.isExited = true;
23696
+ this.exitCode = exitCode;
23697
+ this.emit("exit", exitCode);
23698
+ });
23699
+ this.titleInterval = setInterval(() => {
23700
+ try {
23701
+ const title = this.process.process;
23702
+ if (title && title !== this.lastTitle) {
23703
+ this.lastTitle = title;
23704
+ this.emit("title", title);
23705
+ }
23706
+ } catch {}
23707
+ }, 1e3);
23708
+ }
23709
+ get title() {
23710
+ return this.lastTitle;
23711
+ }
23712
+ appendBuffer(data) {
23713
+ let chunk = data;
23714
+ if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
23715
+ this.buffer.push(chunk);
23716
+ this.bufferByteLength += chunk.length;
23717
+ while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
23718
+ const removed = this.buffer.shift();
23719
+ this.bufferByteLength -= removed.length;
23720
+ }
23721
+ while (this.buffer.length > this.maxBufferLines) {
23722
+ const removed = this.buffer.shift();
23723
+ this.bufferByteLength -= removed.length;
23724
+ }
23725
+ }
23726
+ getBuffer() {
23727
+ return this.buffer.join("");
23728
+ }
23729
+ write(data) {
23730
+ if (!this.isExited) this.process.write(data);
23731
+ }
23732
+ resize(cols, rows) {
23733
+ if (!this.isExited) this.process.resize(cols, rows);
23734
+ }
23735
+ close() {
23736
+ if (this.titleInterval) {
23737
+ clearInterval(this.titleInterval);
23738
+ this.titleInterval = null;
23739
+ }
23740
+ try {
23741
+ this.process.kill();
23742
+ } catch {}
23743
+ this.removeAllListeners();
23744
+ }
23745
+ toInfo() {
23746
+ return {
23747
+ id: this.id,
23748
+ title: this.lastTitle,
23749
+ command: this.command,
23750
+ args: this.args,
23751
+ platform: this.platform,
23752
+ isExited: this.isExited,
23753
+ exitCode: this.exitCode,
23754
+ closeTip: this.closeTip,
23755
+ closeCallbackUrl: this.closeCallbackUrl,
23756
+ createdAt: this.createdAt
23757
+ };
23758
+ }
23759
+ };
23760
+ var PtyManager = class {
23761
+ sessions = /* @__PURE__ */ new Map();
23762
+ idCounter = 0;
23763
+ platform;
23764
+ constructor(defaultCwd) {
23765
+ this.defaultCwd = defaultCwd;
23766
+ this.platform = detectPtyPlatform();
23767
+ }
23768
+ create(opts) {
23769
+ const id = `pty-${++this.idCounter}`;
23770
+ const session = new PtySession(id, {
23771
+ cols: opts.cols,
23772
+ rows: opts.rows,
23773
+ command: opts.command,
23774
+ args: opts.args,
23775
+ closeTip: opts.closeTip,
23776
+ closeCallbackUrl: opts.closeCallbackUrl,
23777
+ cwd: this.defaultCwd,
23778
+ scrollback: opts.scrollback,
23779
+ maxBufferBytes: opts.maxBufferBytes,
23780
+ platform: this.platform
23781
+ });
23782
+ this.sessions.set(id, session);
23783
+ return session;
23784
+ }
23785
+ get(id) {
23786
+ return this.sessions.get(id);
23787
+ }
23788
+ list() {
23789
+ const result = [];
23790
+ for (const session of this.sessions.values()) result.push(session.toInfo());
23791
+ return result;
23792
+ }
23793
+ write(id, data) {
23794
+ this.sessions.get(id)?.write(data);
23795
+ }
23796
+ resize(id, cols, rows) {
23797
+ this.sessions.get(id)?.resize(cols, rows);
23798
+ }
23799
+ close(id) {
23800
+ const session = this.sessions.get(id);
23801
+ if (session) {
23802
+ session.close();
23803
+ this.sessions.delete(id);
23804
+ }
23805
+ }
23806
+ closeAll() {
23807
+ for (const session of this.sessions.values()) session.close();
23808
+ this.sessions.clear();
23809
+ }
23810
+ };
23811
+
23812
+ //#endregion
23813
+ //#region ../server/src/pty-websocket.ts
23814
+ function createPtyWebSocketHandler(ptyManager) {
23815
+ return (ws) => {
23816
+ const cleanups = /* @__PURE__ */ new Map();
23817
+ const send = (msg) => {
23818
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
23819
+ };
23820
+ const sendError = (code, message, opts) => {
23821
+ send({
23822
+ type: "error",
23823
+ code,
23824
+ message,
23825
+ sessionId: opts?.sessionId
23826
+ });
23827
+ };
23828
+ const attachToSession = (session, opts) => {
23829
+ const sessionId = session.id;
23830
+ cleanups.get(sessionId)?.();
23831
+ if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
23832
+ const onData = (data) => {
23833
+ send({
23834
+ type: "output",
23835
+ sessionId,
23836
+ data
23837
+ });
23838
+ };
23839
+ const onExit = (exitCode) => {
23840
+ send({
23841
+ type: "exit",
23842
+ sessionId,
23843
+ exitCode
23844
+ });
23845
+ };
23846
+ const onTitle = (title) => {
23847
+ send({
23848
+ type: "title",
23849
+ sessionId,
23850
+ title
23851
+ });
23852
+ };
23853
+ session.on("data", onData);
23854
+ session.on("exit", onExit);
23855
+ session.on("title", onTitle);
23856
+ cleanups.set(sessionId, () => {
23857
+ session.removeListener("data", onData);
23858
+ session.removeListener("exit", onExit);
23859
+ session.removeListener("title", onTitle);
23860
+ cleanups.delete(sessionId);
23861
+ });
23862
+ };
23863
+ ws.on("message", (raw$1) => {
23864
+ let parsed;
23865
+ try {
23866
+ parsed = JSON.parse(String(raw$1));
23867
+ } catch {
23868
+ sendError("INVALID_JSON", "Invalid JSON payload");
23869
+ return;
23870
+ }
23871
+ const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
23872
+ if (!parsedMessage.success) {
23873
+ const firstIssue = parsedMessage.error.issues[0]?.message;
23874
+ sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
23875
+ return;
23876
+ }
23877
+ const msg = parsedMessage.data;
23878
+ switch (msg.type) {
23879
+ case "create":
23880
+ try {
23881
+ const createMessage = msg;
23882
+ const session = ptyManager.create({
23883
+ cols: msg.cols,
23884
+ rows: msg.rows,
23885
+ command: msg.command,
23886
+ args: msg.args,
23887
+ closeTip: createMessage.closeTip,
23888
+ closeCallbackUrl: createMessage.closeCallbackUrl
23889
+ });
23890
+ send({
23891
+ type: "created",
23892
+ requestId: msg.requestId,
23893
+ sessionId: session.id,
23894
+ platform: session.platform
23895
+ });
23896
+ attachToSession(session);
23897
+ } catch (err) {
23898
+ sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
23899
+ }
23900
+ break;
23901
+ case "attach": {
23902
+ const session = ptyManager.get(msg.sessionId);
23903
+ if (!session) {
23904
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23905
+ send({
23906
+ type: "exit",
23907
+ sessionId: msg.sessionId,
23908
+ exitCode: -1
23909
+ });
23910
+ break;
23911
+ }
23912
+ attachToSession(session, {
23913
+ cols: msg.cols,
23914
+ rows: msg.rows
23915
+ });
23916
+ const buffer = session.getBuffer();
23917
+ if (buffer) send({
23918
+ type: "buffer",
23919
+ sessionId: session.id,
23920
+ data: buffer
23921
+ });
23922
+ if (session.title) send({
23923
+ type: "title",
23924
+ sessionId: session.id,
23925
+ title: session.title
23926
+ });
23927
+ if (session.isExited) send({
23928
+ type: "exit",
23929
+ sessionId: session.id,
23930
+ exitCode: session.exitCode ?? -1
23931
+ });
23932
+ break;
23933
+ }
23934
+ case "list":
23935
+ send({
23936
+ type: "list",
23937
+ sessions: ptyManager.list().map((s) => ({
23938
+ id: s.id,
23939
+ title: s.title,
23940
+ command: s.command,
23941
+ args: s.args,
23942
+ platform: s.platform,
23943
+ isExited: s.isExited,
23944
+ exitCode: s.exitCode,
23945
+ closeTip: s.closeTip,
23946
+ closeCallbackUrl: s.closeCallbackUrl
23947
+ }))
23948
+ });
23949
+ break;
23950
+ case "input": {
23951
+ const session = ptyManager.get(msg.sessionId);
23952
+ if (!session) {
23953
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23954
+ break;
23955
+ }
23956
+ session.write(msg.data);
23957
+ break;
23958
+ }
23959
+ case "resize": {
23960
+ const session = ptyManager.get(msg.sessionId);
23961
+ if (!session) {
23962
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23963
+ break;
23964
+ }
23965
+ session.resize(msg.cols, msg.rows);
23966
+ break;
23515
23967
  }
23516
- } catch (err) {
23517
- console.error("[CLI Stream] Error emitting event:", err);
23518
- if (!completed) {
23519
- completed = true;
23520
- try {
23521
- emit.error(err instanceof Error ? err : new Error(String(err)));
23522
- } catch {}
23968
+ case "close": {
23969
+ const session = ptyManager.get(msg.sessionId);
23970
+ if (!session) {
23971
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
23972
+ break;
23973
+ }
23974
+ cleanups.get(msg.sessionId)?.();
23975
+ ptyManager.close(session.id);
23976
+ break;
23523
23977
  }
23524
23978
  }
23525
- };
23526
- startStream(safeEventHandler).then((cancelFn) => {
23527
- cancel = cancelFn;
23528
- }).catch((err) => {
23529
- console.error("[CLI Stream] Error starting stream:", err);
23530
- if (!completed) {
23531
- completed = true;
23532
- try {
23533
- emit.error(err instanceof Error ? err : new Error(String(err)));
23534
- } catch {}
23535
- }
23536
23979
  });
23537
- return () => {
23538
- completed = true;
23539
- cancel?.();
23540
- };
23541
- });
23542
- }
23543
-
23544
- //#endregion
23545
- //#region ../server/src/dashboard-git-snapshot.ts
23546
- const execFileAsync$1 = promisify$1(execFile);
23547
- const EMPTY_DIFF = {
23548
- files: 0,
23549
- insertions: 0,
23550
- deletions: 0
23551
- };
23552
- async function defaultRunGit(cwd, args) {
23553
- try {
23554
- const { stdout } = await execFileAsync$1("git", args, {
23555
- cwd,
23556
- encoding: "utf8",
23557
- maxBuffer: 8 * 1024 * 1024
23980
+ ws.on("close", () => {
23981
+ for (const cleanup of cleanups.values()) cleanup();
23982
+ cleanups.clear();
23558
23983
  });
23559
- return {
23560
- ok: true,
23561
- stdout
23562
- };
23563
- } catch {
23564
- return {
23565
- ok: false,
23566
- stdout: ""
23567
- };
23568
- }
23569
- }
23570
- function parseShortStat(output) {
23571
- const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
23572
- const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
23573
- const deletions = Number(/(\d+)\s+deletions?\(-\)/.exec(output)?.[1] ?? 0);
23574
- return {
23575
- files: Number.isFinite(files) ? files : 0,
23576
- insertions: Number.isFinite(insertions) ? insertions : 0,
23577
- deletions: Number.isFinite(deletions) ? deletions : 0
23578
- };
23579
- }
23580
- function parseNumStat(output) {
23581
- let files = 0;
23582
- let insertions = 0;
23583
- let deletions = 0;
23584
- for (const line of output.split("\n")) {
23585
- const trimmed = line.trim();
23586
- if (!trimmed) continue;
23587
- const [addRaw, deleteRaw] = trimmed.split(" ");
23588
- if (!addRaw || !deleteRaw) continue;
23589
- files += 1;
23590
- if (addRaw !== "-") insertions += Number(addRaw) || 0;
23591
- if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
23592
- }
23593
- return {
23594
- files,
23595
- insertions,
23596
- deletions
23597
23984
  };
23598
23985
  }
23599
- function normalizeGitPath(path$1) {
23600
- return path$1.replace(/\\/g, "/").replace(/^\.\//, "");
23986
+
23987
+ //#endregion
23988
+ //#region ../search/src/protocol.ts
23989
+ const SearchDocumentKindSchema = enumType([
23990
+ "spec",
23991
+ "change",
23992
+ "archive"
23993
+ ]);
23994
+ const SearchDocumentSchema = objectType({
23995
+ id: stringType(),
23996
+ kind: SearchDocumentKindSchema,
23997
+ title: stringType(),
23998
+ href: stringType(),
23999
+ path: stringType(),
24000
+ content: stringType(),
24001
+ updatedAt: numberType()
24002
+ });
24003
+ const SearchQuerySchema = objectType({
24004
+ query: stringType(),
24005
+ limit: numberType().int().positive().optional()
24006
+ });
24007
+ const SearchHitSchema = objectType({
24008
+ documentId: stringType(),
24009
+ kind: SearchDocumentKindSchema,
24010
+ title: stringType(),
24011
+ href: stringType(),
24012
+ path: stringType(),
24013
+ score: numberType(),
24014
+ snippet: stringType(),
24015
+ updatedAt: numberType()
24016
+ });
24017
+ const SearchWorkerRequestSchema = discriminatedUnionType("type", [
24018
+ objectType({
24019
+ id: stringType(),
24020
+ type: literalType("init"),
24021
+ docs: arrayType(SearchDocumentSchema)
24022
+ }),
24023
+ objectType({
24024
+ id: stringType(),
24025
+ type: literalType("replaceAll"),
24026
+ docs: arrayType(SearchDocumentSchema)
24027
+ }),
24028
+ objectType({
24029
+ id: stringType(),
24030
+ type: literalType("search"),
24031
+ query: SearchQuerySchema
24032
+ }),
24033
+ objectType({
24034
+ id: stringType(),
24035
+ type: literalType("dispose")
24036
+ })
24037
+ ]);
24038
+ const SearchWorkerResponseSchema = discriminatedUnionType("type", [
24039
+ objectType({
24040
+ id: stringType(),
24041
+ type: literalType("ok")
24042
+ }),
24043
+ objectType({
24044
+ id: stringType(),
24045
+ type: literalType("results"),
24046
+ hits: arrayType(SearchHitSchema)
24047
+ }),
24048
+ objectType({
24049
+ id: stringType(),
24050
+ type: literalType("error"),
24051
+ message: stringType()
24052
+ })
24053
+ ]);
24054
+
24055
+ //#endregion
24056
+ //#region ../search/src/worker-source.ts
24057
+ const sharedRuntimeSource = String.raw`
24058
+ const DEFAULT_LIMIT = 50;
24059
+ const MAX_LIMIT = 200;
24060
+ const SNIPPET_SIZE = 180;
24061
+
24062
+ function normalizeText(input) {
24063
+ return String(input || '')
24064
+ .toLowerCase()
24065
+ .replace(/\s+/g, ' ')
24066
+ .trim();
23601
24067
  }
23602
- function relativePath(fromDir, target) {
23603
- const rel = relative$1(fromDir, target);
23604
- if (!rel || rel.length === 0) return ".";
23605
- return rel;
24068
+
24069
+ function splitTerms(query) {
24070
+ return normalizeText(query)
24071
+ .split(' ')
24072
+ .map((term) => term.trim())
24073
+ .filter((term) => term.length > 0);
23606
24074
  }
23607
- function parseBranchName(branchRef, detached) {
23608
- if (detached) return "(detached)";
23609
- if (!branchRef) return "(unknown)";
23610
- return branchRef.replace(/^refs\/heads\//, "");
24075
+
24076
+ function toSearchIndexDocument(doc) {
24077
+ return {
24078
+ id: doc.id,
24079
+ kind: doc.kind,
24080
+ title: doc.title,
24081
+ href: doc.href,
24082
+ path: doc.path,
24083
+ content: doc.content,
24084
+ updatedAt: doc.updatedAt,
24085
+ normalizedTitle: normalizeText(doc.title),
24086
+ normalizedPath: normalizeText(doc.path),
24087
+ normalizedContent: normalizeText(doc.content),
24088
+ };
23611
24089
  }
23612
- function parseWorktreeList(porcelain) {
23613
- const entries = [];
23614
- let current = null;
23615
- const flush = () => {
23616
- if (!current) return;
23617
- entries.push(current);
23618
- current = null;
23619
- };
23620
- for (const line of porcelain.split("\n")) {
23621
- if (line.startsWith("worktree ")) {
23622
- flush();
23623
- current = {
23624
- path: line.slice(9).trim(),
23625
- branchRef: null,
23626
- detached: false
23627
- };
23628
- continue;
23629
- }
23630
- if (!current) continue;
23631
- if (line.startsWith("branch ")) {
23632
- current.branchRef = line.slice(7).trim();
23633
- continue;
23634
- }
23635
- if (line === "detached") {
23636
- current.detached = true;
23637
- continue;
23638
- }
23639
- }
23640
- flush();
23641
- return entries;
24090
+
24091
+ function buildSearchIndex(docs) {
24092
+ return {
24093
+ documents: Array.isArray(docs) ? docs.map(toSearchIndexDocument) : [],
24094
+ };
23642
24095
  }
23643
- function parseRelatedChanges(paths) {
23644
- const related = /* @__PURE__ */ new Set();
23645
- for (const path$1 of paths) {
23646
- const normalized = normalizeGitPath(path$1);
23647
- const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
23648
- if (activeMatch?.[1]) {
23649
- related.add(activeMatch[1]);
23650
- continue;
23651
- }
23652
- const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
23653
- if (archiveMatch?.[1]) {
23654
- const fullName = archiveMatch[1];
23655
- related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
23656
- }
23657
- }
23658
- return [...related].sort((a, b) => a.localeCompare(b));
24096
+
24097
+ function resolveLimit(limit) {
24098
+ if (typeof limit !== 'number' || Number.isNaN(limit)) return DEFAULT_LIMIT;
24099
+ return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
23659
24100
  }
23660
- async function resolveDefaultBranch(projectDir, runGit) {
23661
- const remoteHead = await runGit(projectDir, [
23662
- "symbolic-ref",
23663
- "--quiet",
23664
- "--short",
23665
- "refs/remotes/origin/HEAD"
23666
- ]);
23667
- const remoteRef = remoteHead.stdout.trim();
23668
- if (remoteHead.ok && remoteRef) return remoteRef;
23669
- const localHead = await runGit(projectDir, [
23670
- "rev-parse",
23671
- "--abbrev-ref",
23672
- "HEAD"
23673
- ]);
23674
- const localRef = localHead.stdout.trim();
23675
- if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
23676
- return "main";
24101
+
24102
+ function isDocumentMatch(doc, terms) {
24103
+ return terms.every(
24104
+ (term) =>
24105
+ doc.normalizedTitle.includes(term) ||
24106
+ doc.normalizedPath.includes(term) ||
24107
+ doc.normalizedContent.includes(term)
24108
+ );
23677
24109
  }
23678
- async function collectCommitEntries(options) {
23679
- const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
23680
- const entries = [];
23681
- const commits = await runGit(worktreePath, [
23682
- "log",
23683
- "--format=%H%x1f%s",
23684
- `-n${maxCommitEntries}`,
23685
- `${defaultBranch}..HEAD`
23686
- ]);
23687
- if (commits.ok) for (const line of commits.stdout.split("\n")) {
23688
- if (!line.trim()) continue;
23689
- const [hash, title = ""] = line.split("");
23690
- if (!hash) continue;
23691
- const diffResult = await runGit(worktreePath, [
23692
- "show",
23693
- "--numstat",
23694
- "--format=",
23695
- hash
23696
- ]);
23697
- const changedFiles = (await runGit(worktreePath, [
23698
- "show",
23699
- "--name-only",
23700
- "--format=",
23701
- hash
23702
- ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23703
- entries.push({
23704
- type: "commit",
23705
- hash,
23706
- title: title.trim() || hash.slice(0, 7),
23707
- relatedChanges: parseRelatedChanges(changedFiles),
23708
- diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
23709
- });
23710
- }
23711
- const trackedResult = await runGit(worktreePath, [
23712
- "diff",
23713
- "--numstat",
23714
- "HEAD"
23715
- ]);
23716
- const trackedFilesResult = await runGit(worktreePath, [
23717
- "diff",
23718
- "--name-only",
23719
- "HEAD"
23720
- ]);
23721
- const untrackedResult = await runGit(worktreePath, [
23722
- "ls-files",
23723
- "--others",
23724
- "--exclude-standard"
23725
- ]);
23726
- const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23727
- const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
23728
- const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
23729
- const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
23730
- entries.push({
23731
- type: "uncommitted",
23732
- title: "Uncommitted",
23733
- relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
23734
- diff: {
23735
- files: allUncommittedFiles.size,
23736
- insertions: trackedDiff.insertions,
23737
- deletions: trackedDiff.deletions
23738
- }
23739
- });
23740
- return entries;
24110
+
24111
+ function scoreDocument(doc, terms) {
24112
+ let score = 0;
24113
+
24114
+ for (const term of terms) {
24115
+ if (doc.normalizedTitle.includes(term)) score += 30;
24116
+ if (doc.normalizedPath.includes(term)) score += 20;
24117
+
24118
+ const contentIdx = doc.normalizedContent.indexOf(term);
24119
+ if (contentIdx >= 0) {
24120
+ score += 8;
24121
+ if (contentIdx < 160) score += 4;
24122
+ }
24123
+ }
24124
+
24125
+ return score;
23741
24126
  }
23742
- async function collectWorktree(options) {
23743
- const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
23744
- const worktreePath = resolve$1(worktree.path);
23745
- const resolvedProjectDir = resolve$1(projectDir);
23746
- const aheadBehindResult = await runGit(worktreePath, [
23747
- "rev-list",
23748
- "--left-right",
23749
- "--count",
23750
- `${defaultBranch}...HEAD`
23751
- ]);
23752
- let ahead = 0;
23753
- let behind = 0;
23754
- if (aheadBehindResult.ok) {
23755
- const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
23756
- ahead = Number(aheadRaw) || 0;
23757
- behind = Number(behindRaw) || 0;
23758
- }
23759
- const diffResult = await runGit(worktreePath, [
23760
- "diff",
23761
- "--shortstat",
23762
- `${defaultBranch}...HEAD`
23763
- ]);
23764
- const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
23765
- const entries = await collectCommitEntries({
23766
- worktreePath,
23767
- defaultBranch,
23768
- maxCommitEntries,
23769
- runGit
23770
- });
23771
- return {
23772
- path: worktreePath,
23773
- relativePath: relativePath(resolvedProjectDir, worktreePath),
23774
- branchName: parseBranchName(worktree.branchRef, worktree.detached),
23775
- isCurrent: resolvedProjectDir === worktreePath,
23776
- ahead,
23777
- behind,
23778
- diff,
23779
- entries
23780
- };
24127
+
24128
+ function createSnippet(content, terms) {
24129
+ const source = String(content || '').trim();
24130
+ if (!source) return '';
24131
+
24132
+ const normalizedSource = normalizeText(source);
24133
+ let matchIndex = -1;
24134
+
24135
+ for (const term of terms) {
24136
+ const idx = normalizedSource.indexOf(term);
24137
+ if (idx >= 0 && (matchIndex < 0 || idx < matchIndex)) {
24138
+ matchIndex = idx;
24139
+ }
24140
+ }
24141
+
24142
+ if (matchIndex < 0) {
24143
+ return source.slice(0, SNIPPET_SIZE);
24144
+ }
24145
+
24146
+ const start = Math.max(0, matchIndex - Math.floor(SNIPPET_SIZE / 3));
24147
+ const end = Math.min(source.length, start + SNIPPET_SIZE);
24148
+
24149
+ const prefix = start > 0 ? '...' : '';
24150
+ const suffix = end < source.length ? '...' : '';
24151
+ return prefix + source.slice(start, end) + suffix;
23781
24152
  }
23782
- async function buildDashboardGitSnapshot(options) {
23783
- const runGit = options.runGit ?? defaultRunGit;
23784
- const maxCommitEntries = options.maxCommitEntries ?? 8;
23785
- const resolvedProjectDir = resolve$1(options.projectDir);
23786
- const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
23787
- const worktreeResult = await runGit(resolvedProjectDir, [
23788
- "worktree",
23789
- "list",
23790
- "--porcelain"
23791
- ]);
23792
- const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
23793
- const baseWorktrees = parsed.length > 0 ? parsed : [{
23794
- path: resolvedProjectDir,
23795
- branchRef: null,
23796
- detached: false
23797
- }];
23798
- const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
23799
- projectDir: resolvedProjectDir,
23800
- worktree,
23801
- defaultBranch,
23802
- runGit,
23803
- maxCommitEntries
23804
- })));
23805
- worktrees.sort((a, b) => {
23806
- if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
23807
- return a.branchName.localeCompare(b.branchName);
23808
- });
23809
- return {
23810
- defaultBranch,
23811
- worktrees
23812
- };
24153
+
24154
+ function searchIndex(index, query) {
24155
+ const terms = splitTerms(query && query.query ? query.query : '');
24156
+ if (terms.length === 0) return [];
24157
+
24158
+ const hits = [];
24159
+
24160
+ for (const doc of index.documents) {
24161
+ if (!isDocumentMatch(doc, terms)) continue;
24162
+
24163
+ hits.push({
24164
+ documentId: doc.id,
24165
+ kind: doc.kind,
24166
+ title: doc.title,
24167
+ href: doc.href,
24168
+ path: doc.path,
24169
+ score: scoreDocument(doc, terms),
24170
+ snippet: createSnippet(doc.content, terms),
24171
+ updatedAt: doc.updatedAt,
24172
+ });
24173
+ }
24174
+
24175
+ hits.sort((a, b) => {
24176
+ if (b.score !== a.score) return b.score - a.score;
24177
+ return b.updatedAt - a.updatedAt;
24178
+ });
24179
+
24180
+ return hits.slice(0, resolveLimit(query ? query.limit : undefined));
24181
+ }
24182
+
24183
+ let index = buildSearchIndex([]);
24184
+
24185
+ function handleMessage(payload) {
24186
+ try {
24187
+ if (!payload || typeof payload !== 'object') {
24188
+ throw new Error('Invalid worker request payload');
24189
+ }
24190
+
24191
+ if (payload.type === 'init' || payload.type === 'replaceAll') {
24192
+ index = buildSearchIndex(Array.isArray(payload.docs) ? payload.docs : []);
24193
+ return { id: payload.id, type: 'ok' };
24194
+ }
24195
+
24196
+ if (payload.type === 'search') {
24197
+ const hits = searchIndex(index, payload.query || { query: '' });
24198
+ return { id: payload.id, type: 'results', hits };
24199
+ }
24200
+
24201
+ if (payload.type === 'dispose') {
24202
+ index = buildSearchIndex([]);
24203
+ return { id: payload.id, type: 'ok' };
24204
+ }
24205
+
24206
+ throw new Error('Unsupported worker request type');
24207
+ } catch (error) {
24208
+ const message = error instanceof Error ? error.message : String(error);
24209
+ return { id: payload?.id ?? 'unknown', type: 'error', message };
24210
+ }
24211
+ }
24212
+ `;
24213
+ function buildNodeWorkerSource() {
24214
+ return `${sharedRuntimeSource}\nconst { parentPort } = require('node:worker_threads');\nif (!parentPort) { throw new Error('Missing parentPort'); }\nparentPort.on('message', (payload) => { parentPort.postMessage(handleMessage(payload)); });`;
23813
24215
  }
23814
24216
 
23815
24217
  //#endregion
23816
- //#region ../server/src/dashboard-time-trends.ts
23817
- const MIN_TREND_POINT_LIMIT = 20;
23818
- const MAX_TREND_POINT_LIMIT = 500;
23819
- const DEFAULT_TREND_POINT_LIMIT = 100;
23820
- const TARGET_TREND_BARS = 20;
23821
- const DAY_MS = 1440 * 60 * 1e3;
23822
- function clampPointLimit(pointLimit) {
23823
- if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
23824
- return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
23825
- }
23826
- function createEmptyTrendSeries() {
23827
- return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
23828
- }
23829
- function normalizeEvents(events, pointLimit) {
23830
- return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
23831
- }
23832
- function buildTimeWindow(options) {
23833
- const { probeEvents, targetBars, rightEdgeTs } = options;
23834
- if (probeEvents.length === 0) return null;
23835
- const probeEnd = probeEvents[probeEvents.length - 1].ts;
23836
- const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
23837
- const probeStart = probeEvents[0].ts;
23838
- const rangeMs = Math.max(1, end - probeStart);
23839
- const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
23840
- const windowStart = end - bucketMs * targetBars;
23841
- return {
23842
- windowStart,
23843
- bucketMs,
23844
- bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
23845
- };
23846
- }
23847
- function bucketizeTrend(events, reducer, rightEdgeTs) {
23848
- if (events.length === 0) return [];
23849
- const timeWindow = buildTimeWindow({
23850
- probeEvents: events,
23851
- targetBars: TARGET_TREND_BARS,
23852
- rightEdgeTs
23853
- });
23854
- if (!timeWindow) return [];
23855
- const { windowStart, bucketMs, bucketEnds } = timeWindow;
23856
- const sums = Array.from({ length: bucketEnds.length }, () => 0);
23857
- const counts = Array.from({ length: bucketEnds.length }, () => 0);
23858
- let baseline = 0;
23859
- for (const event of events) {
23860
- if (event.ts <= windowStart) {
23861
- if (reducer === "sum-cumulative") baseline += event.value;
23862
- continue;
23863
- }
23864
- const offset = event.ts - windowStart;
23865
- const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
23866
- sums[index] += event.value;
23867
- counts[index] += 1;
23868
- }
23869
- let cumulative = baseline;
23870
- let carry = baseline !== 0 ? baseline : events[0].value;
23871
- return bucketEnds.map((ts, index) => {
23872
- if (reducer === "sum") return {
23873
- ts,
23874
- value: sums[index]
24218
+ //#region ../server/src/cli-stream-observable.ts
24219
+ /**
24220
+ * 创建安全的 CLI 流式 observable
24221
+ *
24222
+ * 解决的问题:
24223
+ * 1. 防止在 emit.complete() 之后调用 emit.next()(会导致 "Controller is already closed" 错误)
24224
+ * 2. 统一的错误处理,防止未捕获的异常导致服务器崩溃
24225
+ * 3. 确保取消时正确清理资源
24226
+ *
24227
+ * @param startStream 启动流的函数,接收 onEvent 回调,返回取消函数的 Promise
24228
+ */
24229
+ function createCliStreamObservable(startStream) {
24230
+ return observable((emit) => {
24231
+ let cancel;
24232
+ let completed = false;
24233
+ /**
24234
+ * 安全的事件处理器
24235
+ * - 检查是否已完成,防止重复调用
24236
+ * - 使用 try-catch 防止异常导致服务器崩溃
24237
+ */
24238
+ const safeEventHandler = (event) => {
24239
+ if (completed) return;
24240
+ try {
24241
+ emit.next(event);
24242
+ if (event.type === "exit") {
24243
+ completed = true;
24244
+ emit.complete();
24245
+ }
24246
+ } catch (err) {
24247
+ console.error("[CLI Stream] Error emitting event:", err);
24248
+ if (!completed) {
24249
+ completed = true;
24250
+ try {
24251
+ emit.error(err instanceof Error ? err : new Error(String(err)));
24252
+ } catch {}
24253
+ }
24254
+ }
23875
24255
  };
23876
- if (reducer === "sum-cumulative") {
23877
- cumulative += sums[index];
23878
- return {
23879
- ts,
23880
- value: cumulative
23881
- };
23882
- }
23883
- if (counts[index] > 0) carry = sums[index] / counts[index];
23884
- return {
23885
- ts,
23886
- value: carry
24256
+ startStream(safeEventHandler).then((cancelFn) => {
24257
+ cancel = cancelFn;
24258
+ }).catch((err) => {
24259
+ console.error("[CLI Stream] Error starting stream:", err);
24260
+ if (!completed) {
24261
+ completed = true;
24262
+ try {
24263
+ emit.error(err instanceof Error ? err : new Error(String(err)));
24264
+ } catch {}
24265
+ }
24266
+ });
24267
+ return () => {
24268
+ completed = true;
24269
+ cancel?.();
23887
24270
  };
23888
24271
  });
23889
24272
  }
23890
- function buildDashboardTimeTrends(options) {
23891
- const pointLimit = clampPointLimit(options.pointLimit);
23892
- const trends = createEmptyTrendSeries();
23893
- for (const metric of DASHBOARD_METRIC_KEYS) {
23894
- if (options.availability[metric].state !== "ok") continue;
23895
- trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
23896
- }
23897
- return {
23898
- trends,
23899
- trendMeta: {
23900
- pointLimit,
23901
- lastUpdatedAt: options.timestamp
23902
- }
23903
- };
23904
- }
23905
24273
 
23906
24274
  //#endregion
23907
24275
  //#region ../server/src/reactive-kv.ts
@@ -23980,133 +24348,55 @@ const reactiveKV = new ReactiveKV();
23980
24348
  */
23981
24349
  function createReactiveSubscription(task) {
23982
24350
  return observable((emit) => {
23983
- const context = new ReactiveContext();
23984
- const controller = new AbortController();
23985
- (async () => {
23986
- try {
23987
- for await (const data of context.stream(task, controller.signal)) emit.next(data);
23988
- } catch (err) {
23989
- if (!controller.signal.aborted) emit.error(err);
23990
- }
23991
- })();
23992
- return () => {
23993
- controller.abort();
23994
- };
23995
- });
23996
- }
23997
- /**
23998
- * 创建带输入参数的响应式订阅
23999
- *
24000
- * @param task 接收输入参数的异步任务
24001
- * @returns 返回一个函数,接收输入参数并返回 tRPC observable
24002
- *
24003
- * @example
24004
- * ```typescript
24005
- * // 在 router 中使用
24006
- * subscribeOne: publicProcedure
24007
- * .input(z.object({ id: z.string() }))
24008
- * .subscription(({ ctx, input }) => {
24009
- * return createReactiveSubscriptionWithInput(
24010
- * (id: string) => ctx.adapter.readSpec(id)
24011
- * )(input.id)
24012
- * })
24013
- * ```
24014
- */
24015
- function createReactiveSubscriptionWithInput(task) {
24016
- return (input) => {
24017
- return createReactiveSubscription(() => task(input));
24018
- };
24019
- }
24020
-
24021
- //#endregion
24022
- //#region ../server/src/router.ts
24023
- const t = initTRPC.context().create();
24024
- const router = t.router;
24025
- const publicProcedure = t.procedure;
24026
- const execFileAsync = promisify$1(execFile);
24027
- const OPSX_CORE_PROFILE_WORKFLOWS = [
24028
- "propose",
24029
- "explore",
24030
- "apply",
24031
- "archive"
24032
- ];
24033
- const dashboardGitTaskStatusEmitter = new EventEmitter$1();
24034
- dashboardGitTaskStatusEmitter.setMaxListeners(200);
24035
- const dashboardGitTaskStatus = {
24036
- running: false,
24037
- inFlight: 0,
24038
- lastStartedAt: null,
24039
- lastFinishedAt: null,
24040
- lastReason: null,
24041
- lastError: null
24042
- };
24043
- function getDashboardGitTaskStatus() {
24044
- return { ...dashboardGitTaskStatus };
24045
- }
24046
- function emitDashboardGitTaskStatus() {
24047
- dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
24048
- }
24049
- function beginDashboardGitTask(reason) {
24050
- dashboardGitTaskStatus.inFlight += 1;
24051
- dashboardGitTaskStatus.running = true;
24052
- dashboardGitTaskStatus.lastStartedAt = Date.now();
24053
- dashboardGitTaskStatus.lastReason = reason;
24054
- dashboardGitTaskStatus.lastError = null;
24055
- emitDashboardGitTaskStatus();
24056
- }
24057
- function endDashboardGitTask(error) {
24058
- dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
24059
- dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
24060
- dashboardGitTaskStatus.lastFinishedAt = Date.now();
24061
- if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
24062
- emitDashboardGitTaskStatus();
24063
- }
24064
- const DASHBOARD_GIT_REFRESH_STAMP_NAME = "openspecui-dashboard-git-refresh.stamp";
24065
- async function resolveGitMetadataDir(projectDir) {
24066
- try {
24067
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-dir"], {
24068
- cwd: projectDir,
24069
- maxBuffer: 1024 * 1024,
24070
- encoding: "utf8"
24071
- });
24072
- const gitDirRaw = stdout.trim();
24073
- if (!gitDirRaw) return null;
24074
- const gitDirPath = resolve$1(projectDir, gitDirRaw);
24075
- if (!(await stat(gitDirPath)).isDirectory()) return null;
24076
- return gitDirPath;
24077
- } catch {
24078
- return null;
24079
- }
24080
- }
24081
- async function resolveGitMetadataDirReactive(projectDir) {
24082
- const gitMetadataDir = await resolveGitMetadataDir(projectDir);
24083
- if (!gitMetadataDir) return null;
24084
- await reactiveReadDir(gitMetadataDir, { includeHidden: true });
24085
- return gitMetadataDir;
24086
- }
24087
- function getDashboardGitRefreshStampPath(gitMetadataDir) {
24088
- return join$1(gitMetadataDir, DASHBOARD_GIT_REFRESH_STAMP_NAME);
24089
- }
24090
- async function touchDashboardGitRefreshStamp(projectDir, reason) {
24091
- const gitMetadataDir = await resolveGitMetadataDir(projectDir);
24092
- if (!gitMetadataDir) return { skipped: true };
24093
- const stampPath = getDashboardGitRefreshStampPath(gitMetadataDir);
24094
- await mkdir$1(dirname$1(stampPath), { recursive: true });
24095
- await writeFile$1(stampPath, `${Date.now()} ${reason}\n`, "utf8");
24096
- return { skipped: false };
24097
- }
24098
- async function registerDashboardGitReactiveDeps(projectDir) {
24099
- await reactiveReadDir(projectDir, {
24100
- includeHidden: true,
24101
- exclude: ["node_modules"]
24351
+ const context = new ReactiveContext();
24352
+ const controller = new AbortController();
24353
+ (async () => {
24354
+ try {
24355
+ for await (const data of context.stream(task, controller.signal)) emit.next(data);
24356
+ } catch (err) {
24357
+ if (!controller.signal.aborted) emit.error(err);
24358
+ }
24359
+ })();
24360
+ return () => {
24361
+ controller.abort();
24362
+ };
24102
24363
  });
24103
- const gitMetadataDir = await resolveGitMetadataDirReactive(projectDir);
24104
- if (!gitMetadataDir) return;
24105
- await reactiveReadFile(getDashboardGitRefreshStampPath(gitMetadataDir));
24106
- await reactiveReadFile(join$1(gitMetadataDir, "HEAD"));
24107
- await reactiveReadFile(join$1(gitMetadataDir, "index"));
24108
- await reactiveReadFile(join$1(gitMetadataDir, "packed-refs"));
24109
24364
  }
24365
+ /**
24366
+ * 创建带输入参数的响应式订阅
24367
+ *
24368
+ * @param task 接收输入参数的异步任务
24369
+ * @returns 返回一个函数,接收输入参数并返回 tRPC observable
24370
+ *
24371
+ * @example
24372
+ * ```typescript
24373
+ * // 在 router 中使用
24374
+ * subscribeOne: publicProcedure
24375
+ * .input(z.object({ id: z.string() }))
24376
+ * .subscription(({ ctx, input }) => {
24377
+ * return createReactiveSubscriptionWithInput(
24378
+ * (id: string) => ctx.adapter.readSpec(id)
24379
+ * )(input.id)
24380
+ * })
24381
+ * ```
24382
+ */
24383
+ function createReactiveSubscriptionWithInput(task) {
24384
+ return (input) => {
24385
+ return createReactiveSubscription(() => task(input));
24386
+ };
24387
+ }
24388
+
24389
+ //#endregion
24390
+ //#region ../server/src/router.ts
24391
+ const t = initTRPC.context().create();
24392
+ const router = t.router;
24393
+ const publicProcedure = t.procedure;
24394
+ const OPSX_CORE_PROFILE_WORKFLOWS = [
24395
+ "propose",
24396
+ "explore",
24397
+ "apply",
24398
+ "archive"
24399
+ ];
24110
24400
  function requireChangeId(changeId) {
24111
24401
  if (!changeId) throw new Error("change is required");
24112
24402
  return changeId;
@@ -24276,200 +24566,6 @@ function buildSystemStatus(ctx) {
24276
24566
  watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
24277
24567
  };
24278
24568
  }
24279
- function resolveTrendTimestamp(primary, secondary) {
24280
- if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
24281
- if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
24282
- return null;
24283
- }
24284
- function parseDatedIdTimestamp(id) {
24285
- const match$1 = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
24286
- if (!match$1) return null;
24287
- const year = Number(match$1[1]);
24288
- const month = Number(match$1[2]);
24289
- const day = Number(match$1[3]);
24290
- if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
24291
- if (month < 1 || month > 12) return null;
24292
- if (day < 1 || day > 31) return null;
24293
- const ts = Date.UTC(year, month - 1, day);
24294
- return Number.isFinite(ts) ? ts : null;
24295
- }
24296
- function createEmptyTriColorTrends() {
24297
- return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
24298
- }
24299
- async function readLatestCommitTimestamp(projectDir) {
24300
- try {
24301
- const { stdout } = await execFileAsync("git", [
24302
- "log",
24303
- "-1",
24304
- "--format=%ct"
24305
- ], {
24306
- cwd: projectDir,
24307
- maxBuffer: 1024 * 1024,
24308
- encoding: "utf8"
24309
- });
24310
- const seconds = Number(stdout.trim());
24311
- return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
24312
- } catch {
24313
- return null;
24314
- }
24315
- }
24316
- async function fetchDashboardOverview(ctx, reason = "dashboard-refresh") {
24317
- if (contextStorage.getStore()) await registerDashboardGitReactiveDeps(ctx.projectDir);
24318
- const now = Date.now();
24319
- const [specMetas, changeMetas, archiveMetas] = await Promise.all([
24320
- ctx.adapter.listSpecsWithMeta(),
24321
- ctx.adapter.listChangesWithMeta(),
24322
- ctx.adapter.listArchivedChangesWithMeta()
24323
- ]);
24324
- const activeChanges = changeMetas.map((changeMeta) => ({
24325
- id: changeMeta.id,
24326
- name: changeMeta.name ?? changeMeta.id,
24327
- progress: changeMeta.progress,
24328
- updatedAt: changeMeta.updatedAt
24329
- })).sort((a, b) => b.updatedAt - a.updatedAt);
24330
- const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
24331
- const change = await ctx.adapter.readArchivedChange(meta.id);
24332
- if (!change) return null;
24333
- return {
24334
- id: meta.id,
24335
- createdAt: meta.createdAt,
24336
- updatedAt: meta.updatedAt,
24337
- tasksCompleted: change.tasks.filter((task) => task.completed).length
24338
- };
24339
- }))).filter((item) => item !== null);
24340
- const specifications = (await Promise.all(specMetas.map(async (meta) => {
24341
- const spec = await ctx.adapter.readSpec(meta.id);
24342
- if (!spec) return null;
24343
- return {
24344
- id: meta.id,
24345
- name: meta.name,
24346
- requirements: spec.requirements.length,
24347
- updatedAt: meta.updatedAt
24348
- };
24349
- }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
24350
- const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
24351
- const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
24352
- const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
24353
- const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
24354
- const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
24355
- const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
24356
- const specificationTrendEvents = specMetas.flatMap((spec) => {
24357
- const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
24358
- return ts === null ? [] : [{
24359
- ts,
24360
- value: 1
24361
- }];
24362
- });
24363
- const completedTrendEvents = archivedChanges.flatMap((archive) => {
24364
- const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
24365
- return ts === null ? [] : [{
24366
- ts,
24367
- value: archive.tasksCompleted
24368
- }];
24369
- });
24370
- const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
24371
- const requirementTrendEvents = specifications.flatMap((spec) => {
24372
- const meta = specMetaById.get(spec.id);
24373
- const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
24374
- return ts === null ? [] : [{
24375
- ts,
24376
- value: spec.requirements
24377
- }];
24378
- });
24379
- const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
24380
- const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
24381
- const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
24382
- const config = await ctx.configManager.readConfig();
24383
- beginDashboardGitTask(reason);
24384
- let latestCommitTs = null;
24385
- let git;
24386
- try {
24387
- const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
24388
- defaultBranch: "main",
24389
- worktrees: []
24390
- }));
24391
- latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
24392
- git = await gitSnapshotPromise;
24393
- } catch (error) {
24394
- endDashboardGitTask(error);
24395
- throw error;
24396
- }
24397
- endDashboardGitTask(null);
24398
- const cardAvailability = {
24399
- specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
24400
- state: "invalid",
24401
- reason: "objective-history-unavailable"
24402
- },
24403
- requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
24404
- state: "invalid",
24405
- reason: "objective-history-unavailable"
24406
- },
24407
- activeChanges: {
24408
- state: "invalid",
24409
- reason: "objective-history-unavailable"
24410
- },
24411
- inProgressChanges: {
24412
- state: "invalid",
24413
- reason: "objective-history-unavailable"
24414
- },
24415
- completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
24416
- state: "invalid",
24417
- reason: "objective-history-unavailable"
24418
- },
24419
- taskCompletionPercent: {
24420
- state: "invalid",
24421
- reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
24422
- }
24423
- };
24424
- const trendKinds = {
24425
- specifications: "monotonic",
24426
- requirements: "monotonic",
24427
- activeChanges: "bidirectional",
24428
- inProgressChanges: "bidirectional",
24429
- completedChanges: "monotonic",
24430
- taskCompletionPercent: "bidirectional"
24431
- };
24432
- const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
24433
- pointLimit: config.dashboard.trendPointLimit,
24434
- timestamp: now,
24435
- rightEdgeTs: latestCommitTs,
24436
- availability: cardAvailability,
24437
- events: {
24438
- specifications: specificationTrendEvents,
24439
- requirements: requirementTrendEvents,
24440
- activeChanges: [],
24441
- inProgressChanges: [],
24442
- completedChanges: completedTrendEvents,
24443
- taskCompletionPercent: []
24444
- },
24445
- reducers: {
24446
- specifications: "sum",
24447
- requirements: "sum",
24448
- completedChanges: "sum"
24449
- }
24450
- });
24451
- return {
24452
- summary: {
24453
- specifications: specifications.length,
24454
- requirements,
24455
- activeChanges: activeChanges.length,
24456
- inProgressChanges,
24457
- completedChanges: archiveMetas.length,
24458
- archivedTasksCompleted,
24459
- tasksTotal,
24460
- tasksCompleted,
24461
- taskCompletionPercent
24462
- },
24463
- trends: baselineTrends,
24464
- triColorTrends: createEmptyTriColorTrends(),
24465
- trendKinds,
24466
- cardAvailability,
24467
- trendMeta,
24468
- specifications,
24469
- activeChanges,
24470
- git
24471
- };
24472
- }
24473
24569
  /**
24474
24570
  * Spec router - spec CRUD operations
24475
24571
  */
@@ -25171,30 +25267,52 @@ const systemRouter = router({
25171
25267
  */
25172
25268
  const dashboardRouter = router({
25173
25269
  get: publicProcedure.query(async ({ ctx }) => {
25174
- return fetchDashboardOverview(ctx, "dashboard.get");
25270
+ return ctx.dashboardOverviewService.getCurrent();
25175
25271
  }),
25176
25272
  subscribe: publicProcedure.subscription(({ ctx }) => {
25177
- return createReactiveSubscription(async () => {
25178
- return fetchDashboardOverview(ctx, "dashboard.subscribe");
25273
+ return observable((emit) => {
25274
+ const unsubscribe = ctx.dashboardOverviewService.subscribe((overview) => {
25275
+ emit.next(overview);
25276
+ }, {
25277
+ emitCurrent: true,
25278
+ onError: (error) => {
25279
+ emit.error(error);
25280
+ }
25281
+ });
25282
+ return () => {
25283
+ unsubscribe();
25284
+ };
25179
25285
  });
25180
25286
  }),
25181
25287
  refreshGitSnapshot: publicProcedure.input(objectType({ reason: stringType().optional() }).optional()).mutation(async ({ ctx, input }) => {
25182
25288
  const reason = input?.reason?.trim() || "manual-refresh";
25183
25289
  await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
25290
+ await ctx.dashboardOverviewService.refresh(reason);
25291
+ return { success: true };
25292
+ }),
25293
+ removeDetachedWorktree: publicProcedure.input(objectType({ path: stringType().min(1) })).mutation(async ({ ctx, input }) => {
25294
+ await ctx.dashboardOverviewService.getCurrent();
25295
+ await removeDetachedDashboardGitWorktree({
25296
+ projectDir: ctx.projectDir,
25297
+ targetPath: input.path
25298
+ });
25299
+ await touchDashboardGitRefreshStamp(ctx.projectDir, "remove-detached-worktree");
25300
+ await ctx.dashboardOverviewService.refresh("remove-detached-worktree");
25184
25301
  return { success: true };
25185
25302
  }),
25186
- gitTaskStatus: publicProcedure.query(() => {
25303
+ gitTaskStatus: publicProcedure.query(async ({ ctx }) => {
25304
+ await ctx.dashboardOverviewService.getCurrent();
25187
25305
  return getDashboardGitTaskStatus();
25188
25306
  }),
25189
- subscribeGitTaskStatus: publicProcedure.subscription(() => {
25307
+ subscribeGitTaskStatus: publicProcedure.subscription(({ ctx }) => {
25190
25308
  return observable((emit) => {
25309
+ ctx.dashboardOverviewService.getCurrent().catch(() => {});
25191
25310
  emit.next(getDashboardGitTaskStatus());
25192
- const handler = (status) => {
25311
+ const unsubscribe = subscribeDashboardGitTaskStatus((status) => {
25193
25312
  emit.next(status);
25194
- };
25195
- dashboardGitTaskStatusEmitter.on("change", handler);
25313
+ });
25196
25314
  return () => {
25197
- dashboardGitTaskStatusEmitter.off("change", handler);
25315
+ unsubscribe();
25198
25316
  };
25199
25317
  });
25200
25318
  })
@@ -25472,6 +25590,11 @@ function createServer$2(config) {
25472
25590
  const kernel = config.kernel;
25473
25591
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
25474
25592
  const searchService = new SearchService(adapter, watcher);
25593
+ const dashboardOverviewService = new DashboardOverviewService((reason) => loadDashboardOverview({
25594
+ adapter,
25595
+ configManager,
25596
+ projectDir: config.projectDir
25597
+ }, reason), watcher);
25475
25598
  const app = new Hono();
25476
25599
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
25477
25600
  app.use("*", cors({
@@ -25498,6 +25621,7 @@ function createServer$2(config) {
25498
25621
  cliExecutor,
25499
25622
  kernel,
25500
25623
  searchService,
25624
+ dashboardOverviewService,
25501
25625
  watcher,
25502
25626
  projectDir: config.projectDir
25503
25627
  })
@@ -25509,6 +25633,7 @@ function createServer$2(config) {
25509
25633
  cliExecutor,
25510
25634
  kernel,
25511
25635
  searchService,
25636
+ dashboardOverviewService,
25512
25637
  watcher,
25513
25638
  projectDir: config.projectDir
25514
25639
  });
@@ -25519,6 +25644,7 @@ function createServer$2(config) {
25519
25644
  cliExecutor,
25520
25645
  kernel,
25521
25646
  searchService,
25647
+ dashboardOverviewService,
25522
25648
  watcher,
25523
25649
  createContext,
25524
25650
  port: config.port ?? 3100
@@ -25566,6 +25692,7 @@ async function createWebSocketServer(server, httpServer, config) {
25566
25692
  wss.close();
25567
25693
  server.watcher?.stop();
25568
25694
  server.searchService.dispose().catch(() => {});
25695
+ server.dashboardOverviewService.dispose();
25569
25696
  }
25570
25697
  };
25571
25698
  }
@@ -25601,6 +25728,9 @@ async function startServer(config, setupApp) {
25601
25728
  server.searchService.init().catch((err) => {
25602
25729
  console.error("Search service warmup failed:", err);
25603
25730
  });
25731
+ server.dashboardOverviewService.init().catch((err) => {
25732
+ console.error("Dashboard overview warmup failed:", err);
25733
+ });
25604
25734
  return {
25605
25735
  url,
25606
25736
  port,