@openspecui/server 1.2.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.mjs +709 -19
  3. package/package.json +4 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenSpecUI Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.mjs CHANGED
@@ -1,20 +1,22 @@
1
+ import { createServer as createServer$1 } from "node:net";
1
2
  import { serve } from "@hono/node-server";
2
- import { CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli } from "@openspecui/core";
3
+ import { CliExecutor, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, contextStorage, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, reactiveExists, reactiveReadDir, reactiveReadFile, sniffGlobalCli } from "@openspecui/core";
3
4
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
4
5
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
5
6
  import { Hono } from "hono";
6
7
  import { cors } from "hono/cors";
7
8
  import { WebSocketServer } from "ws";
8
- import { createServer as createServer$1 } from "node:net";
9
9
  import * as pty from "@lydell/node-pty";
10
10
  import { EventEmitter } from "events";
11
11
  import { SearchQuerySchema } from "@openspecui/search";
12
12
  import { initTRPC } from "@trpc/server";
13
13
  import { observable } from "@trpc/server/observable";
14
+ import { execFile } from "node:child_process";
15
+ import { EventEmitter as EventEmitter$1 } from "node:events";
14
16
  import { mkdir, rm, writeFile } from "node:fs/promises";
15
- import { dirname, join, resolve, sep } from "node:path";
17
+ import { dirname, join, relative, resolve, sep } from "node:path";
18
+ import { promisify } from "node:util";
16
19
  import { z } from "zod";
17
- import { EventEmitter as EventEmitter$1 } from "node:events";
18
20
  import { NodeWorkerSearchProvider } from "@openspecui/search/node";
19
21
 
20
22
  //#region src/port-utils.ts
@@ -477,6 +479,368 @@ function createCliStreamObservable(startStream) {
477
479
  });
478
480
  }
479
481
 
