@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.
@@ -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
- export function createReviewCheckpointManager() {
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 busyWorkspaceIds = workspaces
1341
- .workspaceIdsForPhysicalWorkspace(workspace)
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(response.content),
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 === "idle_timeout") {
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.register(newTransportSessionId, transport);
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
  })();
@@ -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
- constructor(stateDir) {
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 = new Date().toISOString();
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
- return row ? rowToWorkspaceSession(row) : undefined;
69
+ if (!row)
70
+ return undefined;
71
+ return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(id));
51
72
  }
52
73
  touchSession(id) {
53
- this.database.db
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: new Date().toISOString() })
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.map(rowToWorkspaceSession);
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
- return row ? rowToWorkspaceConversationBinding(row) : undefined;
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 = new Date().toISOString();
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.database.db
133
- .update(workspaceConversationBindings)
134
- .set({ lastUsedAt: new Date().toISOString() })
135
- .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
136
- .run();
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.database.close();
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,