@akira-tl/forgerelay 1.3.5 → 1.3.6-beta.1
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/CHANGELOG.md +12 -0
- package/dist/cli.js +12 -4
- package/dist/lsp/code-intelligence.js +28 -49
- package/dist/lsp/runtime/manager.js +54 -12
- package/dist/lsp/runtime/process-launch.js +48 -0
- package/dist/mcp/server/transport/http-server.js +3 -1
- package/dist/runtime/logging/runtime-diagnostics.js +92 -0
- package/dist/runtime/testing/server-fixture.js +1 -1
- package/package.json +2 -2
- package/scripts/lsp/dev-beta.sh +27 -0
- package/scripts/lsp/interop.mjs +2 -1
- package/scripts/lsp/mutation-soak.sh +6 -0
- package/scripts/lsp/mutation-soak.ts +186 -0
- package/scripts/lsp/verify-beta.sh +6 -0
- package/scripts/release/publish.mjs +1 -1
- package/scripts/release/release-version.test.mjs +14 -2
- package/scripts/release-proof.mjs +3 -2
- package/scripts/release-proof.test.mjs +21 -0
- package/scripts/release-version.mjs +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.3.6-beta.1] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- 语言服务回收前重新检查服务归属、活动请求和最近使用时间,避免并发文件修改重新使用服务后,清理任务仍按旧名单关闭它;服务整体关闭时等待已开始的操作完成。
|
|
12
|
+
- Pyright 互操作验证直接使用真实语言服务器协议请求,避免因不支持 `--version` 而被误跳过。
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- 默认在实例状态目录的 `logs/runtime.jsonl` 持久记录语言服务启动、关闭原因、信号发送位置、操作异常和主进程未捕获异常;同步写入、限制大小并轮换保存,不依赖终端滚屏。
|
|
17
|
+
- 支持 beta 补丁版本,经现有发布验证后发布到 npm `next` 通道并标记为 GitHub 预发布。
|
|
18
|
+
|
|
7
19
|
## [1.3.5] - 2026-11-04
|
|
8
20
|
|
|
9
21
|
### Fixed
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { join, resolve } from "node:path";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { loadConfig } from "./runtime/config/config.js";
|
|
11
11
|
import { acquireRuntimeLease } from "./runtime/state/runtime-lease.js";
|
|
12
|
+
import { diagnosticError, observeRuntimeExit, RuntimeDiagnostics } from "./runtime/logging/runtime-diagnostics.js";
|
|
12
13
|
import { runInit } from "./cli/init.js";
|
|
13
14
|
import { runConfigMigration } from "./cli/config/migrate.js";
|
|
14
15
|
import { runConfigInspection } from "./cli/config/inspect.js";
|
|
@@ -198,11 +199,16 @@ async function serve(runtimePrivilege, runtimeOverrides = {}) {
|
|
|
198
199
|
const config = loadConfig(process.env, { runtimeOverrides });
|
|
199
200
|
config.runtimePrivilege = runtimePrivilege;
|
|
200
201
|
const runtimeLease = acquireRuntimeLease(config.stateDir);
|
|
202
|
+
const diagnostics = new RuntimeDiagnostics(config.stateDir);
|
|
203
|
+
const stopObservingExit = observeRuntimeExit(diagnostics);
|
|
204
|
+
diagnostics.record("runtime_started", { version: installedForgeRelayVersion(), reason: process.version });
|
|
201
205
|
let server;
|
|
202
206
|
try {
|
|
203
|
-
server = createServer(config);
|
|
207
|
+
server = createServer(config, { runtimeDiagnostics: diagnostics });
|
|
204
208
|
}
|
|
205
209
|
catch (error) {
|
|
210
|
+
diagnostics.record("runtime_start_failed", diagnosticError(error));
|
|
211
|
+
stopObservingExit();
|
|
206
212
|
runtimeLease.release();
|
|
207
213
|
throw error;
|
|
208
214
|
}
|
|
@@ -219,6 +225,7 @@ async function serve(runtimePrivilege, runtimeOverrides = {}) {
|
|
|
219
225
|
console.log(`command shell: ${formatCommandShellRuntime(config.commandShellRuntime)}`);
|
|
220
226
|
console.log(`runtime privilege: ${formatRuntimePrivilege(runtimePrivilege)}`);
|
|
221
227
|
console.log(`logging: ${config.logging.level} ${config.logging.format}`);
|
|
228
|
+
console.log(`runtime diagnostics: ${diagnostics.path}`);
|
|
222
229
|
if (config.subagents) {
|
|
223
230
|
console.log(`subagent providers: ${formatSubagentProviderAvailabilitySummary(subagentProviders)}`);
|
|
224
231
|
}
|
|
@@ -239,14 +246,15 @@ async function serve(runtimePrivilege, runtimeOverrides = {}) {
|
|
|
239
246
|
}
|
|
240
247
|
process.exit(0);
|
|
241
248
|
};
|
|
242
|
-
const handleShutdown = () => {
|
|
249
|
+
const handleShutdown = (signal) => {
|
|
250
|
+
diagnostics.record("runtime_signal", { signal });
|
|
243
251
|
void shutdown().catch((error) => {
|
|
244
252
|
console.error("forgerelay shutdown failed", error);
|
|
245
253
|
process.exit(1);
|
|
246
254
|
});
|
|
247
255
|
};
|
|
248
|
-
process.once("SIGINT", handleShutdown);
|
|
249
|
-
process.once("SIGTERM", handleShutdown);
|
|
256
|
+
process.once("SIGINT", () => handleShutdown("SIGINT"));
|
|
257
|
+
process.once("SIGTERM", () => handleShutdown("SIGTERM"));
|
|
250
258
|
}
|
|
251
259
|
async function runDoctor() {
|
|
252
260
|
const files = loadForgeRelayFiles();
|
|
@@ -7,6 +7,8 @@ import { DefinitionRequest, DiagnosticTag, DocumentDiagnosticReportKind, Documen
|
|
|
7
7
|
import { LanguageServerConfigurationError, } from "./language-server-config.js";
|
|
8
8
|
import { terminateProcessTree } from "../mcp/process/process-platform.js";
|
|
9
9
|
import { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { diagnosticError, } from "../runtime/logging/runtime-diagnostics.js";
|
|
10
12
|
import { DEFAULT_CODE_INTELLIGENCE_RESULT_LIMIT } from "./code-intelligence-types.js";
|
|
11
13
|
import { normalizeHoverContents } from "./normalization/hover.js";
|
|
12
14
|
import { normalizeDocumentSymbols, normalizeWorkspaceSymbols, } from "./normalization/symbols.js";
|
|
@@ -14,7 +16,7 @@ import { isWithin, locationEntries, normalizeLocations, workspaceDisplayPath, }
|
|
|
14
16
|
import { lspPositionFromUser, rangeFromLsp } from "./position-encoding.js";
|
|
15
17
|
import { DiagnosticSnapshotStore } from "./runtime/diagnostic-snapshots.js";
|
|
16
18
|
import { DocumentSynchronizer } from "./runtime/document-synchronizer.js";
|
|
17
|
-
import { languageServerCommandRequiresShell } from "./runtime/process-launch.js";
|
|
19
|
+
import { languageServerCommandRequiresShell, waitForChildExit, waitForChildSpawn, withTimeout, } from "./runtime/process-launch.js";
|
|
18
20
|
import { SemanticRequestCoordinator } from "./runtime/semantic-requests.js";
|
|
19
21
|
export { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
20
22
|
const STDERR_TAIL_BYTES = 64 * 1024;
|
|
@@ -36,6 +38,7 @@ export class LanguageService {
|
|
|
36
38
|
semanticRequests;
|
|
37
39
|
stderrTail = Buffer.alloc(0);
|
|
38
40
|
closed = false;
|
|
41
|
+
serviceId = randomUUID();
|
|
39
42
|
constructor(workspaceRoot, project, policy) {
|
|
40
43
|
this.workspaceRoot = workspaceRoot;
|
|
41
44
|
this.project = project;
|
|
@@ -279,9 +282,10 @@ export class LanguageService {
|
|
|
279
282
|
throw new CodeIntelligenceError("code.server_crashed", `Language server ${this.project.definition.id} failed: ${errorMessage(error)}${this.stderrSuffix()}`);
|
|
280
283
|
}
|
|
281
284
|
}
|
|
282
|
-
async shutdown() {
|
|
285
|
+
async shutdown(reason = "server_shutdown") {
|
|
283
286
|
if (this.closed)
|
|
284
287
|
return;
|
|
288
|
+
this.recordDiagnostic("lsp_shutdown", { reason, ...diagnosticError(new Error()) });
|
|
285
289
|
this.closed = true;
|
|
286
290
|
const connection = this.connection;
|
|
287
291
|
const child = this.child;
|
|
@@ -302,6 +306,7 @@ export class LanguageService {
|
|
|
302
306
|
this.documents.clear();
|
|
303
307
|
this.diagnosticSnapshots.clear();
|
|
304
308
|
if (child && child.exitCode === null && child.signalCode === null) {
|
|
309
|
+
this.recordDiagnostic("lsp_terminate", { reason, signal: "SIGTERM", ...diagnosticError(new Error()) });
|
|
305
310
|
terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
|
|
306
311
|
}
|
|
307
312
|
this.child = undefined;
|
|
@@ -324,6 +329,7 @@ export class LanguageService {
|
|
|
324
329
|
}
|
|
325
330
|
catch (error) {
|
|
326
331
|
this.initializePromise = undefined;
|
|
332
|
+
this.recordDiagnostic("lsp_start_failed", diagnosticError(error));
|
|
327
333
|
this.cleanupFailedStart();
|
|
328
334
|
if (error instanceof CodeIntelligenceError)
|
|
329
335
|
throw error;
|
|
@@ -348,6 +354,10 @@ export class LanguageService {
|
|
|
348
354
|
throw new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${definition.id}: ${errorMessage(error)}.`);
|
|
349
355
|
}
|
|
350
356
|
this.child = child;
|
|
357
|
+
this.recordDiagnostic("lsp_start");
|
|
358
|
+
child.on("exit", (exitCode, signal) => {
|
|
359
|
+
this.recordDiagnostic("lsp_exit", { childPid: child.pid, exitCode, signal });
|
|
360
|
+
});
|
|
351
361
|
child.stderr.on("data", (chunk) => this.appendStderr(chunk));
|
|
352
362
|
await waitForChildSpawn(child, definition.id);
|
|
353
363
|
const connection = createMessageConnection(child.stdout, child.stdin);
|
|
@@ -430,6 +440,7 @@ export class LanguageService {
|
|
|
430
440
|
try {
|
|
431
441
|
const result = await withTimeout(Promise.race([initialize, startupFailure]), this.policy.startTimeoutMs, () => new CodeIntelligenceError("code.language_service_start_timeout", `Language server ${definition.id} did not initialize within ${this.policy.startTimeoutMs}ms.`));
|
|
432
442
|
await connection.sendNotification(InitializedNotification.type, {});
|
|
443
|
+
this.recordDiagnostic("lsp_ready");
|
|
433
444
|
return result;
|
|
434
445
|
}
|
|
435
446
|
finally {
|
|
@@ -441,6 +452,9 @@ export class LanguageService {
|
|
|
441
452
|
this.connection?.dispose();
|
|
442
453
|
const child = this.child;
|
|
443
454
|
if (child && child.exitCode === null && child.signalCode === null) {
|
|
455
|
+
this.recordDiagnostic("lsp_terminate", {
|
|
456
|
+
reason: "initialization_failed", signal: "SIGTERM", ...diagnosticError(new Error()),
|
|
457
|
+
});
|
|
444
458
|
terminateProcessTree(child, "SIGTERM", process.platform !== "win32");
|
|
445
459
|
}
|
|
446
460
|
this.connection = undefined;
|
|
@@ -448,6 +462,18 @@ export class LanguageService {
|
|
|
448
462
|
this.documents.clear();
|
|
449
463
|
this.diagnosticSnapshots.clear();
|
|
450
464
|
}
|
|
465
|
+
recordDiagnostic(event, fields = {}) {
|
|
466
|
+
this.policy.diagnostics?.record(event, {
|
|
467
|
+
serviceId: this.serviceId,
|
|
468
|
+
serverId: this.project.definition.id,
|
|
469
|
+
projectRoot: this.project.projectRoot,
|
|
470
|
+
childPid: this.child?.pid,
|
|
471
|
+
operationsInFlight: this.inFlight,
|
|
472
|
+
semanticRequestsActive: this.semanticRequests.activeCount,
|
|
473
|
+
semanticRequestsQueued: this.semanticRequests.queuedCount,
|
|
474
|
+
...fields,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
451
477
|
registerClientHandlers(connection) {
|
|
452
478
|
connection.onRequest("workspace/configuration", (params) => Array.isArray(params?.items) ? params.items.map(() => null) : []);
|
|
453
479
|
connection.onRequest("workspace/workspaceFolders", () => [{
|
|
@@ -502,53 +528,6 @@ async function workspaceSourcePath(workspaceRoot, inputPath) {
|
|
|
502
528
|
throw new CodeIntelligenceError("code.language_service_unavailable", `Unable to read code-intelligence source ${inputPath}: ${errorMessage(error)}`);
|
|
503
529
|
}
|
|
504
530
|
}
|
|
505
|
-
async function withTimeout(promise, timeoutMs, timeoutError) {
|
|
506
|
-
let timer;
|
|
507
|
-
try {
|
|
508
|
-
return await Promise.race([
|
|
509
|
-
promise,
|
|
510
|
-
new Promise((_resolve, reject) => {
|
|
511
|
-
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
|
512
|
-
timer.unref();
|
|
513
|
-
}),
|
|
514
|
-
]);
|
|
515
|
-
}
|
|
516
|
-
finally {
|
|
517
|
-
if (timer)
|
|
518
|
-
clearTimeout(timer);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
async function waitForChildSpawn(child, serverId) {
|
|
522
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
523
|
-
const onSpawn = () => {
|
|
524
|
-
child.off("error", onError);
|
|
525
|
-
resolvePromise();
|
|
526
|
-
};
|
|
527
|
-
const onError = (error) => {
|
|
528
|
-
child.off("spawn", onSpawn);
|
|
529
|
-
rejectPromise(new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${serverId}: ${error.message}.`));
|
|
530
|
-
};
|
|
531
|
-
child.once("spawn", onSpawn);
|
|
532
|
-
child.once("error", onError);
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
async function waitForChildExit(child, timeoutMs) {
|
|
536
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
537
|
-
return true;
|
|
538
|
-
return new Promise((resolvePromise) => {
|
|
539
|
-
let timer;
|
|
540
|
-
const finish = (exited) => {
|
|
541
|
-
child.off("exit", onExit);
|
|
542
|
-
if (timer)
|
|
543
|
-
clearTimeout(timer);
|
|
544
|
-
resolvePromise(exited);
|
|
545
|
-
};
|
|
546
|
-
const onExit = () => finish(true);
|
|
547
|
-
child.once("exit", onExit);
|
|
548
|
-
timer = setTimeout(() => finish(false), timeoutMs);
|
|
549
|
-
timer.unref();
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
531
|
function errorMessage(error) {
|
|
553
532
|
return error instanceof Error ? error.message : String(error);
|
|
554
533
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { realpath } from "node:fs/promises";
|
|
3
3
|
import { resolve, sep } from "node:path";
|
|
4
|
+
import { diagnosticError } from "../../runtime/logging/runtime-diagnostics.js";
|
|
4
5
|
import { CodeIntelligenceError, LanguageService, languageServiceKey, } from "../code-intelligence.js";
|
|
5
6
|
import { LanguageServerConfigurationError, resolveLanguageProject, } from "../language-server-config.js";
|
|
6
7
|
import { managedLanguageServerIdForCommand, managedLanguageServerRuntimeIdentity, managedTypeScriptTsserverPath, withManagedLanguageServerPath, } from "./managed-language-servers.js";
|
|
@@ -24,6 +25,9 @@ export class CodeIntelligenceManager {
|
|
|
24
25
|
crashStates = new Map();
|
|
25
26
|
retiredWorkspaceRoots = new Set();
|
|
26
27
|
serviceCreationQueue = Promise.resolve();
|
|
28
|
+
activeRuns = 0;
|
|
29
|
+
runDrainWaiters = new Set();
|
|
30
|
+
shutdownPromise;
|
|
27
31
|
cleanupTimer;
|
|
28
32
|
policy;
|
|
29
33
|
crashCooldownMs;
|
|
@@ -33,6 +37,7 @@ export class CodeIntelligenceManager {
|
|
|
33
37
|
this.projectExecutionTrustPolicy = options.projectExecutionTrustPolicy ?? compatibilityAllowProjectExecutionTrustPolicy;
|
|
34
38
|
this.crashCooldownMs = positiveInteger(options.crashCooldownMs, LANGUAGE_SERVICE_CRASH_COOLDOWN_MS, "crashCooldownMs");
|
|
35
39
|
this.policy = {
|
|
40
|
+
diagnostics: options.diagnostics,
|
|
36
41
|
idleMs: positiveInteger(options.idleMs, LANGUAGE_SERVICE_IDLE_MS, "idleMs"),
|
|
37
42
|
cleanupIntervalMs: positiveInteger(options.cleanupIntervalMs, LANGUAGE_SERVICE_CLEANUP_INTERVAL_MS, "cleanupIntervalMs"),
|
|
38
43
|
maxServices: positiveInteger(options.maxServices, MAX_LANGUAGE_SERVICES, "maxServices"),
|
|
@@ -50,6 +55,23 @@ export class CodeIntelligenceManager {
|
|
|
50
55
|
this.cleanupTimer.unref();
|
|
51
56
|
}
|
|
52
57
|
async run(workspaceRoot, input, options = {}) {
|
|
58
|
+
if (this.shutdownPromise) {
|
|
59
|
+
throw new CodeIntelligenceError("code.language_service_unavailable", "Code intelligence is shutting down.");
|
|
60
|
+
}
|
|
61
|
+
this.activeRuns += 1;
|
|
62
|
+
try {
|
|
63
|
+
return await this.runActive(workspaceRoot, input, options);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
this.activeRuns = Math.max(0, this.activeRuns - 1);
|
|
67
|
+
if (this.activeRuns === 0) {
|
|
68
|
+
for (const resolveDrain of this.runDrainWaiters)
|
|
69
|
+
resolveDrain();
|
|
70
|
+
this.runDrainWaiters.clear();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async runActive(workspaceRoot, input, options) {
|
|
53
75
|
const canonicalWorkspaceRoot = await realpath(resolve(workspaceRoot));
|
|
54
76
|
this.assertWorkspaceRootAvailable(canonicalWorkspaceRoot);
|
|
55
77
|
let project;
|
|
@@ -89,6 +111,7 @@ export class CodeIntelligenceManager {
|
|
|
89
111
|
return result;
|
|
90
112
|
}
|
|
91
113
|
catch (error) {
|
|
114
|
+
service.recordDiagnostic("lsp_operation_failed", { operation: input.operation, ...diagnosticError(error) });
|
|
92
115
|
if (!(error instanceof CodeIntelligenceError) || error.code !== "code.server_crashed") {
|
|
93
116
|
throw error;
|
|
94
117
|
}
|
|
@@ -113,7 +136,12 @@ export class CodeIntelligenceManager {
|
|
|
113
136
|
throw lastCrash ?? new CodeIntelligenceError("code.server_crashed", `Language server ${project.definition.id} failed without a recoverable result.`);
|
|
114
137
|
}
|
|
115
138
|
async shutdown() {
|
|
139
|
+
this.shutdownPromise ??= this.performShutdown();
|
|
140
|
+
return this.shutdownPromise;
|
|
141
|
+
}
|
|
142
|
+
async performShutdown() {
|
|
116
143
|
clearInterval(this.cleanupTimer);
|
|
144
|
+
await this.waitForActiveRuns();
|
|
117
145
|
await Promise.allSettled(this.serviceCreations.values());
|
|
118
146
|
this.serviceCreations.clear();
|
|
119
147
|
const services = [...this.services.values()];
|
|
@@ -121,7 +149,14 @@ export class CodeIntelligenceManager {
|
|
|
121
149
|
this.invalidatedServiceKeys.clear();
|
|
122
150
|
this.crashStates.clear();
|
|
123
151
|
this.retiredWorkspaceRoots.clear();
|
|
124
|
-
await Promise.allSettled(services.map((service) => service.shutdown()));
|
|
152
|
+
await Promise.allSettled(services.map((service) => service.shutdown("server_shutdown")));
|
|
153
|
+
}
|
|
154
|
+
waitForActiveRuns() {
|
|
155
|
+
if (this.activeRuns === 0)
|
|
156
|
+
return Promise.resolve();
|
|
157
|
+
return new Promise((resolveDrain) => {
|
|
158
|
+
this.runDrainWaiters.add(resolveDrain);
|
|
159
|
+
});
|
|
125
160
|
}
|
|
126
161
|
get size() {
|
|
127
162
|
return this.services.size;
|
|
@@ -176,7 +211,7 @@ export class CodeIntelligenceManager {
|
|
|
176
211
|
this.services.delete(key);
|
|
177
212
|
this.invalidatedServiceKeys.delete(key);
|
|
178
213
|
this.crashStates.delete(key);
|
|
179
|
-
await service.shutdown();
|
|
214
|
+
await service.shutdown("workspace_retired");
|
|
180
215
|
releasedServices += 1;
|
|
181
216
|
}
|
|
182
217
|
this.clearIdentityStateForWorkspaceRoot(canonicalRoot);
|
|
@@ -242,10 +277,11 @@ export class CodeIntelligenceManager {
|
|
|
242
277
|
invalidated.push([key, service]);
|
|
243
278
|
}
|
|
244
279
|
for (const [key, service] of invalidated) {
|
|
245
|
-
if (this.services.get(key)
|
|
246
|
-
|
|
280
|
+
if (this.services.get(key) !== service || !service.isIdle)
|
|
281
|
+
continue;
|
|
282
|
+
this.services.delete(key);
|
|
247
283
|
this.invalidatedServiceKeys.delete(key);
|
|
248
|
-
await service.shutdown();
|
|
284
|
+
await service.shutdown("configuration_unavailable");
|
|
249
285
|
}
|
|
250
286
|
}
|
|
251
287
|
async invalidateChangedServices(project) {
|
|
@@ -262,10 +298,11 @@ export class CodeIntelligenceManager {
|
|
|
262
298
|
}
|
|
263
299
|
}
|
|
264
300
|
for (const [key, service] of invalidated) {
|
|
265
|
-
if (this.services.get(key)
|
|
266
|
-
|
|
301
|
+
if (this.services.get(key) !== service || !service.isIdle)
|
|
302
|
+
continue;
|
|
303
|
+
this.services.delete(key);
|
|
267
304
|
this.invalidatedServiceKeys.delete(key);
|
|
268
|
-
await service.shutdown();
|
|
305
|
+
await service.shutdown("configuration_changed");
|
|
269
306
|
}
|
|
270
307
|
}
|
|
271
308
|
async releaseService(service) {
|
|
@@ -275,13 +312,13 @@ export class CodeIntelligenceManager {
|
|
|
275
312
|
if (this.services.get(service.key) === service)
|
|
276
313
|
this.services.delete(service.key);
|
|
277
314
|
this.invalidatedServiceKeys.delete(service.key);
|
|
278
|
-
await service.shutdown();
|
|
315
|
+
await service.shutdown("invalidated");
|
|
279
316
|
}
|
|
280
317
|
async discardService(service) {
|
|
281
318
|
if (this.services.get(service.key) === service)
|
|
282
319
|
this.services.delete(service.key);
|
|
283
320
|
this.invalidatedServiceKeys.delete(service.key);
|
|
284
|
-
await service.shutdown();
|
|
321
|
+
await service.shutdown("crashed");
|
|
285
322
|
}
|
|
286
323
|
async acquireService(workspaceRoot, project) {
|
|
287
324
|
const key = languageServiceKey(project);
|
|
@@ -335,8 +372,13 @@ export class CodeIntelligenceManager {
|
|
|
335
372
|
async closeIdle(now = Date.now()) {
|
|
336
373
|
const stale = [...this.services.entries()].filter(([, service]) => service.isIdle && now - service.lastUsedAt >= this.policy.idleMs);
|
|
337
374
|
for (const [key, service] of stale) {
|
|
375
|
+
if (this.services.get(key) !== service ||
|
|
376
|
+
!service.isIdle ||
|
|
377
|
+
now - service.lastUsedAt < this.policy.idleMs) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
338
380
|
this.services.delete(key);
|
|
339
|
-
await service.shutdown();
|
|
381
|
+
await service.shutdown("idle_timeout");
|
|
340
382
|
}
|
|
341
383
|
}
|
|
342
384
|
async ensureCapacity() {
|
|
@@ -350,7 +392,7 @@ export class CodeIntelligenceManager {
|
|
|
350
392
|
throw new CodeIntelligenceError("code.language_service_capacity", `Language service capacity reached (${this.policy.maxServices}) with no idle service available for eviction.`);
|
|
351
393
|
}
|
|
352
394
|
this.services.delete(candidate[0]);
|
|
353
|
-
await candidate[1].shutdown();
|
|
395
|
+
await candidate[1].shutdown("capacity_limit");
|
|
354
396
|
}
|
|
355
397
|
}
|
|
356
398
|
function withManagedLanguageServerRuntime(project, configDir) {
|
|
@@ -1,3 +1,51 @@
|
|
|
1
|
+
import { CodeIntelligenceError } from "../code-intelligence-error.js";
|
|
2
|
+
export async function withTimeout(promise, timeoutMs, timeoutError) {
|
|
3
|
+
let timer;
|
|
4
|
+
try {
|
|
5
|
+
return await Promise.race([
|
|
6
|
+
promise,
|
|
7
|
+
new Promise((_resolve, reject) => {
|
|
8
|
+
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
|
9
|
+
timer.unref();
|
|
10
|
+
}),
|
|
11
|
+
]);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
if (timer)
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function waitForChildSpawn(child, serverId) {
|
|
19
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
20
|
+
const onSpawn = () => {
|
|
21
|
+
child.off("error", onError);
|
|
22
|
+
resolvePromise();
|
|
23
|
+
};
|
|
24
|
+
const onError = (error) => {
|
|
25
|
+
child.off("spawn", onSpawn);
|
|
26
|
+
rejectPromise(new CodeIntelligenceError("code.language_service_start_failed", `Unable to start Language server ${serverId}: ${error.message}.`));
|
|
27
|
+
};
|
|
28
|
+
child.once("spawn", onSpawn);
|
|
29
|
+
child.once("error", onError);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
export async function waitForChildExit(child, timeoutMs) {
|
|
33
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
34
|
+
return true;
|
|
35
|
+
return new Promise((resolvePromise) => {
|
|
36
|
+
let timer;
|
|
37
|
+
const finish = (exited) => {
|
|
38
|
+
child.off("exit", onExit);
|
|
39
|
+
if (timer)
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolvePromise(exited);
|
|
42
|
+
};
|
|
43
|
+
const onExit = () => finish(true);
|
|
44
|
+
child.once("exit", onExit);
|
|
45
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
46
|
+
timer.unref();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
1
49
|
export function languageServerCommandRequiresShell(command, platform = process.platform) {
|
|
2
50
|
return platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
|
|
3
51
|
}
|
|
@@ -11,6 +11,7 @@ import { ActivityQueryService } from "../../../activity/history/query-service.js
|
|
|
11
11
|
import { CodeIntelligenceManager } from "../../../lsp/runtime/manager.js";
|
|
12
12
|
import { createOpenAIIncomingArtifactAdapter } from "../../artifacts/incoming-artifacts.js";
|
|
13
13
|
import { logEvent, requestPath, transportSessionIdPrefix } from "../../../runtime/logging/logger.js";
|
|
14
|
+
import { RuntimeDiagnostics } from "../../../runtime/logging/runtime-diagnostics.js";
|
|
14
15
|
import { SingleUserOAuthProvider } from "../../oauth/oauth-provider.js";
|
|
15
16
|
import { createForgeRelayAuthRouter } from "../../oauth/router.js";
|
|
16
17
|
import { publicEndpointPaths, publicEndpointUrl } from "../../oauth/public-url.js";
|
|
@@ -80,7 +81,8 @@ export function createHttpServer(config, options, createMcpServer) {
|
|
|
80
81
|
outputAudit: bashOutputStore,
|
|
81
82
|
commandShellRuntime: config.commandShellRuntime,
|
|
82
83
|
});
|
|
83
|
-
const
|
|
84
|
+
const diagnostics = options.runtimeDiagnostics ?? new RuntimeDiagnostics(config.stateDir);
|
|
85
|
+
const codeIntelligence = new CodeIntelligenceManager(config, { diagnostics });
|
|
84
86
|
const subagentProviders = config.subagents
|
|
85
87
|
? getSubagentProviderAvailabilitySnapshot()
|
|
86
88
|
: [];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { closeSync, constants, fchmodSync, fstatSync, mkdirSync, openSync, renameSync, statSync, unlinkSync, writeSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
/** Keep error identities and call sites, without persisting messages or server stderr. */
|
|
4
|
+
export function diagnosticError(error) {
|
|
5
|
+
if (!(error instanceof Error))
|
|
6
|
+
return { errorName: "NonError" };
|
|
7
|
+
return {
|
|
8
|
+
errorName: error.name,
|
|
9
|
+
errorCode: "code" in error && typeof error.code === "string" ? error.code : undefined,
|
|
10
|
+
stack: error.stack?.split("\n").filter((line) => /^\s+at /.test(line)).slice(0, 16),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Synchronous writes retain fatal-event evidence even when Node exits immediately afterwards. */
|
|
14
|
+
export class RuntimeDiagnostics {
|
|
15
|
+
maxBytes;
|
|
16
|
+
path;
|
|
17
|
+
warned = false;
|
|
18
|
+
constructor(stateDir, maxBytes = 4 * 1024 * 1024) {
|
|
19
|
+
this.maxBytes = maxBytes;
|
|
20
|
+
this.path = join(stateDir, "logs", "runtime.jsonl");
|
|
21
|
+
}
|
|
22
|
+
record(event, fields = {}) {
|
|
23
|
+
let fd;
|
|
24
|
+
try {
|
|
25
|
+
const bounded = JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, event, ...fields }, (_key, value) => typeof value === "string" ? value.slice(0, 1024) : value);
|
|
26
|
+
const line = Buffer.from(`${bounded}\n`);
|
|
27
|
+
if (line.length > this.maxBytes)
|
|
28
|
+
throw new Error("Diagnostic entry exceeds retention limit.");
|
|
29
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
30
|
+
let size = 0;
|
|
31
|
+
try {
|
|
32
|
+
size = statSync(this.path).size;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (size + line.length > this.maxBytes)
|
|
39
|
+
this.rotate();
|
|
40
|
+
fd = openSync(this.path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND |
|
|
41
|
+
(constants.O_NOFOLLOW ?? 0), 0o600);
|
|
42
|
+
if (!fstatSync(fd).isFile())
|
|
43
|
+
throw new Error("Runtime diagnostic destination must be a regular file.");
|
|
44
|
+
fchmodSync(fd, 0o600);
|
|
45
|
+
let offset = 0;
|
|
46
|
+
while (offset < line.length)
|
|
47
|
+
offset += writeSync(fd, line, offset, line.length - offset);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
if (!this.warned) {
|
|
51
|
+
this.warned = true;
|
|
52
|
+
console.error("ForgeRelay could not persist runtime diagnostics; check state-directory permissions and disk space.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
if (fd !== undefined) {
|
|
57
|
+
try {
|
|
58
|
+
closeSync(fd);
|
|
59
|
+
}
|
|
60
|
+
catch { /* Logging must never replace the original failure. */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
rotate() {
|
|
65
|
+
for (let index = 3; index >= 0; index -= 1) {
|
|
66
|
+
const path = index === 0 ? this.path : `${this.path}.${index}`;
|
|
67
|
+
try {
|
|
68
|
+
if (index === 3)
|
|
69
|
+
unlinkSync(path);
|
|
70
|
+
else
|
|
71
|
+
renameSync(path, `${this.path}.${index + 1}`);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Observes fatal errors without converting an unhandled rejection into a recoverable event. */
|
|
81
|
+
export function observeRuntimeExit(diagnostics) {
|
|
82
|
+
const onFatal = (error, origin) => {
|
|
83
|
+
diagnostics.record("runtime_uncaught_exception", { reason: origin, ...diagnosticError(error) });
|
|
84
|
+
};
|
|
85
|
+
const onExit = (exitCode) => diagnostics.record("runtime_exit", { exitCode });
|
|
86
|
+
process.on("uncaughtExceptionMonitor", onFatal);
|
|
87
|
+
process.on("exit", onExit);
|
|
88
|
+
return () => {
|
|
89
|
+
process.off("uncaughtExceptionMonitor", onFatal);
|
|
90
|
+
process.off("exit", onExit);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -87,7 +87,7 @@ export async function fixture(t, options = {}) {
|
|
|
87
87
|
turnId: () => `turn_test_${++turnSequence}`,
|
|
88
88
|
turnIdForConversation: (conversationScopeId, workspaceId) => activityQueries.currentTurnId(conversationScopeId, workspaceId),
|
|
89
89
|
});
|
|
90
|
-
const codeIntelligence = new CodeIntelligenceManager(config);
|
|
90
|
+
const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
|
|
91
91
|
const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], options.incomingArtifactAdapters ?? [], codeIntelligence, activityLifecycle, bashOutputStore, activityQueries);
|
|
92
92
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
93
93
|
const client = new Client({ name: "forgerelay-test-client", version: "1.0.0" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6-beta.1",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"release:push-ready": "node scripts/release/push-ready.mjs",
|
|
69
69
|
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
|
|
70
70
|
"start": "node dist/cli.js serve",
|
|
71
|
-
"test": "node --test scripts/debug/runtime.test.mjs scripts/config/schema-text.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/workspaces/relay/tests/composite.test.ts && tsx src/runtime/config/definition/schema.test.ts && tsx src/runtime/config/resolution/resolver.test.ts && tsx src/runtime/security/project-execution-trust.test.ts && tsx src/runtime/config/resolution/hooks.test.ts && tsx src/workspaces/state/project-context.test.ts && tsx src/runtime/config/config.test.ts && tsx src/cli/config/general.test.ts && tsx src/cli/config/domains/domain-cli.test.ts && node --test --import tsx src/cli/config/domains/context-cli.test.ts && node --test --import tsx src/cli/config/inspect.test.ts src/cli/config/migrate.test.ts && tsx src/runtime/config/external-mcp-registry.test.ts && tsx src/runtime/config/external-mcp-auth-store.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-oauth.test.ts && tsx src/cli/mcp/status.test.ts && tsx src/cli/mcp/diagnostics.test.ts && tsx src/cli/mcp/external-mcp.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/operations/project-trust.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/cli/system/status.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/hooks/hooks-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-transform.test.ts && tsx src/mcp/hooks/external-mcp-transform-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-trust.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-context.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/project-trust.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
|
|
71
|
+
"test": "node --test scripts/debug/runtime.test.mjs scripts/config/schema-text.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/workspaces/relay/tests/composite.test.ts && tsx src/runtime/config/definition/schema.test.ts && tsx src/runtime/config/resolution/resolver.test.ts && tsx src/runtime/security/project-execution-trust.test.ts && tsx src/runtime/config/resolution/hooks.test.ts && tsx src/workspaces/state/project-context.test.ts && tsx src/runtime/config/config.test.ts && tsx src/cli/config/general.test.ts && tsx src/cli/config/domains/domain-cli.test.ts && node --test --import tsx src/cli/config/domains/context-cli.test.ts && node --test --import tsx src/cli/config/inspect.test.ts src/cli/config/migrate.test.ts && tsx src/runtime/config/external-mcp-registry.test.ts && tsx src/runtime/config/external-mcp-auth-store.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-oauth.test.ts && tsx src/cli/mcp/status.test.ts && tsx src/cli/mcp/diagnostics.test.ts && tsx src/cli/mcp/external-mcp.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/operations/project-trust.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/runtime-diagnostics.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/cli/system/status.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/hooks/hooks-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-transform.test.ts && tsx src/mcp/hooks/external-mcp-transform-trust.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-trust.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-context.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/mutation-diagnostics-stress.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/project-trust.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
|
|
72
72
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
73
73
|
"release:check": "node scripts/release-version.mjs check",
|
|
74
74
|
"release:tag-check": "node scripts/release-version.mjs tag",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
4
|
+
cd "$repo_root"
|
|
5
|
+
mkdir -p logs
|
|
6
|
+
node --input-type=module <<'JS' 2>&1 | tee logs/lsp-beta-dev.log
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { relative, resolve, sep } from "node:path";
|
|
10
|
+
import { createInteractiveDebugEnvironment, debugRoot } from "./scripts/debug/runtime.mjs";
|
|
11
|
+
import { loadConfig } from "./dist/runtime/config/config.js";
|
|
12
|
+
|
|
13
|
+
const { configDir, env } = createInteractiveDebugEnvironment();
|
|
14
|
+
const config = loadConfig(env);
|
|
15
|
+
assert.equal(config.port, 7677, "Beta acceptance must use the dedicated dev port 7677.");
|
|
16
|
+
assert.equal(config.host, "127.0.0.1", "Beta acceptance must bind to loopback.");
|
|
17
|
+
const stateRelative = relative(resolve(debugRoot), resolve(config.stateDir));
|
|
18
|
+
assert.ok(stateRelative !== ".." && !stateRelative.startsWith(`..${sep}`) && !stateRelative.startsWith(sep),
|
|
19
|
+
"Beta acceptance state must stay under .forgerelay-debug/.");
|
|
20
|
+
console.error(`[forgerelay:beta] config: ${configDir}; isolated state: ${config.stateDir}`);
|
|
21
|
+
const child = spawn(process.execPath, ["dist/cli.js", "serve"], { env, stdio: "inherit" });
|
|
22
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
|
|
23
|
+
child.on("error", (error) => { console.error(error); process.exitCode = 1; });
|
|
24
|
+
child.on("exit", (code, signal) => {
|
|
25
|
+
process.exitCode = code ?? (signal ? 1 : 0);
|
|
26
|
+
});
|
|
27
|
+
JS
|
package/scripts/lsp/interop.mjs
CHANGED
|
@@ -113,7 +113,8 @@ for (const fixture of fixtures) {
|
|
|
113
113
|
console.log(`SKIP ${fixture.id}: ${fixture.command} not found on PATH; ForgeRelay does not install Language servers.`);
|
|
114
114
|
continue;
|
|
115
115
|
}
|
|
116
|
-
|
|
116
|
+
// pyright-langserver has no --version mode; verify it with the real LSP requests below.
|
|
117
|
+
const preflight = fixture.id === "pyright" ? { available: true } : probeExecutable(executable);
|
|
117
118
|
if (!preflight.available) {
|
|
118
119
|
skipped += 1;
|
|
119
120
|
console.log(`SKIP ${fixture.id}: ${executable} is present but not runnable: ${preflight.reason}`);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
10
|
+
import { createMcpServer } from "../../src/server.js";
|
|
11
|
+
import { createHttpServer } from "../../src/mcp/server/transport/http-server.js";
|
|
12
|
+
import { loadConfig } from "../../src/runtime/config/config.js";
|
|
13
|
+
import type { CodeIntelligenceManager } from "../../src/lsp/runtime/manager.js";
|
|
14
|
+
|
|
15
|
+
// A disposable dev HTTP instance; never reuse installed configuration or state.
|
|
16
|
+
const base = "http://127.0.0.1:7677";
|
|
17
|
+
const debugRoot = resolve(".forgerelay-debug");
|
|
18
|
+
await mkdir(debugRoot, { recursive: true });
|
|
19
|
+
const root = await mkdtemp(join(debugRoot, "mutation-soak-"));
|
|
20
|
+
const configDir = join(root, "config");
|
|
21
|
+
const project = join(root, "project");
|
|
22
|
+
await mkdir(configDir);
|
|
23
|
+
await mkdir(join(project, ".forgerelay"), { recursive: true });
|
|
24
|
+
// Prevent discovery from inheriting the enclosing ForgeRelay Git project.
|
|
25
|
+
await promisify(execFile)("git", ["init", "--quiet", project]);
|
|
26
|
+
await writeFile(join(configDir, "config.json"), JSON.stringify({
|
|
27
|
+
host: "127.0.0.1", port: 7677, publicBaseUrl: base,
|
|
28
|
+
allowedRoots: [root], stateDir: join(root, "state"), worktreeRoot: join(root, "worktrees"),
|
|
29
|
+
agentDir: join(root, "agent"), subagents: false,
|
|
30
|
+
}));
|
|
31
|
+
const ownerToken = randomBytes(32).toString("base64url");
|
|
32
|
+
const config = loadConfig({
|
|
33
|
+
PATH: process.env.PATH,
|
|
34
|
+
FORGERELAY_CONFIG_DIR: configDir,
|
|
35
|
+
FORGERELAY_OAUTH_OWNER_TOKEN: ownerToken,
|
|
36
|
+
FORGERELAY_TOOL_MODE: "full",
|
|
37
|
+
FORGERELAY_WIDGETS: "off",
|
|
38
|
+
FORGERELAY_SKILLS: "0",
|
|
39
|
+
FORGERELAY_LOG_SHELL_COMMANDS: "0",
|
|
40
|
+
});
|
|
41
|
+
assert.equal(config.port, 7677);
|
|
42
|
+
assert.equal(config.stateDir, join(root, "state"));
|
|
43
|
+
let manager: CodeIntelligenceManager | undefined;
|
|
44
|
+
const diagnostics = new Map<string, number>();
|
|
45
|
+
const runtime = createHttpServer(config, {}, (...args) => {
|
|
46
|
+
if (!manager) {
|
|
47
|
+
manager = args[6];
|
|
48
|
+
const run = manager.run.bind(manager);
|
|
49
|
+
manager.run = async (...input) => {
|
|
50
|
+
const result = await run(...input);
|
|
51
|
+
if (result.operation === "diagnostics") {
|
|
52
|
+
diagnostics.set(result.selectedServer, (diagnostics.get(result.selectedServer) ?? 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return createMcpServer(...args);
|
|
58
|
+
});
|
|
59
|
+
const http = runtime.app.listen(7677, "127.0.0.1");
|
|
60
|
+
// EADDRINUSE fails the run; this script never stops an existing listener.
|
|
61
|
+
await once(http, "listening");
|
|
62
|
+
const client = new Client({ name: "mutation-soak", version: "1" }, {
|
|
63
|
+
versionNegotiation: { mode: "legacy" },
|
|
64
|
+
});
|
|
65
|
+
type ToolInput =
|
|
66
|
+
| { path: string; context?: "full" }
|
|
67
|
+
| { workspaceId: string; path: string; content: string }
|
|
68
|
+
| { workspaceId: string; path: string; edits: Array<{ oldText: string; newText: string }> };
|
|
69
|
+
let mutations = 0;
|
|
70
|
+
try {
|
|
71
|
+
const auth = await fetch(`${base}/auth/cli`, {
|
|
72
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
73
|
+
body: JSON.stringify({ owner_token: ownerToken }),
|
|
74
|
+
});
|
|
75
|
+
assert.equal(auth.status, 200);
|
|
76
|
+
const tokens = await auth.json() as { access_token: string };
|
|
77
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`), {
|
|
78
|
+
requestInit: { headers: { Authorization: `Bearer ${tokens.access_token}` } },
|
|
79
|
+
}));
|
|
80
|
+
const call = async (name: string, input: ToolInput, signal?: AbortSignal) => {
|
|
81
|
+
const result = await client.callTool({
|
|
82
|
+
name, arguments: input, _meta: { "openai/session": "mutation-soak" },
|
|
83
|
+
}, { signal, timeout: 30_000 });
|
|
84
|
+
assert.notEqual(result.isError, true, `${name} failed`);
|
|
85
|
+
return result;
|
|
86
|
+
};
|
|
87
|
+
const opened = await call("open_workspace", { path: project, context: "full" });
|
|
88
|
+
const workspaceId = (opened.structuredContent as { workspaceId: string }).workspaceId;
|
|
89
|
+
assert.ok(manager);
|
|
90
|
+
const cleanup = () => (manager as unknown as { closeIdle(now: number): Promise<void> })
|
|
91
|
+
.closeIdle(Date.now() + 3_600_000);
|
|
92
|
+
const pyright = join(homedir(), ".forgerelay/language-servers/node_modules/.bin/pyright-langserver");
|
|
93
|
+
const fake = resolve("src/lsp/test-fixtures/fake-lsp-server.mjs");
|
|
94
|
+
const cancellationLog = join(root, "cancellation-lsp.jsonl");
|
|
95
|
+
const languageConfig = (generation: number) => ({
|
|
96
|
+
pyright: {
|
|
97
|
+
command: pyright, args: ["--stdio"],
|
|
98
|
+
languages: ["python"], extensions: [".py"], projectMarkers: ["pyrightconfig.json"],
|
|
99
|
+
env: { FORGERELAY_SOAK_GENERATION: String(generation) },
|
|
100
|
+
},
|
|
101
|
+
capacity: {
|
|
102
|
+
command: process.execPath, args: [fake],
|
|
103
|
+
languages: ["typescript"], extensions: [".cap"], projectMarkers: ["capacity.project"],
|
|
104
|
+
env: { FORGERELAY_FAKE_LSP_DIAGNOSTICS_MODE: "pull" },
|
|
105
|
+
},
|
|
106
|
+
cancellation: {
|
|
107
|
+
command: process.execPath, args: [fake],
|
|
108
|
+
languages: ["typescript"], extensions: [".cancel"], projectMarkers: [],
|
|
109
|
+
env: {
|
|
110
|
+
FORGERELAY_FAKE_LSP_DIAGNOSTICS_MODE: "pull",
|
|
111
|
+
FORGERELAY_FAKE_LSP_DIAGNOSTIC_DELAY_MS: "1000",
|
|
112
|
+
FORGERELAY_FAKE_LSP_LOG: cancellationLog,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const definitions = join(project, ".forgerelay/language-servers.json");
|
|
117
|
+
await writeFile(definitions, JSON.stringify(languageConfig(0)));
|
|
118
|
+
for (let i = 0; i < 3; i++) {
|
|
119
|
+
await mkdir(join(project, `python-${i}`));
|
|
120
|
+
await writeFile(join(project, `python-${i}/pyrightconfig.json`), "{}");
|
|
121
|
+
}
|
|
122
|
+
const verify = async (result: Awaited<ReturnType<typeof call>>, diagnostic: RegExp) => {
|
|
123
|
+
const text = result.content.filter(c => c.type === "text").map(c => c.text).join("\n");
|
|
124
|
+
if (diagnostic.source !== "pyright") assert.match(text, diagnostic);
|
|
125
|
+
assert.doesNotMatch(text, /diagnostics warning|SIGTERM|start_failed|server_crashed|failed internally/i);
|
|
126
|
+
mutations++;
|
|
127
|
+
};
|
|
128
|
+
for (let round = 0; round < 30; round++) {
|
|
129
|
+
await Promise.all([0, 1, 2].map(async i => {
|
|
130
|
+
const path = `python-${i}/main.py`;
|
|
131
|
+
const oldText = `value: int = "round-${round}"`;
|
|
132
|
+
await verify(await call("write", { workspaceId, path, content: `${oldText}\n` }), /pyright/);
|
|
133
|
+
await verify(await call("edit", {
|
|
134
|
+
workspaceId, path, edits: [{ oldText, newText: `value: int = "edited-${round}"` }],
|
|
135
|
+
}), /pyright/);
|
|
136
|
+
}));
|
|
137
|
+
if (round % 5 === 4) {
|
|
138
|
+
await writeFile(definitions, JSON.stringify(languageConfig(round)));
|
|
139
|
+
await cleanup();
|
|
140
|
+
}
|
|
141
|
+
assert.equal(diagnostics.get("pyright"), mutations, "each Python mutation must finish real diagnostics");
|
|
142
|
+
if (round % 10 === 9) console.log(`PASS real Pyright: ${round + 1} rounds, ${mutations} mutations`);
|
|
143
|
+
}
|
|
144
|
+
assert.equal(diagnostics.get("pyright"), 180, "every Python mutation must complete real Pyright diagnostics");
|
|
145
|
+
// Default capacity is sixteen services. Distinct project roots force eviction.
|
|
146
|
+
for (let i = 0; i < 18; i++) {
|
|
147
|
+
await mkdir(join(project, `capacity-${i}`));
|
|
148
|
+
await writeFile(join(project, `capacity-${i}/capacity.project`), "");
|
|
149
|
+
await verify(await call("write", {
|
|
150
|
+
workspaceId, path: `capacity-${i}/main.cap`, content: "const value = 1;\n",
|
|
151
|
+
}), /pulled diagnostic/);
|
|
152
|
+
}
|
|
153
|
+
assert.ok(manager.stats().servicesTotal <= 16);
|
|
154
|
+
assert.equal(diagnostics.get("capacity"), 18);
|
|
155
|
+
await verify(await call("write", {
|
|
156
|
+
workspaceId, path: "main.cancel", content: "const value = 1;\n",
|
|
157
|
+
}), /pulled diagnostic/);
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const pendingEdit = call("edit", {
|
|
160
|
+
workspaceId, path: "main.cancel", edits: [{ oldText: "1", newText: "2" }],
|
|
161
|
+
}, controller.signal);
|
|
162
|
+
const cancelled = assert.rejects(pendingEdit, /abort|cancel/i);
|
|
163
|
+
const deadline = Date.now() + 10_000;
|
|
164
|
+
while (true) {
|
|
165
|
+
const log = await readFile(cancellationLog, "utf8");
|
|
166
|
+
if (log.split('"method":"textDocument/diagnostic"').length >= 3) break;
|
|
167
|
+
assert.ok(Date.now() < deadline, "cancelled edit must reach automatic diagnostics");
|
|
168
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 10));
|
|
169
|
+
}
|
|
170
|
+
controller.abort();
|
|
171
|
+
await cancelled;
|
|
172
|
+
assert.match(await readFile(join(project, "main.cancel"), "utf8"), /value = 2/);
|
|
173
|
+
await verify(await call("edit", {
|
|
174
|
+
workspaceId, path: "main.cancel", edits: [{ oldText: "2", newText: "3" }],
|
|
175
|
+
}), /pulled diagnostic/);
|
|
176
|
+
await cleanup();
|
|
177
|
+
assert.equal(manager.stats().servicesTotal, 0);
|
|
178
|
+
const health = await fetch(`${base}/healthz`);
|
|
179
|
+
assert.equal(health.status, 200);
|
|
180
|
+
console.log(`PASS dev HTTP/MCP soak: ${mutations} verified mutations + cancellation; state ${root}`);
|
|
181
|
+
await writeFile(join(root, "result.json"), JSON.stringify({ mutations, passed: true }));
|
|
182
|
+
} finally {
|
|
183
|
+
await client.close();
|
|
184
|
+
await runtime.close();
|
|
185
|
+
await new Promise<void>((resolveClose, reject) => http.close(error => error ? reject(error) : resolveClose()));
|
|
186
|
+
}
|
|
@@ -40,7 +40,7 @@ if (basename(packagePath) !== expectedPackageName) {
|
|
|
40
40
|
throw new Error(`unexpected release artifact ${basename(packagePath)}; expected ${expectedPackageName}`);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const npmTag = pkg.version
|
|
43
|
+
const npmTag = /-(?:rc|beta)\.\d+$/.test(pkg.version) ? "next" : "latest";
|
|
44
44
|
const packageSpec = `${pkg.name}@${pkg.version}`;
|
|
45
45
|
if (npmPackageExists(packageSpec)) {
|
|
46
46
|
console.log(`${packageSpec} is already published; leaving npm unchanged.`);
|
|
@@ -110,10 +110,22 @@ test("release version tooling prepares and validates an rc release", async (t) =
|
|
|
110
110
|
test("release version tooling rejects unsupported prerelease identifiers", async (t) => {
|
|
111
111
|
const root = await createFixture(t);
|
|
112
112
|
await assert.rejects(
|
|
113
|
-
() => runRelease(root, "prepare", "0.6.0-
|
|
113
|
+
() => runRelease(root, "prepare", "0.6.0-alpha.1"),
|
|
114
114
|
(error) => {
|
|
115
|
-
assert.match(String(error.stderr ?? error), /prepare X\.Y\.Z\[-rc\.N\]/);
|
|
115
|
+
assert.match(String(error.stderr ?? error), /prepare X\.Y\.Z\[-rc\.N\|-beta\.N\]/);
|
|
116
116
|
return true;
|
|
117
117
|
},
|
|
118
118
|
);
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
test("beta metadata and matching tag require dedicated beta notes", async (t) => {
|
|
122
|
+
const root = await createFixture(t);
|
|
123
|
+
await runRelease(root, "prepare", "0.5.8-beta.1");
|
|
124
|
+
const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
125
|
+
assert.equal(pkg.version, "0.5.8-beta.1");
|
|
126
|
+
await runRelease(root, "check");
|
|
127
|
+
await assert.rejects(() => runRelease(root, "tag", "v0.5.8-beta.1"), /missing dedicated release notes/);
|
|
128
|
+
await writeFile(join(root, "docs", "releases", "v0.5.8-beta.1.md"), "# Beta\n\nBeta notes.\n");
|
|
129
|
+
const checked = await runRelease(root, "tag", "v0.5.8-beta.1");
|
|
130
|
+
assert.match(checked.stdout, /release tag v0\.5\.8-beta\.1 matches/);
|
|
131
|
+
});
|
|
@@ -135,11 +135,12 @@ function hookTag() {
|
|
|
135
135
|
}
|
|
136
136
|
const pushCommand = pushMatch[0];
|
|
137
137
|
if (/(?:^|\s)(?:-f|--force(?:-with-lease)?)(?:=\S*)?(?=$|\s)/.test(pushCommand)
|
|
138
|
-
|| /(?:^|\s)\+(?:refs\/tags\/)?v\d+\.\d+\.\d+(?:-rc\.\d+)?(?=$|\s)/.test(pushCommand)) {
|
|
138
|
+
|| /(?:^|\s)\+(?:refs\/tags\/)?v\d+\.\d+\.\d+(?:-(?:rc|beta)\.\d+)?(?=$|\s)/.test(pushCommand)) {
|
|
139
139
|
throw new Error("force push is not allowed for release tags");
|
|
140
140
|
}
|
|
141
141
|
const deleting = /(?:^|\s)(?:-d|--delete)(?=$|\s)/.test(pushCommand);
|
|
142
|
-
const
|
|
142
|
+
const tagPattern = /(?:^|\s)(?:tag\s+)?(?:refs\/tags\/)?(v\d+\.\d+\.\d+(?:-(?:rc|beta)\.\d+)?)(?=$|\s)/;
|
|
143
|
+
const tagMatch = tagPattern.exec(pushCommand);
|
|
143
144
|
if (!tagMatch?.[1]) {
|
|
144
145
|
throw new Error("release Hook payload does not contain a release tag push");
|
|
145
146
|
}
|
|
@@ -231,6 +231,27 @@ test("release tag hook gate accepts an rc tag for the package version", async (t
|
|
|
231
231
|
assert.match(checked.stdout, /Release tag gate OK: v0\.6\.0-rc\.1/);
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
test("release tag hook validates beta tags instead of treating them as non-release pushes", async (t) => {
|
|
235
|
+
const root = await mkdtemp(join(tmpdir(), "forgerelay-beta-proof-"));
|
|
236
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
237
|
+
await writeFile(join(root, "package.json"), JSON.stringify({ version: "1.3.6-beta.1" }) + "\n");
|
|
238
|
+
await mkdir(join(root, "docs", "releases"), { recursive: true });
|
|
239
|
+
await writeFile(join(root, "docs", "releases", "v1.3.6-beta.1.md"), "# Beta\n\nDedicated beta notes.\n");
|
|
240
|
+
await git(root, ["init"]);
|
|
241
|
+
await git(root, ["config", "user.email", "proof@example.com"]);
|
|
242
|
+
await git(root, ["config", "user.name", "Release Proof Test"]);
|
|
243
|
+
await git(root, ["add", "."]);
|
|
244
|
+
await git(root, ["commit", "-m", "release beta"]);
|
|
245
|
+
await git(root, ["tag", "v1.3.6-beta.1"]);
|
|
246
|
+
const checked = await runProof(root, "check-hook", {
|
|
247
|
+
FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin v1.3.6-beta.1" }),
|
|
248
|
+
});
|
|
249
|
+
assert.match(checked.stdout, /Release tag gate OK: v1\.3\.6-beta\.1/);
|
|
250
|
+
await assert.rejects(() => runProof(root, "check-hook", {
|
|
251
|
+
FORGERELAY_HOOK_PAYLOAD: JSON.stringify({ command: "git push origin +v1.3.6-beta.1" }),
|
|
252
|
+
}), /force push is not allowed/);
|
|
253
|
+
});
|
|
254
|
+
|
|
234
255
|
test("release tag hook gate rejects a mismatched tag without invoking a remote", async (t) => {
|
|
235
256
|
const root = await mkdtemp(join(tmpdir(), "forgerelay-release-proof-"));
|
|
236
257
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
@@ -9,7 +9,7 @@ const changelogPath = resolve(repoRoot, "CHANGELOG.md");
|
|
|
9
9
|
const noticePath = resolve(repoRoot, "NOTICE.md");
|
|
10
10
|
const licensePath = resolve(repoRoot, "LICENSE");
|
|
11
11
|
const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
12
|
-
const releaseVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-rc\.(0|[1-9]\d*))?$/;
|
|
12
|
+
const releaseVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:rc|beta)\.(0|[1-9]\d*))?$/;
|
|
13
13
|
const expectedPackageName = "@akira-tl/forgerelay";
|
|
14
14
|
const expectedRepositoryUrl = "git+https://github.com/Akira-TL/forgerelay.git";
|
|
15
15
|
const expectedHomepage = "https://github.com/Akira-TL/forgerelay#readme";
|
|
@@ -27,7 +27,7 @@ switch (command) {
|
|
|
27
27
|
break;
|
|
28
28
|
case "tag": {
|
|
29
29
|
checkState(state);
|
|
30
|
-
if (!value) fail("usage: node scripts/release-version.mjs tag vX.Y.Z[-rc.N]");
|
|
30
|
+
if (!value) fail("usage: node scripts/release-version.mjs tag vX.Y.Z[-rc.N|-beta.N]");
|
|
31
31
|
if (getUnreleasedBody(state.changelog)) {
|
|
32
32
|
fail("CHANGELOG.md still has Unreleased changes; prepare the next version before creating a release tag");
|
|
33
33
|
}
|
|
@@ -42,7 +42,7 @@ switch (command) {
|
|
|
42
42
|
case "prepare": {
|
|
43
43
|
checkState(state);
|
|
44
44
|
if (!value || !releaseVersionPattern.test(value)) {
|
|
45
|
-
fail("usage: node scripts/release-version.mjs prepare X.Y.Z[-rc.N] [--dry-run]");
|
|
45
|
+
fail("usage: node scripts/release-version.mjs prepare X.Y.Z[-rc.N|-beta.N] [--dry-run]");
|
|
46
46
|
}
|
|
47
47
|
await prepareRelease(state, value, dryRun);
|
|
48
48
|
break;
|
|
@@ -58,10 +58,10 @@ switch (command) {
|
|
|
58
58
|
}
|
|
59
59
|
case "notes": {
|
|
60
60
|
checkState(state);
|
|
61
|
-
if (!value) fail("usage: node scripts/release-version.mjs notes vX.Y.Z[-rc.N]");
|
|
61
|
+
if (!value) fail("usage: node scripts/release-version.mjs notes vX.Y.Z[-rc.N|-beta.N]");
|
|
62
62
|
const version = value.startsWith("v") ? value.slice(1) : value;
|
|
63
63
|
if (!releaseVersionPattern.test(version)) {
|
|
64
|
-
fail(`release notes version ${JSON.stringify(value)} must be vX.Y.Z[-rc.N] or X.Y.Z[-rc.N]`);
|
|
64
|
+
fail(`release notes version ${JSON.stringify(value)} must be vX.Y.Z[-rc.N|-beta.N] or X.Y.Z[-rc.N|-beta.N]`);
|
|
65
65
|
}
|
|
66
66
|
const body = getReleaseBody(state.changelog, version);
|
|
67
67
|
if (!body) fail(`CHANGELOG.md has no release notes for ${version}`);
|
|
@@ -98,7 +98,7 @@ function checkState(state) {
|
|
|
98
98
|
fail(`unexpected package name ${JSON.stringify(state.pkg.name)}; expected ${expectedPackageName}`);
|
|
99
99
|
}
|
|
100
100
|
if (typeof version !== "string" || !releaseVersionPattern.test(version)) {
|
|
101
|
-
fail(`package version ${JSON.stringify(version)} must be X.Y.Z or X.Y.Z-
|
|
101
|
+
fail(`package version ${JSON.stringify(version)} must be X.Y.Z, X.Y.Z-rc.N or X.Y.Z-beta.N`);
|
|
102
102
|
}
|
|
103
103
|
if (state.pkg.repository?.url !== expectedRepositoryUrl) {
|
|
104
104
|
fail(`package repository.url must be ${expectedRepositoryUrl} for GitHub/npm trusted publishing`);
|