@openspecui/server 2.3.5 → 2.3.6

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 (2) hide show
  1. package/dist/index.mjs +228 -4
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer as createServer$1 } from "node:net";
2
2
  import { serve } from "@hono/node-server";
3
- import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, GitConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli } from "@openspecui/core";
3
+ import { CliExecutor, CodeEditorThemeSchema, ConfigManager, DASHBOARD_METRIC_KEYS, DashboardConfigSchema, GitConfigSchema, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, TerminalConfigSchema, TerminalRendererEngineSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, getDetectedProjectTools, getToolInitStates, getWatcherRuntimeStatus, initWatcherPool, isWatcherPoolInitialized, sniffGlobalCli, subscribeWatcherRuntimeStatus } 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";
@@ -928,6 +928,210 @@ async function loadDashboardOverview(ctx, reason = "dashboard-refresh") {
928
928
  };
929
929
  }
930
930
 
931
+ //#endregion
932
+ //#region src/project-recovery-service.ts
933
+ function normalizeWorktreeBranchName(defaultBranch) {
934
+ const normalized = defaultBranch.trim();
935
+ return /^[^/]+\/(.+)$/.exec(normalized)?.[1] ?? normalized;
936
+ }
937
+ function getGitDirArgs(gitDir, args) {
938
+ return [
939
+ "--git-dir",
940
+ gitDir,
941
+ ...args
942
+ ];
943
+ }
944
+ var ProjectRecoveryService = class {
945
+ emitter = new EventEmitter();
946
+ current = { state: "idle" };
947
+ projectDir;
948
+ gitWorktreeHandoff;
949
+ runGit;
950
+ doesPathExist;
951
+ canonicalPath;
952
+ cachedGitCommonDir = null;
953
+ cachedCanonicalProjectDir;
954
+ primeRepositoryMetadataPromise = null;
955
+ recoveryEpoch = 0;
956
+ unsubscribeWatcherRuntime;
957
+ constructor(options) {
958
+ this.emitter.setMaxListeners(200);
959
+ this.projectDir = resolve(options.projectDir);
960
+ this.gitWorktreeHandoff = options.gitWorktreeHandoff;
961
+ this.runGit = options.runGit ?? defaultRunGit;
962
+ this.doesPathExist = options.pathExists ?? pathExists;
963
+ this.canonicalPath = options.canonicalPath ?? canonicalGitPath;
964
+ this.cachedCanonicalProjectDir = this.projectDir;
965
+ this.unsubscribeWatcherRuntime = (options.subscribeWatcherRuntime ?? subscribeWatcherRuntimeStatus)((status) => {
966
+ this.handleWatcherRuntimeStatus(status);
967
+ }, { emitCurrent: true });
968
+ this.primeRepositoryMetadata();
969
+ }
970
+ getCurrent() {
971
+ return this.current;
972
+ }
973
+ subscribe(listener, options = {}) {
974
+ this.emitter.on("change", listener);
975
+ if (options.emitCurrent !== false) listener(this.current);
976
+ return () => {
977
+ this.emitter.off("change", listener);
978
+ };
979
+ }
980
+ dispose() {
981
+ this.unsubscribeWatcherRuntime();
982
+ this.emitter.removeAllListeners();
983
+ }
984
+ setStatus(next) {
985
+ if (JSON.stringify(this.current) === JSON.stringify(next)) return;
986
+ this.current = next;
987
+ this.emitter.emit("change", next);
988
+ }
989
+ handleWatcherRuntimeStatus(status) {
990
+ const residency = status?.projectResidency;
991
+ if (!residency || residency.state === "active") {
992
+ this.recoveryEpoch += 1;
993
+ this.setStatus({ state: "idle" });
994
+ this.primeRepositoryMetadata();
995
+ return;
996
+ }
997
+ if (this.hasHandledEviction(residency)) return;
998
+ const epoch = ++this.recoveryEpoch;
999
+ this.setStatus({
1000
+ state: "evicted",
1001
+ reason: residency.reason,
1002
+ detectedAt: residency.detectedAt
1003
+ });
1004
+ this.resolveRecovery(residency, epoch);
1005
+ }
1006
+ hasHandledEviction(residency) {
1007
+ switch (this.current.state) {
1008
+ case "evicted":
1009
+ case "resolving":
1010
+ case "ready":
1011
+ case "unavailable":
1012
+ case "failed": return this.current.detectedAt === residency.detectedAt;
1013
+ default: return false;
1014
+ }
1015
+ }
1016
+ async resolveRecovery(residency, epoch) {
1017
+ this.setStatus({
1018
+ state: "resolving",
1019
+ reason: residency.reason,
1020
+ detectedAt: residency.detectedAt
1021
+ });
1022
+ if (this.cachedGitCommonDir === null) await this.primeRepositoryMetadata();
1023
+ if (epoch !== this.recoveryEpoch) return;
1024
+ const gitCommonDir = this.cachedGitCommonDir;
1025
+ if (!gitCommonDir || !await this.doesPathExist(gitCommonDir)) {
1026
+ this.setStatus({
1027
+ state: "unavailable",
1028
+ reason: residency.reason,
1029
+ detectedAt: residency.detectedAt,
1030
+ message: "Cached Git metadata is unavailable, so automatic recovery cannot resolve a fallback worktree."
1031
+ });
1032
+ return;
1033
+ }
1034
+ const targetPath = await this.resolveFallbackTargetPath(gitCommonDir);
1035
+ if (epoch !== this.recoveryEpoch) return;
1036
+ if (!targetPath) {
1037
+ this.setStatus({
1038
+ state: "unavailable",
1039
+ reason: residency.reason,
1040
+ detectedAt: residency.detectedAt,
1041
+ message: "No existing default-branch worktree is available for automatic recovery. Restore the worktree manually or reopen the repo from a surviving worktree."
1042
+ });
1043
+ return;
1044
+ }
1045
+ if (!this.gitWorktreeHandoff) {
1046
+ this.setStatus({
1047
+ state: "unavailable",
1048
+ reason: residency.reason,
1049
+ detectedAt: residency.detectedAt,
1050
+ message: "This runtime cannot spawn or reuse sibling worktree servers for automatic recovery."
1051
+ });
1052
+ return;
1053
+ }
1054
+ try {
1055
+ const handoff = await this.gitWorktreeHandoff.ensureWorktreeServer({ targetPath });
1056
+ if (epoch !== this.recoveryEpoch) return;
1057
+ this.setStatus({
1058
+ state: "ready",
1059
+ reason: residency.reason,
1060
+ detectedAt: residency.detectedAt,
1061
+ handoff
1062
+ });
1063
+ } catch (error) {
1064
+ if (epoch !== this.recoveryEpoch) return;
1065
+ this.setStatus({
1066
+ state: "failed",
1067
+ reason: residency.reason,
1068
+ detectedAt: residency.detectedAt,
1069
+ message: error instanceof Error ? error.message : String(error)
1070
+ });
1071
+ }
1072
+ }
1073
+ async primeRepositoryMetadata() {
1074
+ if (this.primeRepositoryMetadataPromise) return this.primeRepositoryMetadataPromise;
1075
+ this.primeRepositoryMetadataPromise = (async () => {
1076
+ this.cachedCanonicalProjectDir = await this.canonicalPath(this.projectDir);
1077
+ this.cachedGitCommonDir = await this.resolveGitCommonDir();
1078
+ })();
1079
+ try {
1080
+ await this.primeRepositoryMetadataPromise;
1081
+ } catch {
1082
+ this.cachedGitCommonDir = null;
1083
+ this.cachedCanonicalProjectDir = this.projectDir;
1084
+ } finally {
1085
+ this.primeRepositoryMetadataPromise = null;
1086
+ }
1087
+ }
1088
+ async resolveGitCommonDir() {
1089
+ const result = await this.runGit(this.projectDir, ["rev-parse", "--git-common-dir"]);
1090
+ const gitCommonDirRaw = result.ok ? result.stdout.trim() : "";
1091
+ if (!gitCommonDirRaw) return null;
1092
+ return resolve(this.projectDir, gitCommonDirRaw);
1093
+ }
1094
+ async resolveDefaultBranchName(gitCommonDir) {
1095
+ const cwd = dirname(gitCommonDir);
1096
+ const remoteHead = await this.runGit(cwd, getGitDirArgs(gitCommonDir, [
1097
+ "symbolic-ref",
1098
+ "--quiet",
1099
+ "--short",
1100
+ "refs/remotes/origin/HEAD"
1101
+ ]));
1102
+ const remoteRef = remoteHead.stdout.trim();
1103
+ if (remoteHead.ok && remoteRef) return normalizeWorktreeBranchName(remoteRef);
1104
+ const localHead = await this.runGit(cwd, getGitDirArgs(gitCommonDir, [
1105
+ "rev-parse",
1106
+ "--abbrev-ref",
1107
+ "HEAD"
1108
+ ]));
1109
+ const localRef = localHead.stdout.trim();
1110
+ if (localHead.ok && localRef && localRef !== "HEAD") return normalizeWorktreeBranchName(localRef);
1111
+ return "main";
1112
+ }
1113
+ async resolveFallbackTargetPath(gitCommonDir) {
1114
+ const cwd = dirname(gitCommonDir);
1115
+ const defaultBranchName = await this.resolveDefaultBranchName(gitCommonDir);
1116
+ const worktreeListResult = await this.runGit(cwd, getGitDirArgs(gitCommonDir, [
1117
+ "worktree",
1118
+ "list",
1119
+ "--porcelain"
1120
+ ]));
1121
+ if (!worktreeListResult.ok) return null;
1122
+ const parsedWorktrees = parseWorktreeList(worktreeListResult.stdout);
1123
+ for (const worktree of parsedWorktrees) {
1124
+ const targetPath = resolve(worktree.path);
1125
+ const branchName = worktree.branchRef?.replace(/^refs\/heads\//, "") ?? null;
1126
+ if (worktree.detached || branchName !== defaultBranchName) continue;
1127
+ if (!await this.doesPathExist(targetPath)) continue;
1128
+ if (await this.canonicalPath(targetPath) === this.cachedCanonicalProjectDir) continue;
1129
+ return targetPath;
1130
+ }
1131
+ return null;
1132
+ }
1133
+ };
1134
+
931
1135
  //#endregion
932
1136
  //#region src/pty-manager.ts
933
1137
  const DEFAULT_SCROLLBACK = 1e3;
@@ -2275,7 +2479,8 @@ function buildSystemStatus(ctx) {
2275
2479
  watcherEnabled: runtime?.initialized ?? false,
2276
2480
  watcherGeneration: runtime?.generation ?? 0,
2277
2481
  watcherReinitializeCount: runtime?.reinitializeCount ?? 0,
2278
- watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null
2482
+ watcherLastReinitializeReason: runtime?.lastReinitializeReason ?? null,
2483
+ projectRecovery: ctx.projectRecoveryService.getCurrent()
2279
2484
  };
2280
2485
  }
