@openspecui/server 1.4.1 → 1.6.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 +670 -6
  3. package/package.json +3 -3
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,6 +1,6 @@
1
1
  import { createServer as createServer$1 } from "node:net";
2
2
  import { serve } from "@hono/node-server";
3
- import { CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getWatcherRuntimeStatus, 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";
4
4
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
5
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
6
6
  import { Hono } from "hono";
@@ -11,10 +11,12 @@ 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;
@@ -678,6 +1112,200 @@ function buildSystemStatus(ctx) {
678
1112
  watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
679
1113
  };
680
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
+ }
681
1309
  /**
682
1310
  * Spec router - spec CRUD operations
683
1311
  */
@@ -885,15 +1513,17 @@ const configRouter = router({
885
1513
  "dark",
886
1514
  "system"
887
1515
  ]).optional(),
888
- terminal: TerminalConfigSchema.omit({ rendererEngine: true }).partial().extend({ rendererEngine: TerminalRendererEngineSchema.optional() }).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
  }
@@ -1350,9 +1980,43 @@ const systemRouter = router({
1350
1980
  })
1351
1981
  });
1352
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
+ /**
1353
2016
  * Main app router
1354
2017
  */
1355
2018
  const appRouter = router({
2019
+ dashboard: dashboardRouter,
1356
2020
  spec: specRouter,
1357
2021
  change: changeRouter,
1358
2022
  archive: archiveRouter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "1.4.1",
3
+ "version": "1.6.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.6.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.10.2",