@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.
- package/dist/index.mjs +228 -4
- 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
|
-
|
|
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
|
}
|