2281
2486
  /**
@@ -2989,13 +3194,24 @@ const systemRouter = router({
2989
3194
  }),
2990
3195
  subscribe: publicProcedure.subscription(({ ctx }) => {
2991
3196
  return observable((emit) => {
2992
- emit.next(buildSystemStatus(ctx));
2993
- const timer = setInterval(() => {
3197
+ const pushStatus = () => {
2994
3198
  emit.next(buildSystemStatus(ctx));
3199
+ };
3200
+ pushStatus();
3201
+ const unsubscribeWatcherRuntime = subscribeWatcherRuntimeStatus(() => {
3202
+ pushStatus();
3203
+ });
3204
+ const unsubscribeProjectRecovery = ctx.projectRecoveryService.subscribe(() => {
3205
+ pushStatus();
3206
+ });
3207
+ const timer = setInterval(() => {
3208
+ pushStatus();
2995
3209
  }, 3e3);
2996
3210
  timer.unref();
2997
3211
  return () => {
2998
3212
  clearInterval(timer);
3213
+ unsubscribeWatcherRuntime();
3214
+ unsubscribeProjectRecovery();
2999
3215
  };
3000
3216
  });
3001
3217
  })
@@ -3300,6 +3516,10 @@ function createServer(config) {
3300
3516
  configManager,
3301
3517
  projectDir: config.projectDir
3302
3518
  }, reason), watcher);
3519
+ const projectRecoveryService = new ProjectRecoveryService({
3520
+ projectDir: config.projectDir,
3521
+ gitWorktreeHandoff: config.gitWorktreeHandoff
3522
+ });
3303
3523
  const app = new Hono();
3304
3524
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
3305
3525
  app.use("*", cors({
@@ -3327,6 +3547,7 @@ function createServer(config) {
3327
3547
  kernel,
3328
3548
  searchService,
3329
3549
  dashboardOverviewService,
3550
+ projectRecoveryService,
3330
3551
  gitWorktreeHandoff: config.gitWorktreeHandoff,
3331
3552
  watcher,
3332
3553
  projectDir: config.projectDir
@@ -3340,6 +3561,7 @@ function createServer(config) {
3340
3561
  kernel,
3341
3562
  searchService,
3342
3563
  dashboardOverviewService,
3564
+ projectRecoveryService,
3343
3565
  gitWorktreeHandoff: config.gitWorktreeHandoff,
3344
3566
  watcher,
3345
3567
  projectDir: config.projectDir
@@ -3352,6 +3574,7 @@ function createServer(config) {
3352
3574
  kernel,
3353
3575
  searchService,
3354
3576
  dashboardOverviewService,
3577
+ projectRecoveryService,
3355
3578
  watcher,
3356
3579
  createContext,
3357
3580
  port: config.port ?? 3100
@@ -3400,6 +3623,7 @@ async function createWebSocketServer(server, httpServer, config) {
3400
3623
  server.watcher?.stop();
3401
3624
  server.searchService.dispose().catch(() => {});
3402
3625
  server.dashboardOverviewService.dispose();
3626
+ server.projectRecoveryService.dispose();
3403
3627
  }
3404
3628
  };
3405
3629
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "2.3.5",
3
+ "version": "2.3.6",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {