@openspecui/server 1.4.1 → 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.
- package/LICENSE +21 -0
- package/dist/index.mjs +670 -6
- 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.
|
|
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/
|
|
24
|
-
"@openspecui/
|
|
23
|
+
"@openspecui/search": "1.1.0",
|
|
24
|
+
"@openspecui/core": "1.5.0"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^22.10.2",
|