@akira-tl/forgerelay 0.3.6 → 0.4.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/CHANGELOG.md +36 -0
- package/capabilities/code-intelligence/GUIDE.md +11 -0
- package/capabilities/shell-processes/GUIDE.md +2 -2
- package/dist/capabilities.js +6 -0
- package/dist/capability-registry.js +20 -0
- package/dist/config.js +1 -0
- package/dist/logger.js +16 -0
- package/dist/lsp/code-intelligence-error.js +8 -0
- package/dist/lsp/code-intelligence.js +550 -0
- package/dist/lsp/language-server-config.js +313 -0
- package/dist/lsp/position-encoding.js +88 -0
- package/dist/mcp-sessions.js +30 -0
- package/dist/oauth-provider.js +9 -0
- package/dist/process-sessions.js +98 -16
- package/dist/review-checkpoints.js +36 -1
- package/dist/server.js +107 -11
- package/dist/workspace-store.js +96 -19
- package/dist/workspaces.js +130 -82
- package/docs/chatgpt-coding-workflow.md +10 -4
- package/docs/configuration.md +80 -3
- package/docs/roadmap.md +26 -0
- package/package.json +4 -2
- package/scripts/debug/accept.mjs +15 -0
|
@@ -3,26 +3,60 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { git, getGitEligibility, safeWorkspaceRefSegment } from "./git.js";
|
|
5
5
|
const REVIEW_REF_PREFIX = "refs/devspace/review";
|
|
6
|
-
|
|
6
|
+
const DEFAULT_MAX_REVIEW_WORKSPACE_STATES = 128;
|
|
7
|
+
export function createReviewCheckpointManager(options = {}) {
|
|
8
|
+
const maxWorkspaceStates = options.maxWorkspaceStates ?? DEFAULT_MAX_REVIEW_WORKSPACE_STATES;
|
|
9
|
+
if (!Number.isInteger(maxWorkspaceStates) || maxWorkspaceStates < 1) {
|
|
10
|
+
throw new Error("Review checkpoint workspace-state limit must be a positive integer.");
|
|
11
|
+
}
|
|
7
12
|
const states = new Map();
|
|
8
13
|
const initializations = new Map();
|
|
14
|
+
const touchState = (workspaceId) => {
|
|
15
|
+
const state = states.get(workspaceId);
|
|
16
|
+
if (!state)
|
|
17
|
+
return;
|
|
18
|
+
states.delete(workspaceId);
|
|
19
|
+
states.set(workspaceId, state);
|
|
20
|
+
};
|
|
21
|
+
const trimStates = () => {
|
|
22
|
+
while (states.size > maxWorkspaceStates) {
|
|
23
|
+
const oldestWorkspaceId = states.keys().next().value;
|
|
24
|
+
if (!oldestWorkspaceId)
|
|
25
|
+
break;
|
|
26
|
+
states.delete(oldestWorkspaceId);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
9
29
|
return {
|
|
30
|
+
get stateCount() {
|
|
31
|
+
return states.size;
|
|
32
|
+
},
|
|
33
|
+
async releaseWorkspace(workspaceId) {
|
|
34
|
+
const pending = initializations.get(workspaceId);
|
|
35
|
+
if (pending)
|
|
36
|
+
await pending;
|
|
37
|
+
states.delete(workspaceId);
|
|
38
|
+
},
|
|
10
39
|
async initializeWorkspace({ workspaceId, root }) {
|
|
11
40
|
const existingState = states.get(workspaceId);
|
|
12
41
|
assertWorkspaceRoot(existingState, workspaceId, root);
|
|
13
42
|
if (existingState?.root === root && existingState.gitRoot !== undefined) {
|
|
43
|
+
touchState(workspaceId);
|
|
14
44
|
return;
|
|
15
45
|
}
|
|
16
46
|
const pending = initializations.get(workspaceId);
|
|
17
47
|
if (pending) {
|
|
18
48
|
await pending;
|
|
19
49
|
assertWorkspaceRoot(states.get(workspaceId), workspaceId, root);
|
|
50
|
+
touchState(workspaceId);
|
|
51
|
+
trimStates();
|
|
20
52
|
return;
|
|
21
53
|
}
|
|
22
54
|
const initialize = initializeWorkspaceState(states, workspaceId, root);
|
|
23
55
|
initializations.set(workspaceId, initialize);
|
|
24
56
|
try {
|
|
25
57
|
await initialize;
|
|
58
|
+
touchState(workspaceId);
|
|
59
|
+
trimStates();
|
|
26
60
|
}
|
|
27
61
|
finally {
|
|
28
62
|
if (initializations.get(workspaceId) === initialize) {
|
|
@@ -38,6 +72,7 @@ export function createReviewCheckpointManager() {
|
|
|
38
72
|
state = states.get(workspaceId);
|
|
39
73
|
}
|
|
40
74
|
assertWorkspaceRoot(state, workspaceId, root);
|
|
75
|
+
touchState(workspaceId);
|
|
41
76
|
if (!state?.gitRoot) {
|
|
42
77
|
throw new Error(state?.diagnostic ?? "review.changes requires a Git workspace in this version.");
|
|
43
78
|
}
|
package/dist/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { access, realpath } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
+
import { resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
8
|
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
@@ -20,6 +21,7 @@ import { deletePath, renamePath } from "./file-mutations.js";
|
|
|
20
21
|
import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, } from "./artifact-tools.js";
|
|
21
22
|
import { ArtifactError } from "./artifact-error.js";
|
|
22
23
|
import { loadConfig } from "./config.js";
|
|
24
|
+
import { CodeIntelligenceError, CodeIntelligenceManager } from "./lsp/code-intelligence.js";
|
|
23
25
|
import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
24
26
|
import { checkHookConfiguration } from "./hook-cli.js";
|
|
25
27
|
import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
@@ -42,6 +44,7 @@ import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvail
|
|
|
42
44
|
// transport. Bound stale transport-session retention so abandoned transports do
|
|
43
45
|
// not accumulate for the life of the process.
|
|
44
46
|
const MCP_TRANSPORT_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
|
47
|
+
const MAX_MCP_TRANSPORT_SESSIONS = 64;
|
|
45
48
|
const FORGERELAY_VERSION = readForgeRelayVersion();
|
|
46
49
|
const MCP_TRANSPORT_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
|
|
47
50
|
const WRITE_TOOL_ANNOTATIONS = {
|
|
@@ -92,6 +95,30 @@ function workspaceLogContext(workspace, _transportSessionId) {
|
|
|
92
95
|
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
93
96
|
};
|
|
94
97
|
}
|
|
98
|
+
function formatDiscoveredWorkspaceInstructions(files, workspaceRoot) {
|
|
99
|
+
return [
|
|
100
|
+
"Workspace instructions discovered for this path. Apply them to follow-up work under their directories:",
|
|
101
|
+
...files.flatMap((file) => [
|
|
102
|
+
`--- ${formatAgentsPath(file.path, workspaceRoot)} ---`,
|
|
103
|
+
file.content.trimEnd(),
|
|
104
|
+
]),
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
async function assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, paths) {
|
|
108
|
+
const discovered = new Map();
|
|
109
|
+
for (const path of paths) {
|
|
110
|
+
const absolutePath = resolve(workspace.root, path);
|
|
111
|
+
for (const file of await workspaces.discoverPathInstructions(workspace, absolutePath)) {
|
|
112
|
+
discovered.set(file.path, file);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (discovered.size === 0)
|
|
116
|
+
return;
|
|
117
|
+
throw new Error([
|
|
118
|
+
formatDiscoveredWorkspaceInstructions([...discovered.values()], workspace.root),
|
|
119
|
+
"Apply these instructions, then retry this tool call. No mutation or command was executed.",
|
|
120
|
+
].join("\n"));
|
|
121
|
+
}
|
|
95
122
|
function formatVisibleAgent(agent) {
|
|
96
123
|
const model = agent.model ? `, model ${agent.model}` : "";
|
|
97
124
|
const thinking = agent.thinking ? `, thinking ${agent.thinking}` : "";
|
|
@@ -608,6 +635,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
608
635
|
operation: async () => {
|
|
609
636
|
const startedAt = performance.now();
|
|
610
637
|
const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
|
|
638
|
+
await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
|
|
611
639
|
const snapshot = await processSessions.start({
|
|
612
640
|
workspaceId,
|
|
613
641
|
command: cmd,
|
|
@@ -716,7 +744,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
716
744
|
});
|
|
717
745
|
});
|
|
718
746
|
}
|
|
719
|
-
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
|
|
747
|
+
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence) {
|
|
720
748
|
const toolDescriptions = buildToolDescriptions(config);
|
|
721
749
|
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
|
|
722
750
|
const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
@@ -724,6 +752,22 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
724
752
|
const reviewChangesAvailable = config.widgets === "changes";
|
|
725
753
|
const capabilityRegistry = createCapabilityRegistry({
|
|
726
754
|
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
755
|
+
codeIntelligence: {
|
|
756
|
+
available: true,
|
|
757
|
+
run: async (input, context) => {
|
|
758
|
+
try {
|
|
759
|
+
return {
|
|
760
|
+
value: await codeIntelligence.definition(context.workspaceRoot, input),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
catch (error) {
|
|
764
|
+
if (error instanceof CodeIntelligenceError) {
|
|
765
|
+
throw new CapabilityError(error.code, error.message);
|
|
766
|
+
}
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
},
|
|
727
771
|
reviewChanges: {
|
|
728
772
|
available: reviewChangesAvailable,
|
|
729
773
|
unavailableReason: reviewChangesAvailable
|
|
@@ -1337,14 +1381,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1337
1381
|
if (!commitMessage) {
|
|
1338
1382
|
throw new Error(`Managed-worktree-backed workspace ${workspaceId} requires commitMessage when closing.`);
|
|
1339
1383
|
}
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1384
|
+
const physicalWorkspaceIds = workspaces.workspaceIdsForPhysicalWorkspace(workspace);
|
|
1385
|
+
const busyWorkspaceIds = physicalWorkspaceIds
|
|
1342
1386
|
.filter((id) => processSessions.activeWorkspaceIds().has(id));
|
|
1343
1387
|
if (busyWorkspaceIds.length > 0) {
|
|
1344
1388
|
throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
|
|
1345
1389
|
}
|
|
1346
1390
|
const startedAt = performance.now();
|
|
1347
1391
|
const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
|
|
1392
|
+
await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
|
|
1348
1393
|
const result = [
|
|
1349
1394
|
`Closed managed-worktree-backed workspace ${workspaceId}.`,
|
|
1350
1395
|
`Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
|
|
@@ -1384,6 +1429,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1384
1429
|
throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
|
|
1385
1430
|
}
|
|
1386
1431
|
workspaces.closeWorkspace(workspaceId);
|
|
1432
|
+
await reviewCheckpoints.releaseWorkspace(workspaceId);
|
|
1387
1433
|
const result = `Closed checkout-backed workspace ${workspaceId}. Physical project files were not removed.`;
|
|
1388
1434
|
return {
|
|
1389
1435
|
content: [textBlock(result)],
|
|
@@ -1417,7 +1463,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1417
1463
|
.optional()
|
|
1418
1464
|
.describe("Maximum number of lines to read."),
|
|
1419
1465
|
},
|
|
1420
|
-
outputSchema: resultOutputSchema(
|
|
1466
|
+
outputSchema: resultOutputSchema({
|
|
1467
|
+
agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
|
|
1468
|
+
}),
|
|
1421
1469
|
...toolWidgetDescriptorMeta(config, "read"),
|
|
1422
1470
|
annotations: { readOnlyHint: true },
|
|
1423
1471
|
}, async ({ workspaceId, ...input }, extra) => {
|
|
@@ -1430,6 +1478,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1430
1478
|
operation: async () => {
|
|
1431
1479
|
const startedAt = performance.now();
|
|
1432
1480
|
const readPath = workspaces.resolveReadPath(workspace, input.path);
|
|
1481
|
+
const discoveredInstructions = (await workspaces.discoverPathInstructions(workspace, readPath.absolutePath)).filter((file) => file.path !== readPath.absolutePath);
|
|
1433
1482
|
const response = await readFileTool({ ...input, path: readPath.absolutePath }, {
|
|
1434
1483
|
cwd: workspace.root,
|
|
1435
1484
|
root: workspace.root,
|
|
@@ -1444,6 +1493,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1444
1493
|
return response;
|
|
1445
1494
|
}
|
|
1446
1495
|
workspaces.markReadPathLoaded(workspace, readPath);
|
|
1496
|
+
const discoveredInstructionContent = discoveredInstructions.length > 0
|
|
1497
|
+
? textBlock(formatDiscoveredWorkspaceInstructions(discoveredInstructions, workspace.root))
|
|
1498
|
+
: undefined;
|
|
1499
|
+
const content = discoveredInstructionContent
|
|
1500
|
+
? [discoveredInstructionContent, ...response.content]
|
|
1501
|
+
: response.content;
|
|
1447
1502
|
const summary = {
|
|
1448
1503
|
...textSummary(response.content),
|
|
1449
1504
|
offset: input.offset ?? 1,
|
|
@@ -1458,6 +1513,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1458
1513
|
});
|
|
1459
1514
|
return {
|
|
1460
1515
|
...response,
|
|
1516
|
+
content,
|
|
1461
1517
|
_meta: {
|
|
1462
1518
|
tool: toolNames.read,
|
|
1463
1519
|
card: {
|
|
@@ -1468,7 +1524,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1468
1524
|
},
|
|
1469
1525
|
},
|
|
1470
1526
|
structuredContent: {
|
|
1471
|
-
result: contentText(
|
|
1527
|
+
result: contentText(content),
|
|
1528
|
+
...(discoveredInstructions.length > 0
|
|
1529
|
+
? {
|
|
1530
|
+
agentsFiles: discoveredInstructions.map((file) => ({
|
|
1531
|
+
path: formatAgentsPath(file.path, workspace.root),
|
|
1532
|
+
content: file.content,
|
|
1533
|
+
})),
|
|
1534
|
+
}
|
|
1535
|
+
: {}),
|
|
1472
1536
|
},
|
|
1473
1537
|
};
|
|
1474
1538
|
},
|
|
@@ -1500,6 +1564,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1500
1564
|
changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
|
|
1501
1565
|
operation: async () => {
|
|
1502
1566
|
const startedAt = performance.now();
|
|
1567
|
+
await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [input.path]);
|
|
1503
1568
|
const response = await writeFileTool(input, {
|
|
1504
1569
|
cwd: workspace.root,
|
|
1505
1570
|
root: workspace.root,
|
|
@@ -1582,6 +1647,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1582
1647
|
changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
|
|
1583
1648
|
operation: async () => {
|
|
1584
1649
|
const startedAt = performance.now();
|
|
1650
|
+
await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [input.path]);
|
|
1585
1651
|
const response = await editFileTool(input, {
|
|
1586
1652
|
cwd: workspace.root,
|
|
1587
1653
|
root: workspace.root,
|
|
@@ -1657,6 +1723,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1657
1723
|
operation: async () => {
|
|
1658
1724
|
const startedAt = performance.now();
|
|
1659
1725
|
try {
|
|
1726
|
+
await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [path, newPath]);
|
|
1660
1727
|
await renamePath({ path, newPath }, {
|
|
1661
1728
|
cwd: workspace.root,
|
|
1662
1729
|
allowedRoots: workspaces.fileToolRoots(workspace),
|
|
@@ -1728,6 +1795,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1728
1795
|
operation: async () => {
|
|
1729
1796
|
const startedAt = performance.now();
|
|
1730
1797
|
try {
|
|
1798
|
+
await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [path]);
|
|
1731
1799
|
const deleted = await deletePath({ path, recursive }, {
|
|
1732
1800
|
cwd: workspace.root,
|
|
1733
1801
|
allowedRoots: workspaces.fileToolRoots(workspace),
|
|
@@ -1938,6 +2006,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1938
2006
|
operation: async () => {
|
|
1939
2007
|
const startedAt = performance.now();
|
|
1940
2008
|
const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
|
|
2009
|
+
await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
|
|
1941
2010
|
const snapshot = await processSessions.start({
|
|
1942
2011
|
workspaceId,
|
|
1943
2012
|
command,
|
|
@@ -2041,7 +2110,9 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2041
2110
|
host: config.host,
|
|
2042
2111
|
...(allowedHosts ? { allowedHosts } : {}),
|
|
2043
2112
|
});
|
|
2044
|
-
const transports = new McpTransportRegistry(
|
|
2113
|
+
const transports = new McpTransportRegistry({
|
|
2114
|
+
maxTransports: MAX_MCP_TRANSPORT_SESSIONS,
|
|
2115
|
+
});
|
|
2045
2116
|
const mcpUrl = new URL("/mcp", config.publicBaseUrl);
|
|
2046
2117
|
const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
|
|
2047
2118
|
const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
|
|
@@ -2054,6 +2125,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2054
2125
|
const workspaces = new WorkspaceRegistry(config, workspaceStore);
|
|
2055
2126
|
const reviewCheckpoints = createReviewCheckpointManager();
|
|
2056
2127
|
const processSessions = new ProcessManager();
|
|
2128
|
+
const codeIntelligence = new CodeIntelligenceManager(config);
|
|
2057
2129
|
const localAgentProviders = config.subagents
|
|
2058
2130
|
? getLocalAgentProviderAvailabilitySnapshot()
|
|
2059
2131
|
: [];
|
|
@@ -2071,7 +2143,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2071
2143
|
continue;
|
|
2072
2144
|
}
|
|
2073
2145
|
closedCount += 1;
|
|
2074
|
-
if (reason
|
|
2146
|
+
if (reason !== "server_shutdown") {
|
|
2075
2147
|
logEvent(config.logging, "debug", "mcp_transport_session_closed", {
|
|
2076
2148
|
reason,
|
|
2077
2149
|
transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
|
|
@@ -2085,12 +2157,32 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2085
2157
|
});
|
|
2086
2158
|
}
|
|
2087
2159
|
};
|
|
2160
|
+
const logRuntimeResources = () => {
|
|
2161
|
+
const memory = process.memoryUsage();
|
|
2162
|
+
const processStats = processSessions.stats();
|
|
2163
|
+
logEvent(config.logging, "debug", "runtime_resources", {
|
|
2164
|
+
rssBytes: memory.rss,
|
|
2165
|
+
heapUsedBytes: memory.heapUsed,
|
|
2166
|
+
heapTotalBytes: memory.heapTotal,
|
|
2167
|
+
externalBytes: memory.external,
|
|
2168
|
+
arrayBuffersBytes: memory.arrayBuffers,
|
|
2169
|
+
mcpTransports: transports.size,
|
|
2170
|
+
processesTotal: processStats.total,
|
|
2171
|
+
processesRunning: processStats.running,
|
|
2172
|
+
processesCompleted: processStats.completed,
|
|
2173
|
+
cachedWorkspaces: workspaces.cachedWorkspaceCount,
|
|
2174
|
+
reviewStates: reviewCheckpoints.stateCount,
|
|
2175
|
+
languageServices: codeIntelligence.size,
|
|
2176
|
+
});
|
|
2177
|
+
};
|
|
2088
2178
|
const transportCleanupTimer = setInterval(() => {
|
|
2089
2179
|
void transports
|
|
2090
2180
|
.closeIdle(MCP_TRANSPORT_IDLE_TIMEOUT_MS)
|
|
2091
|
-
.then((results) => logTransportCloseResults("idle_timeout", results))
|
|
2181
|
+
.then((results) => logTransportCloseResults("idle_timeout", results))
|
|
2182
|
+
.finally(logRuntimeResources);
|
|
2092
2183
|
}, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
|
|
2093
2184
|
transportCleanupTimer.unref();
|
|
2185
|
+
logRuntimeResources();
|
|
2094
2186
|
if (config.logging.trustProxy) {
|
|
2095
2187
|
app.set("trust proxy", 1);
|
|
2096
2188
|
}
|
|
@@ -2182,8 +2274,11 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2182
2274
|
transport = new StreamableHTTPServerTransport({
|
|
2183
2275
|
sessionIdGenerator: () => randomUUID(),
|
|
2184
2276
|
onsessioninitialized: (newTransportSessionId) => {
|
|
2185
|
-
if (transport)
|
|
2186
|
-
transports
|
|
2277
|
+
if (transport) {
|
|
2278
|
+
void transports
|
|
2279
|
+
.register(newTransportSessionId, transport)
|
|
2280
|
+
.then((results) => logTransportCloseResults("capacity_limit", results));
|
|
2281
|
+
}
|
|
2187
2282
|
logEvent(config.logging, "debug", "mcp_transport_session_created", {
|
|
2188
2283
|
requestId,
|
|
2189
2284
|
transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
|
|
@@ -2200,7 +2295,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2200
2295
|
});
|
|
2201
2296
|
}
|
|
2202
2297
|
};
|
|
2203
|
-
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters);
|
|
2298
|
+
const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence);
|
|
2204
2299
|
await server.connect(transport);
|
|
2205
2300
|
}
|
|
2206
2301
|
else {
|
|
@@ -2230,6 +2325,7 @@ export function createServer(config = loadConfig(), options = {}) {
|
|
|
2230
2325
|
const results = await transports.closeAll();
|
|
2231
2326
|
logTransportCloseResults("server_shutdown", results);
|
|
2232
2327
|
processSessions.shutdown();
|
|
2328
|
+
await codeIntelligence.shutdown();
|
|
2233
2329
|
oauthProvider.close();
|
|
2234
2330
|
workspaceStore.close?.();
|
|
2235
2331
|
})();
|
package/dist/workspace-store.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
import { and, desc, eq } from "drizzle-orm";
|
|
2
2
|
import { openDatabase } from "./db/client.js";
|
|
3
3
|
import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
|
|
4
|
+
const DEFAULT_TOUCH_FLUSH_INTERVAL_MS = 5 * 60 * 1_000;
|
|
4
5
|
export class SqliteWorkspaceStore {
|
|
5
6
|
database;
|
|
6
|
-
|
|
7
|
+
now;
|
|
8
|
+
touchFlushTimer;
|
|
9
|
+
pendingSessionTouches = new Map();
|
|
10
|
+
pendingConversationTouches = new Map();
|
|
11
|
+
constructor(stateDir, options = {}) {
|
|
7
12
|
this.database = openDatabase(stateDir);
|
|
13
|
+
this.now = options.now ?? (() => new Date());
|
|
14
|
+
const touchFlushIntervalMs = options.touchFlushIntervalMs ?? DEFAULT_TOUCH_FLUSH_INTERVAL_MS;
|
|
15
|
+
if (!Number.isInteger(touchFlushIntervalMs) || touchFlushIntervalMs < 1) {
|
|
16
|
+
throw new Error("Workspace touch flush interval must be a positive integer.");
|
|
17
|
+
}
|
|
18
|
+
this.touchFlushTimer = setInterval(() => {
|
|
19
|
+
try {
|
|
20
|
+
this.flushTouches();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
console.warn(`ForgeRelay workspace touch flush failed: ${errorMessage(error)}`);
|
|
24
|
+
}
|
|
25
|
+
}, touchFlushIntervalMs);
|
|
26
|
+
this.touchFlushTimer.unref();
|
|
8
27
|
}
|
|
9
28
|
createSession(input) {
|
|
10
|
-
const now =
|
|
29
|
+
const now = this.now().toISOString();
|
|
11
30
|
const session = {
|
|
12
31
|
id: input.id,
|
|
13
32
|
root: input.root,
|
|
@@ -47,19 +66,18 @@ export class SqliteWorkspaceStore {
|
|
|
47
66
|
.from(workspaceSessions)
|
|
48
67
|
.where(eq(workspaceSessions.id, id))
|
|
49
68
|
.get();
|
|
50
|
-
|
|
69
|
+
if (!row)
|
|
70
|
+
return undefined;
|
|
71
|
+
return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(id));
|
|
51
72
|
}
|
|
52
73
|
touchSession(id) {
|
|
53
|
-
this.
|
|
54
|
-
.update(workspaceSessions)
|
|
55
|
-
.set({ lastUsedAt: new Date().toISOString() })
|
|
56
|
-
.where(eq(workspaceSessions.id, id))
|
|
57
|
-
.run();
|
|
74
|
+
this.pendingSessionTouches.set(id, this.now().toISOString());
|
|
58
75
|
}
|
|
59
76
|
setSessionStatus(id, status) {
|
|
77
|
+
this.pendingSessionTouches.delete(id);
|
|
60
78
|
this.database.db
|
|
61
79
|
.update(workspaceSessions)
|
|
62
|
-
.set({ status, lastUsedAt:
|
|
80
|
+
.set({ status, lastUsedAt: this.now().toISOString() })
|
|
63
81
|
.where(eq(workspaceSessions.id, id))
|
|
64
82
|
.run();
|
|
65
83
|
}
|
|
@@ -77,9 +95,13 @@ export class SqliteWorkspaceStore {
|
|
|
77
95
|
: conditions.length === 1
|
|
78
96
|
? query.where(conditions[0]).all()
|
|
79
97
|
: query.where(and(...conditions)).all();
|
|
80
|
-
return rows
|
|
98
|
+
return rows
|
|
99
|
+
.map(rowToWorkspaceSession)
|
|
100
|
+
.map((session) => applySessionTouch(session, this.pendingSessionTouches.get(session.id)))
|
|
101
|
+
.sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
|
|
81
102
|
}
|
|
82
103
|
deleteSession(id) {
|
|
104
|
+
this.pendingSessionTouches.delete(id);
|
|
83
105
|
this.database.db
|
|
84
106
|
.delete(workspaceSessions)
|
|
85
107
|
.where(eq(workspaceSessions.id, id))
|
|
@@ -90,7 +112,8 @@ export class SqliteWorkspaceStore {
|
|
|
90
112
|
.select()
|
|
91
113
|
.from(workspaceConversationBindings)
|
|
92
114
|
.all()
|
|
93
|
-
.map(rowToWorkspaceConversationBinding)
|
|
115
|
+
.map(rowToWorkspaceConversationBinding)
|
|
116
|
+
.map((binding) => applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(binding.conversationScopeId, binding.targetKey))?.lastUsedAt));
|
|
94
117
|
}
|
|
95
118
|
getConversationBinding(conversationScopeId, targetKey) {
|
|
96
119
|
const row = this.database.db
|
|
@@ -98,10 +121,13 @@ export class SqliteWorkspaceStore {
|
|
|
98
121
|
.from(workspaceConversationBindings)
|
|
99
122
|
.where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
|
|
100
123
|
.get();
|
|
101
|
-
|
|
124
|
+
if (!row)
|
|
125
|
+
return undefined;
|
|
126
|
+
const binding = rowToWorkspaceConversationBinding(row);
|
|
127
|
+
return applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(conversationScopeId, targetKey))?.lastUsedAt);
|
|
102
128
|
}
|
|
103
129
|
setConversationBinding(input) {
|
|
104
|
-
const now =
|
|
130
|
+
const now = this.now().toISOString();
|
|
105
131
|
const row = this.database.db
|
|
106
132
|
.insert(workspaceConversationBindings)
|
|
107
133
|
.values({
|
|
@@ -126,16 +152,18 @@ export class SqliteWorkspaceStore {
|
|
|
126
152
|
if (!row) {
|
|
127
153
|
throw new Error("Conversation workspace binding upsert returned no row.");
|
|
128
154
|
}
|
|
155
|
+
this.pendingConversationTouches.delete(conversationTouchKey(input.conversationScopeId, input.targetKey));
|
|
129
156
|
return rowToWorkspaceConversationBinding(row);
|
|
130
157
|
}
|
|
131
158
|
touchConversationBinding(conversationScopeId, targetKey) {
|
|
132
|
-
this.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.
|
|
136
|
-
|
|
159
|
+
this.pendingConversationTouches.set(conversationTouchKey(conversationScopeId, targetKey), {
|
|
160
|
+
conversationScopeId,
|
|
161
|
+
targetKey,
|
|
162
|
+
lastUsedAt: this.now().toISOString(),
|
|
163
|
+
});
|
|
137
164
|
}
|
|
138
165
|
deleteConversationBinding(conversationScopeId, targetKey) {
|
|
166
|
+
this.pendingConversationTouches.delete(conversationTouchKey(conversationScopeId, targetKey));
|
|
139
167
|
this.database.db
|
|
140
168
|
.delete(workspaceConversationBindings)
|
|
141
169
|
.where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
|
|
@@ -184,13 +212,62 @@ export class SqliteWorkspaceStore {
|
|
|
184
212
|
.where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
|
|
185
213
|
.run();
|
|
186
214
|
}
|
|
215
|
+
get pendingTouchCount() {
|
|
216
|
+
return this.pendingSessionTouches.size + this.pendingConversationTouches.size;
|
|
217
|
+
}
|
|
218
|
+
flushTouches() {
|
|
219
|
+
if (this.pendingTouchCount === 0)
|
|
220
|
+
return;
|
|
221
|
+
const sessionTouches = [...this.pendingSessionTouches.entries()];
|
|
222
|
+
const conversationTouches = [...this.pendingConversationTouches.values()];
|
|
223
|
+
const updateSession = this.database.sqlite.prepare("UPDATE workspace_sessions SET last_used_at = ? WHERE id = ?");
|
|
224
|
+
const updateConversation = this.database.sqlite.prepare("UPDATE workspace_conversation_bindings SET last_used_at = ? WHERE conversation_scope_id = ? AND target_key = ?");
|
|
225
|
+
const flush = this.database.sqlite.transaction(() => {
|
|
226
|
+
for (const [workspaceId, lastUsedAt] of sessionTouches) {
|
|
227
|
+
updateSession.run(lastUsedAt, workspaceId);
|
|
228
|
+
}
|
|
229
|
+
for (const touch of conversationTouches) {
|
|
230
|
+
updateConversation.run(touch.lastUsedAt, touch.conversationScopeId, touch.targetKey);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
flush();
|
|
234
|
+
for (const [workspaceId, lastUsedAt] of sessionTouches) {
|
|
235
|
+
if (this.pendingSessionTouches.get(workspaceId) === lastUsedAt) {
|
|
236
|
+
this.pendingSessionTouches.delete(workspaceId);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const touch of conversationTouches) {
|
|
240
|
+
const key = conversationTouchKey(touch.conversationScopeId, touch.targetKey);
|
|
241
|
+
if (this.pendingConversationTouches.get(key)?.lastUsedAt === touch.lastUsedAt) {
|
|
242
|
+
this.pendingConversationTouches.delete(key);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
187
246
|
close() {
|
|
188
|
-
this.
|
|
247
|
+
clearInterval(this.touchFlushTimer);
|
|
248
|
+
try {
|
|
249
|
+
this.flushTouches();
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
this.database.close();
|
|
253
|
+
}
|
|
189
254
|
}
|
|
190
255
|
}
|
|
191
256
|
export function createWorkspaceStore(stateDir) {
|
|
192
257
|
return new SqliteWorkspaceStore(stateDir);
|
|
193
258
|
}
|
|
259
|
+
function applySessionTouch(session, lastUsedAt) {
|
|
260
|
+
return lastUsedAt ? { ...session, lastUsedAt } : session;
|
|
261
|
+
}
|
|
262
|
+
function applyConversationTouch(binding, lastUsedAt) {
|
|
263
|
+
return lastUsedAt ? { ...binding, lastUsedAt } : binding;
|
|
264
|
+
}
|
|
265
|
+
function conversationTouchKey(conversationScopeId, targetKey) {
|
|
266
|
+
return JSON.stringify([conversationScopeId, targetKey]);
|
|
267
|
+
}
|
|
268
|
+
function errorMessage(error) {
|
|
269
|
+
return error instanceof Error ? error.message : String(error);
|
|
270
|
+
}
|
|
194
271
|
function rowToWorkspaceSession(row) {
|
|
195
272
|
return {
|
|
196
273
|
id: row.id,
|