482
+ //#endregion
483
+ //#region src/dashboard-git-snapshot.ts
484
+ const execFileAsync$1 = promisify(execFile);
485
+ const EMPTY_DIFF = {
486
+ files: 0,
487
+ insertions: 0,
488
+ deletions: 0
489
+ };
490
+ async function defaultRunGit(cwd, args) {
491
+ try {
492
+ const { stdout } = await execFileAsync$1("git", args, {
493
+ cwd,
494
+ encoding: "utf8",
495
+ maxBuffer: 8 * 1024 * 1024
496
+ });
497
+ return {
498
+ ok: true,
499
+ stdout
500
+ };
501
+ } catch {
502
+ return {
503
+ ok: false,
504
+ stdout: ""
505
+ };
506
+ }
507
+ }
508
+ function parseShortStat(output) {
509
+ const files = Number(/(\d+)\s+files? changed/.exec(output)?.[1] ?? 0);
510
+ const insertions = Number(/(\d+)\s+insertions?\(\+\)/.exec(output)?.[1] ?? 0);
511
+ const deletions = Number(/(\d+)\s+deletions?\(-\)/.exec(output)?.[1] ?? 0);
512
+ return {
513
+ files: Number.isFinite(files) ? files : 0,
514
+ insertions: Number.isFinite(insertions) ? insertions : 0,
515
+ deletions: Number.isFinite(deletions) ? deletions : 0
516
+ };
517
+ }
518
+ function parseNumStat(output) {
519
+ let files = 0;
520
+ let insertions = 0;
521
+ let deletions = 0;
522
+ for (const line of output.split("\n")) {
523
+ const trimmed = line.trim();
524
+ if (!trimmed) continue;
525
+ const [addRaw, deleteRaw] = trimmed.split(" ");
526
+ if (!addRaw || !deleteRaw) continue;
527
+ files += 1;
528
+ if (addRaw !== "-") insertions += Number(addRaw) || 0;
529
+ if (deleteRaw !== "-") deletions += Number(deleteRaw) || 0;
530
+ }
531
+ return {
532
+ files,
533
+ insertions,
534
+ deletions
535
+ };
536
+ }
537
+ function normalizeGitPath(path) {
538
+ return path.replace(/\\/g, "/").replace(/^\.\//, "");
539
+ }
540
+ function relativePath(fromDir, target) {
541
+ const rel = relative(fromDir, target);
542
+ if (!rel || rel.length === 0) return ".";
543
+ return rel;
544
+ }
545
+ function parseBranchName(branchRef, detached) {
546
+ if (detached) return "(detached)";
547
+ if (!branchRef) return "(unknown)";
548
+ return branchRef.replace(/^refs\/heads\//, "");
549
+ }
550
+ function parseWorktreeList(porcelain) {
551
+ const entries = [];
552
+ let current = null;
553
+ const flush = () => {
554
+ if (!current) return;
555
+ entries.push(current);
556
+ current = null;
557
+ };
558
+ for (const line of porcelain.split("\n")) {
559
+ if (line.startsWith("worktree ")) {
560
+ flush();
561
+ current = {
562
+ path: line.slice(9).trim(),
563
+ branchRef: null,
564
+ detached: false
565
+ };
566
+ continue;
567
+ }
568
+ if (!current) continue;
569
+ if (line.startsWith("branch ")) {
570
+ current.branchRef = line.slice(7).trim();
571
+ continue;
572
+ }
573
+ if (line === "detached") {
574
+ current.detached = true;
575
+ continue;
576
+ }
577
+ }
578
+ flush();
579
+ return entries;
580
+ }
581
+ function parseRelatedChanges(paths) {
582
+ const related = /* @__PURE__ */ new Set();
583
+ for (const path of paths) {
584
+ const normalized = normalizeGitPath(path);
585
+ const activeMatch = /^openspec\/changes\/([^/]+)\//.exec(normalized);
586
+ if (activeMatch?.[1]) {
587
+ related.add(activeMatch[1]);
588
+ continue;
589
+ }
590
+ const archiveMatch = /^openspec\/changes\/archive\/([^/]+)\//.exec(normalized);
591
+ if (archiveMatch?.[1]) {
592
+ const fullName = archiveMatch[1];
593
+ related.add(fullName.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
594
+ }
595
+ }
596
+ return [...related].sort((a, b) => a.localeCompare(b));
597
+ }
598
+ async function resolveDefaultBranch(projectDir, runGit) {
599
+ const remoteHead = await runGit(projectDir, [
600
+ "symbolic-ref",
601
+ "--quiet",
602
+ "--short",
603
+ "refs/remotes/origin/HEAD"
604
+ ]);
605
+ const remoteRef = remoteHead.stdout.trim();
606
+ if (remoteHead.ok && remoteRef) return remoteRef;
607
+ const localHead = await runGit(projectDir, [
608
+ "rev-parse",
609
+ "--abbrev-ref",
610
+ "HEAD"
611
+ ]);
612
+ const localRef = localHead.stdout.trim();
613
+ if (localHead.ok && localRef && localRef !== "HEAD") return localRef;
614
+ return "main";
615
+ }
616
+ async function collectCommitEntries(options) {
617
+ const { worktreePath, defaultBranch, maxCommitEntries, runGit } = options;
618
+ const entries = [];
619
+ const commits = await runGit(worktreePath, [
620
+ "log",
621
+ "--format=%H%x1f%s",
622
+ `-n${maxCommitEntries}`,
623
+ `${defaultBranch}..HEAD`
624
+ ]);
625
+ if (commits.ok) for (const line of commits.stdout.split("\n")) {
626
+ if (!line.trim()) continue;
627
+ const [hash, title = ""] = line.split("");
628
+ if (!hash) continue;
629
+ const diffResult = await runGit(worktreePath, [
630
+ "show",
631
+ "--numstat",
632
+ "--format=",
633
+ hash
634
+ ]);
635
+ const changedFiles = (await runGit(worktreePath, [
636
+ "show",
637
+ "--name-only",
638
+ "--format=",
639
+ hash
640
+ ])).stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
641
+ entries.push({
642
+ type: "commit",
643
+ hash,
644
+ title: title.trim() || hash.slice(0, 7),
645
+ relatedChanges: parseRelatedChanges(changedFiles),
646
+ diff: diffResult.ok ? parseNumStat(diffResult.stdout) : EMPTY_DIFF
647
+ });
648
+ }
649
+ const trackedResult = await runGit(worktreePath, [
650
+ "diff",
651
+ "--numstat",
652
+ "HEAD"
653
+ ]);
654
+ const trackedFilesResult = await runGit(worktreePath, [
655
+ "diff",
656
+ "--name-only",
657
+ "HEAD"
658
+ ]);
659
+ const untrackedResult = await runGit(worktreePath, [
660
+ "ls-files",
661
+ "--others",
662
+ "--exclude-standard"
663
+ ]);
664
+ const trackedFiles = trackedFilesResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
665
+ const untrackedFiles = untrackedResult.stdout.split("\n").map((item) => item.trim()).filter((item) => item.length > 0);
666
+ const allUncommittedFiles = new Set([...trackedFiles, ...untrackedFiles]);
667
+ const trackedDiff = trackedResult.ok ? parseNumStat(trackedResult.stdout) : EMPTY_DIFF;
668
+ entries.push({
669
+ type: "uncommitted",
670
+ title: "Uncommitted",
671
+ relatedChanges: parseRelatedChanges([...allUncommittedFiles]),
672
+ diff: {
673
+ files: allUncommittedFiles.size,
674
+ insertions: trackedDiff.insertions,
675
+ deletions: trackedDiff.deletions
676
+ }
677
+ });
678
+ return entries;
679
+ }
680
+ async function collectWorktree(options) {
681
+ const { projectDir, worktree, defaultBranch, runGit, maxCommitEntries } = options;
682
+ const worktreePath = resolve(worktree.path);
683
+ const resolvedProjectDir = resolve(projectDir);
684
+ const aheadBehindResult = await runGit(worktreePath, [
685
+ "rev-list",
686
+ "--left-right",
687
+ "--count",
688
+ `${defaultBranch}...HEAD`
689
+ ]);
690
+ let ahead = 0;
691
+ let behind = 0;
692
+ if (aheadBehindResult.ok) {
693
+ const [behindRaw, aheadRaw] = aheadBehindResult.stdout.trim().split(/\s+/);
694
+ ahead = Number(aheadRaw) || 0;
695
+ behind = Number(behindRaw) || 0;
696
+ }
697
+ const diffResult = await runGit(worktreePath, [
698
+ "diff",
699
+ "--shortstat",
700
+ `${defaultBranch}...HEAD`
701
+ ]);
702
+ const diff = diffResult.ok ? parseShortStat(diffResult.stdout) : EMPTY_DIFF;
703
+ const entries = await collectCommitEntries({
704
+ worktreePath,
705
+ defaultBranch,
706
+ maxCommitEntries,
707
+ runGit
708
+ });
709
+ return {
710
+ path: worktreePath,
711
+ relativePath: relativePath(resolvedProjectDir, worktreePath),
712
+ branchName: parseBranchName(worktree.branchRef, worktree.detached),
713
+ isCurrent: resolvedProjectDir === worktreePath,
714
+ ahead,
715
+ behind,
716
+ diff,
717
+ entries
718
+ };
719
+ }
720
+ async function buildDashboardGitSnapshot(options) {
721
+ const runGit = options.runGit ?? defaultRunGit;
722
+ const maxCommitEntries = options.maxCommitEntries ?? 8;
723
+ const resolvedProjectDir = resolve(options.projectDir);
724
+ const defaultBranch = await resolveDefaultBranch(resolvedProjectDir, runGit);
725
+ const worktreeResult = await runGit(resolvedProjectDir, [
726
+ "worktree",
727
+ "list",
728
+ "--porcelain"
729
+ ]);
730
+ const parsed = worktreeResult.ok ? parseWorktreeList(worktreeResult.stdout) : [];
731
+ const baseWorktrees = parsed.length > 0 ? parsed : [{
732
+ path: resolvedProjectDir,
733
+ branchRef: null,
734
+ detached: false
735
+ }];
736
+ const worktrees = await Promise.all(baseWorktrees.map((worktree) => collectWorktree({
737
+ projectDir: resolvedProjectDir,
738
+ worktree,
739
+ defaultBranch,
740
+ runGit,
741
+ maxCommitEntries
742
+ })));
743
+ worktrees.sort((a, b) => {
744
+ if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
745
+ return a.branchName.localeCompare(b.branchName);
746
+ });
747
+ return {
748
+ defaultBranch,
749
+ worktrees
750
+ };
751
+ }
752
+
753
+ //#endregion
754
+ //#region src/dashboard-time-trends.ts
755
+ const MIN_TREND_POINT_LIMIT = 20;
756
+ const MAX_TREND_POINT_LIMIT = 500;
757
+ const DEFAULT_TREND_POINT_LIMIT = 100;
758
+ const TARGET_TREND_BARS = 20;
759
+ const DAY_MS = 1440 * 60 * 1e3;
760
+ function clampPointLimit(pointLimit) {
761
+ if (!Number.isFinite(pointLimit)) return DEFAULT_TREND_POINT_LIMIT;
762
+ return Math.max(MIN_TREND_POINT_LIMIT, Math.min(MAX_TREND_POINT_LIMIT, Math.trunc(pointLimit)));
763
+ }
764
+ function createEmptyTrendSeries() {
765
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
766
+ }
767
+ function normalizeEvents(events, pointLimit) {
768
+ return events.filter((event) => Number.isFinite(event.ts) && event.ts > 0 && Number.isFinite(event.value)).sort((a, b) => a.ts - b.ts).slice(-pointLimit);
769
+ }
770
+ function buildTimeWindow(options) {
771
+ const { probeEvents, targetBars, rightEdgeTs } = options;
772
+ if (probeEvents.length === 0) return null;
773
+ const probeEnd = probeEvents[probeEvents.length - 1].ts;
774
+ const end = typeof rightEdgeTs === "number" && Number.isFinite(rightEdgeTs) && rightEdgeTs > 0 ? Math.max(probeEnd, rightEdgeTs) : probeEnd;
775
+ const probeStart = probeEvents[0].ts;
776
+ const rangeMs = Math.max(1, end - probeStart);
777
+ const bucketMs = rangeMs >= DAY_MS ? Math.max(DAY_MS, Math.ceil(rangeMs / targetBars / DAY_MS) * DAY_MS) : Math.max(1, Math.ceil(rangeMs / targetBars));
778
+ const windowStart = end - bucketMs * targetBars;
779
+ return {
780
+ windowStart,
781
+ bucketMs,
782
+ bucketEnds: Array.from({ length: targetBars }, (_, index) => windowStart + bucketMs * (index + 1))
783
+ };
784
+ }
785
+ function bucketizeTrend(events, reducer, rightEdgeTs) {
786
+ if (events.length === 0) return [];
787
+ const timeWindow = buildTimeWindow({
788
+ probeEvents: events,
789
+ targetBars: TARGET_TREND_BARS,
790
+ rightEdgeTs
791
+ });
792
+ if (!timeWindow) return [];
793
+ const { windowStart, bucketMs, bucketEnds } = timeWindow;
794
+ const sums = Array.from({ length: bucketEnds.length }, () => 0);
795
+ const counts = Array.from({ length: bucketEnds.length }, () => 0);
796
+ let baseline = 0;
797
+ for (const event of events) {
798
+ if (event.ts <= windowStart) {
799
+ if (reducer === "sum-cumulative") baseline += event.value;
800
+ continue;
801
+ }
802
+ const offset = event.ts - windowStart;
803
+ const index = Math.max(0, Math.min(bucketEnds.length - 1, Math.ceil(offset / bucketMs) - 1));
804
+ sums[index] += event.value;
805
+ counts[index] += 1;
806
+ }
807
+ let cumulative = baseline;
808
+ let carry = baseline !== 0 ? baseline : events[0].value;
809
+ return bucketEnds.map((ts, index) => {
810
+ if (reducer === "sum") return {
811
+ ts,
812
+ value: sums[index]
813
+ };
814
+ if (reducer === "sum-cumulative") {
815
+ cumulative += sums[index];
816
+ return {
817
+ ts,
818
+ value: cumulative
819
+ };
820
+ }
821
+ if (counts[index] > 0) carry = sums[index] / counts[index];
822
+ return {
823
+ ts,
824
+ value: carry
825
+ };
826
+ });
827
+ }
828
+ function buildDashboardTimeTrends(options) {
829
+ const pointLimit = clampPointLimit(options.pointLimit);
830
+ const trends = createEmptyTrendSeries();
831
+ for (const metric of DASHBOARD_METRIC_KEYS) {
832
+ if (options.availability[metric].state !== "ok") continue;
833
+ trends[metric] = bucketizeTrend(normalizeEvents(options.events[metric], pointLimit), options.reducers?.[metric] ?? "sum", options.rightEdgeTs);
834
+ }
835
+ return {
836
+ trends,
837
+ trendMeta: {
838
+ pointLimit,
839
+ lastUpdatedAt: options.timestamp
840
+ }
841
+ };
842
+ }
843
+
480
844
  //#endregion
481
845
  //#region src/reactive-kv.ts
482
846
  /**
@@ -597,6 +961,76 @@ function createReactiveSubscriptionWithInput(task) {
597
961
  const t = initTRPC.context().create();
598
962
  const router = t.router;
599
963
  const publicProcedure = t.procedure;
964
+ const execFileAsync = promisify(execFile);
965
+ const dashboardGitTaskStatusEmitter = new EventEmitter$1();
966
+ dashboardGitTaskStatusEmitter.setMaxListeners(200);
967
+ const dashboardGitTaskStatus = {
968
+ running: false,
969
+ inFlight: 0,
970
+ lastStartedAt: null,
971
+ lastFinishedAt: null,
972
+ lastReason: null,
973
+ lastError: null
974
+ };
975
+ function getDashboardGitTaskStatus() {
976
+ return { ...dashboardGitTaskStatus };
977
+ }
978
+ function emitDashboardGitTaskStatus() {
979
+ dashboardGitTaskStatusEmitter.emit("change", getDashboardGitTaskStatus());
980
+ }
981
+ function beginDashboardGitTask(reason) {
982
+ dashboardGitTaskStatus.inFlight += 1;
983
+ dashboardGitTaskStatus.running = true;
984
+ dashboardGitTaskStatus.lastStartedAt = Date.now();
985
+ dashboardGitTaskStatus.lastReason = reason;
986
+ dashboardGitTaskStatus.lastError = null;
987
+ emitDashboardGitTaskStatus();
988
+ }
989
+ function endDashboardGitTask(error) {
990
+ dashboardGitTaskStatus.inFlight = Math.max(0, dashboardGitTaskStatus.inFlight - 1);
991
+ dashboardGitTaskStatus.running = dashboardGitTaskStatus.inFlight > 0;
992
+ dashboardGitTaskStatus.lastFinishedAt = Date.now();
993
+ if (error) dashboardGitTaskStatus.lastError = error instanceof Error ? error.message : String(error);
994
+ emitDashboardGitTaskStatus();
995
+ }
996
+ function parseGitDirFromDotGitFile(content) {
997
+ const line = content.split(/\r?\n/).map((item) => item.trim()).find((item) => item.startsWith("gitdir:"));
998
+ if (!line) return null;
999
+ const rawPath = line.slice(7).trim();
1000
+ return rawPath.length > 0 ? rawPath : null;
1001
+ }
1002
+ function getDashboardGitRefreshStampPath(projectDir) {
1003
+ return join(projectDir, "openspec", ".openspecui-dashboard-git-refresh.stamp");
1004
+ }
1005
+ async function touchDashboardGitRefreshStamp(projectDir, reason) {
1006
+ const stampPath = getDashboardGitRefreshStampPath(projectDir);
1007
+ await mkdir(dirname(stampPath), { recursive: true });
1008
+ await writeFile(stampPath, `${Date.now()} ${reason}\n`, "utf8");
1009
+ }
1010
+ async function registerDashboardGitReactiveDeps(projectDir) {
1011
+ await reactiveReadDir(projectDir, {
1012
+ includeHidden: true,
1013
+ exclude: ["node_modules"]
1014
+ });
1015
+ await reactiveReadFile(getDashboardGitRefreshStampPath(projectDir));
1016
+ const dotGitPath = join(projectDir, ".git");
1017
+ if (!await reactiveExists(dotGitPath)) return;
1018
+ const dotGitFileContent = await reactiveReadFile(dotGitPath);
1019
+ if (dotGitFileContent !== null) {
1020
+ const gitDirRaw = parseGitDirFromDotGitFile(dotGitFileContent);
1021
+ if (!gitDirRaw) return;
1022
+ const gitDirPath = resolve(projectDir, gitDirRaw);
1023
+ await reactiveReadDir(gitDirPath, { includeHidden: true });
1024
+ await reactiveReadFile(join(gitDirPath, "HEAD"));
1025
+ await reactiveReadFile(join(gitDirPath, "index"));
1026
+ await reactiveReadFile(join(gitDirPath, "packed-refs"));
1027
+ return;
1028
+ }
1029
+ await reactiveReadDir(dotGitPath, { includeHidden: true });
1030
+ await reactiveReadFile(join(dotGitPath, "HEAD"));
1031
+ await reactiveReadFile(join(dotGitPath, "index"));
1032
+ await reactiveReadFile(join(dotGitPath, "packed-refs"));
1033
+ }
600
1034
  function requireChangeId(changeId) {
601
1035
  if (!changeId) throw new Error("change is required");
602
1036
  return changeId;
@@ -668,6 +1102,210 @@ async function fetchOpsxTemplateContents(ctx, schema) {
668
1102
  await ctx.kernel.ensureTemplateContents(schema);
669
1103
  return ctx.kernel.getTemplateContents(schema);
670
1104
  }
1105
+ function buildSystemStatus(ctx) {
1106
+ const runtime = getWatcherRuntimeStatus();
1107
+ return {
1108
+ projectDir: ctx.projectDir,
1109
+ watcherEnabled: runtime?.initialized ?? false,
1110
+ watcherGeneration: runtime?.generation ?? 0,
1111
+ watcherReinitializeCount: runtime?.reinitializeCount ?? 0,
1112
+ watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
1113
+ };
1114
+ }
1115
+ function resolveTrendTimestamp(primary, secondary) {
1116
+ if (typeof primary === "number" && Number.isFinite(primary) && primary > 0) return primary;
1117
+ if (typeof secondary === "number" && Number.isFinite(secondary) && secondary > 0) return secondary;
1118
+ return null;
1119
+ }
1120
+ function parseDatedIdTimestamp(id) {
1121
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:-|$)/.exec(id);
1122
+ if (!match) return null;
1123
+ const year = Number(match[1]);
1124
+ const month = Number(match[2]);
1125
+ const day = Number(match[3]);
1126
+ if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
1127
+ if (month < 1 || month > 12) return null;
1128
+ if (day < 1 || day > 31) return null;
1129
+ const ts = Date.UTC(year, month - 1, day);
1130
+ return Number.isFinite(ts) ? ts : null;
1131
+ }
1132
+ function createEmptyTriColorTrends() {
1133
+ return Object.fromEntries(DASHBOARD_METRIC_KEYS.map((metric) => [metric, []]));
1134
+ }
1135
+ async function readLatestCommitTimestamp(projectDir) {
1136
+ try {
1137
+ const { stdout } = await execFileAsync("git", [
1138
+ "log",
1139
+ "-1",
1140
+ "--format=%ct"
1141
+ ], {
1142
+ cwd: projectDir,
1143
+ maxBuffer: 1024 * 1024,
1144
+ encoding: "utf8"
1145
+ });
1146
+ const seconds = Number(stdout.trim());
1147
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
1148
+ } catch {
1149
+ return null;
1150
+ }
1151
+ }
1152
+ async function fetchDashboardOverview(ctx, reason = "dashboard-refresh") {
1153
+ if (contextStorage.getStore()) await registerDashboardGitReactiveDeps(ctx.projectDir);
1154
+ const now = Date.now();
1155
+ const [specMetas, changeMetas, archiveMetas] = await Promise.all([
1156
+ ctx.adapter.listSpecsWithMeta(),
1157
+ ctx.adapter.listChangesWithMeta(),
1158
+ ctx.adapter.listArchivedChangesWithMeta()
1159
+ ]);
1160
+ const archivedChanges = (await Promise.all(archiveMetas.map(async (meta) => {
1161
+ const change = await ctx.adapter.readArchivedChange(meta.id);
1162
+ if (!change) return null;
1163
+ return {
1164
+ id: meta.id,
1165
+ createdAt: meta.createdAt,
1166
+ updatedAt: meta.updatedAt,
1167
+ tasksCompleted: change.tasks.filter((task) => task.completed).length
1168
+ };
1169
+ }))).filter((item) => item !== null);
1170
+ const specifications = (await Promise.all(specMetas.map(async (meta) => {
1171
+ const spec = await ctx.adapter.readSpec(meta.id);
1172
+ if (!spec) return null;
1173
+ return {
1174
+ id: meta.id,
1175
+ name: meta.name,
1176
+ requirements: spec.requirements.length,
1177
+ updatedAt: meta.updatedAt
1178
+ };
1179
+ }))).filter((item) => item !== null).sort((a, b) => b.requirements - a.requirements || b.updatedAt - a.updatedAt);
1180
+ const activeChanges = changeMetas.map((change) => ({
1181
+ id: change.id,
1182
+ name: change.name,
1183
+ progress: change.progress,
1184
+ updatedAt: change.updatedAt
1185
+ }));
1186
+ const requirements = specifications.reduce((sum, spec) => sum + spec.requirements, 0);
1187
+ const tasksTotal = activeChanges.reduce((sum, change) => sum + change.progress.total, 0);
1188
+ const tasksCompleted = activeChanges.reduce((sum, change) => sum + change.progress.completed, 0);
1189
+ const archivedTasksCompleted = archivedChanges.reduce((sum, change) => sum + change.tasksCompleted, 0);
1190
+ const taskCompletionPercent = tasksTotal > 0 ? Math.round(tasksCompleted / tasksTotal * 100) : null;
1191
+ const inProgressChanges = activeChanges.filter((change) => change.progress.total > 0 && change.progress.completed < change.progress.total).length;
1192
+ const specificationTrendEvents = specMetas.flatMap((spec) => {
1193
+ const ts = resolveTrendTimestamp(spec.createdAt, spec.updatedAt);
1194
+ return ts === null ? [] : [{
1195
+ ts,
1196
+ value: 1
1197
+ }];
1198
+ });
1199
+ const completedTrendEvents = archivedChanges.flatMap((archive) => {
1200
+ const ts = parseDatedIdTimestamp(archive.id) ?? resolveTrendTimestamp(archive.updatedAt, archive.createdAt);
1201
+ return ts === null ? [] : [{
1202
+ ts,
1203
+ value: archive.tasksCompleted
1204
+ }];
1205
+ });
1206
+ const specMetaById = new Map(specMetas.map((meta) => [meta.id, meta]));
1207
+ const requirementTrendEvents = specifications.flatMap((spec) => {
1208
+ const meta = specMetaById.get(spec.id);
1209
+ const ts = resolveTrendTimestamp(meta?.updatedAt, meta?.createdAt);
1210
+ return ts === null ? [] : [{
1211
+ ts,
1212
+ value: spec.requirements
1213
+ }];
1214
+ });
1215
+ const hasObjectiveSpecificationTrend = specificationTrendEvents.length > 0 || specifications.length === 0;
1216
+ const hasObjectiveRequirementTrend = requirementTrendEvents.length > 0 || requirements === 0;
1217
+ const hasObjectiveCompletedTrend = completedTrendEvents.length > 0 || archiveMetas.length === 0;
1218
+ const config = await ctx.configManager.readConfig();
1219
+ beginDashboardGitTask(reason);
1220
+ let latestCommitTs = null;
1221
+ let git;
1222
+ try {
1223
+ const gitSnapshotPromise = buildDashboardGitSnapshot({ projectDir: ctx.projectDir }).catch(() => ({
1224
+ defaultBranch: "main",
1225
+ worktrees: []
1226
+ }));
1227
+ latestCommitTs = await readLatestCommitTimestamp(ctx.projectDir);
1228
+ git = await gitSnapshotPromise;
1229
+ } catch (error) {
1230
+ endDashboardGitTask(error);
1231
+ throw error;
1232
+ }
1233
+ endDashboardGitTask(null);
1234
+ const cardAvailability = {
1235
+ specifications: hasObjectiveSpecificationTrend ? { state: "ok" } : {
1236
+ state: "invalid",
1237
+ reason: "objective-history-unavailable"
1238
+ },
1239
+ requirements: hasObjectiveRequirementTrend ? { state: "ok" } : {
1240
+ state: "invalid",
1241
+ reason: "objective-history-unavailable"
1242
+ },
1243
+ activeChanges: {
1244
+ state: "invalid",
1245
+ reason: "objective-history-unavailable"
1246
+ },
1247
+ inProgressChanges: {
1248
+ state: "invalid",
1249
+ reason: "objective-history-unavailable"
1250
+ },
1251
+ completedChanges: hasObjectiveCompletedTrend ? { state: "ok" } : {
1252
+ state: "invalid",
1253
+ reason: "objective-history-unavailable"
1254
+ },
1255
+ taskCompletionPercent: {
1256
+ state: "invalid",
1257
+ reason: taskCompletionPercent === null ? "semantic-uncomputable" : "objective-history-unavailable"
1258
+ }
1259
+ };
1260
+ const trendKinds = {
1261
+ specifications: "monotonic",
1262
+ requirements: "monotonic",
1263
+ activeChanges: "bidirectional",
1264
+ inProgressChanges: "bidirectional",
1265
+ completedChanges: "monotonic",
1266
+ taskCompletionPercent: "bidirectional"
1267
+ };
1268
+ const { trends: baselineTrends, trendMeta } = buildDashboardTimeTrends({
1269
+ pointLimit: config.dashboard.trendPointLimit,
1270
+ timestamp: now,
1271
+ rightEdgeTs: latestCommitTs,
1272
+ availability: cardAvailability,
1273
+ events: {
1274
+ specifications: specificationTrendEvents,
1275
+ requirements: requirementTrendEvents,
1276
+ activeChanges: [],
1277
+ inProgressChanges: [],
1278
+ completedChanges: completedTrendEvents,
1279
+ taskCompletionPercent: []
1280
+ },
1281
+ reducers: {
1282
+ specifications: "sum",
1283
+ requirements: "sum",
1284
+ completedChanges: "sum"
1285
+ }
1286
+ });
1287
+ return {
1288
+ summary: {
1289
+ specifications: specifications.length,
1290
+ requirements,
1291
+ activeChanges: activeChanges.length,
1292
+ inProgressChanges,
1293
+ completedChanges: archiveMetas.length,
1294
+ archivedTasksCompleted,
1295
+ tasksTotal,
1296
+ tasksCompleted,
1297
+ taskCompletionPercent
1298
+ },
1299
+ trends: baselineTrends,
1300
+ triColorTrends: createEmptyTriColorTrends(),
1301
+ trendKinds,
1302
+ cardAvailability,
1303
+ trendMeta,
1304
+ specifications,
1305
+ activeChanges,
1306
+ git
1307
+ };
1308
+ }
671
1309
  /**
672
1310
  * Spec router - spec CRUD operations
673
1311
  */
@@ -875,25 +1513,17 @@ const configRouter = router({
875
1513
  "dark",
876
1514
  "system"
877
1515
  ]).optional(),
878
- terminal: z.object({
879
- fontSize: z.number().min(8).max(32).optional(),
880
- fontFamily: z.string().optional(),
881
- cursorBlink: z.boolean().optional(),
882
- cursorStyle: z.enum([
883
- "block",
884
- "underline",
885
- "bar"
886
- ]).optional(),
887
- scrollback: z.number().min(0).max(1e5).optional()
888
- }).optional()
1516
+ terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).optional(),
1517
+ dashboard: DashboardConfigSchema.partial().optional()
889
1518
  })).mutation(async ({ ctx, input }) => {
890
1519
  const hasCliCommand = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "command");
891
1520
  const hasCliArgs = input.cli !== void 0 && Object.prototype.hasOwnProperty.call(input.cli, "args");
892
1521
  if (hasCliCommand && !hasCliArgs) {
893
1522
  await ctx.configManager.setCliCommand(input.cli?.command ?? "");
894
- if (input.theme !== void 0 || input.terminal !== void 0) await ctx.configManager.writeConfig({
1523
+ if (input.theme !== void 0 || input.terminal !== void 0 || input.dashboard !== void 0) await ctx.configManager.writeConfig({
895
1524
  theme: input.theme,
896
- terminal: input.terminal
1525
+ terminal: input.terminal,
1526
+ dashboard: input.dashboard
897
1527
  });
898
1528
  return { success: true };
899
1529
  }
@@ -1330,9 +1960,63 @@ const searchRouter = router({
1330
1960
  })
1331
1961
  });
1332
1962
  /**
1963
+ * System router - runtime status and heartbeat-friendly subscription
1964
+ */
1965
+ const systemRouter = router({
1966
+ status: publicProcedure.query(({ ctx }) => {
1967
+ return buildSystemStatus(ctx);
1968
+ }),
1969
+ subscribe: publicProcedure.subscription(({ ctx }) => {
1970
+ return observable((emit) => {
1971
+ emit.next(buildSystemStatus(ctx));
1972
+ const timer = setInterval(() => {
1973
+ emit.next(buildSystemStatus(ctx));
1974
+ }, 3e3);
1975
+ timer.unref();
1976
+ return () => {
1977
+ clearInterval(timer);
1978
+ };
1979
+ });
1980
+ })
1981
+ });
1982
+ /**
1983
+ * Dashboard router - objective project overview for UI
1984
+ */
1985
+ const dashboardRouter = router({
1986
+ get: publicProcedure.query(async ({ ctx }) => {
1987
+ return fetchDashboardOverview(ctx, "dashboard.get");
1988
+ }),
1989
+ subscribe: publicProcedure.subscription(({ ctx }) => {
1990
+ return createReactiveSubscription(async () => {
1991
+ return fetchDashboardOverview(ctx, "dashboard.subscribe");
1992
+ });
1993
+ }),
1994
+ refreshGitSnapshot: publicProcedure.input(z.object({ reason: z.string().optional() }).optional()).mutation(async ({ ctx, input }) => {
1995
+ const reason = input?.reason?.trim() || "manual-refresh";
1996
+ await touchDashboardGitRefreshStamp(ctx.projectDir, reason);
1997
+ return { success: true };
1998
+ }),
1999
+ gitTaskStatus: publicProcedure.query(() => {
2000
+ return getDashboardGitTaskStatus();
2001
+ }),
2002
+ subscribeGitTaskStatus: publicProcedure.subscription(() => {
2003
+ return observable((emit) => {
2004
+ emit.next(getDashboardGitTaskStatus());
2005
+ const handler = (status) => {
2006
+ emit.next(status);
2007
+ };
2008
+ dashboardGitTaskStatusEmitter.on("change", handler);
2009
+ return () => {
2010
+ dashboardGitTaskStatusEmitter.off("change", handler);
2011
+ };
2012
+ });
2013
+ })
2014
+ });
2015
+ /**
1333
2016
  * Main app router
1334
2017
  */
1335
2018
  const appRouter = router({
2019
+ dashboard: dashboardRouter,
1336
2020
  spec: specRouter,
1337
2021
  change: changeRouter,
1338
2022
  archive: archiveRouter,
@@ -1342,7 +2026,8 @@ const appRouter = router({
1342
2026
  cli: cliRouter,
1343
2027
  opsx: opsxRouter,
1344
2028
  kv: kvRouter,
1345
- search: searchRouter
2029
+ search: searchRouter,
2030
+ system: systemRouter
1346
2031
  });
1347
2032
 
1348
2033
  //#endregion
@@ -1561,7 +2246,12 @@ async function createWebSocketServer(server, httpServer, config) {
1561
2246
  const handler = applyWSSHandler({
1562
2247
  wss,
1563
2248
  router: appRouter,
1564
- createContext: server.createContext
2249
+ createContext: server.createContext,
2250
+ keepAlive: {
2251
+ enabled: true,
2252
+ pingMs: 3e4,
2253
+ pongWaitMs: 5e3
2254
+ }
1565
2255
  });
1566
2256
  const ptyManager = new PtyManager(config.projectDir);
1567
2257
  const ptyWss = new WebSocketServer({ noServer: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "1.2.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
@@ -20,8 +20,8 @@
20
20
  "yaml": "^2.8.0",
21
21
  "yargs": "^18.0.0",
22
22
  "zod": "^3.24.1",
23
- "@openspecui/core": "1.2.0",
24
- "@openspecui/search": "1.1.0"
23
+ "@openspecui/search": "1.1.0",
24
+ "@openspecui/core": "1.5.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.10.2",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsdown src/index.ts --format esm --no-dts",
37
- "typecheck": "tsc --noEmit",
37
+ "typecheck": "tsc -p tsconfig.check.json --noEmit",
38
38
  "dev": "tsx watch --include '../core/dist/**' --include '../search/dist/**' src/standalone.ts",
39
39
  "test": "vitest run",
40
40
  "test:watch": "vitest"