pi-app-server 0.1.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/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/command-classification.d.ts +59 -0
- package/dist/command-classification.d.ts.map +1 -0
- package/dist/command-classification.js +78 -0
- package/dist/command-classification.js.map +7 -0
- package/dist/command-execution-engine.d.ts +118 -0
- package/dist/command-execution-engine.d.ts.map +1 -0
- package/dist/command-execution-engine.js +259 -0
- package/dist/command-execution-engine.js.map +7 -0
- package/dist/command-replay-store.d.ts +241 -0
- package/dist/command-replay-store.d.ts.map +1 -0
- package/dist/command-replay-store.js +306 -0
- package/dist/command-replay-store.js.map +7 -0
- package/dist/command-router.d.ts +25 -0
- package/dist/command-router.d.ts.map +1 -0
- package/dist/command-router.js +353 -0
- package/dist/command-router.js.map +7 -0
- package/dist/extension-ui.d.ts +139 -0
- package/dist/extension-ui.d.ts.map +1 -0
- package/dist/extension-ui.js +189 -0
- package/dist/extension-ui.js.map +7 -0
- package/dist/resource-governor.d.ts +254 -0
- package/dist/resource-governor.d.ts.map +1 -0
- package/dist/resource-governor.js +603 -0
- package/dist/resource-governor.js.map +7 -0
- package/dist/server-command-handlers.d.ts +120 -0
- package/dist/server-command-handlers.d.ts.map +1 -0
- package/dist/server-command-handlers.js +234 -0
- package/dist/server-command-handlers.js.map +7 -0
- package/dist/server-ui-context.d.ts +22 -0
- package/dist/server-ui-context.d.ts.map +1 -0
- package/dist/server-ui-context.js +221 -0
- package/dist/server-ui-context.js.map +7 -0
- package/dist/server.d.ts +82 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +561 -0
- package/dist/server.js.map +7 -0
- package/dist/session-lock-manager.d.ts +100 -0
- package/dist/session-lock-manager.d.ts.map +1 -0
- package/dist/session-lock-manager.js +199 -0
- package/dist/session-lock-manager.js.map +7 -0
- package/dist/session-manager.d.ts +196 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +1010 -0
- package/dist/session-manager.js.map +7 -0
- package/dist/session-store.d.ts +190 -0
- package/dist/session-store.d.ts.map +1 -0
- package/dist/session-store.js +446 -0
- package/dist/session-store.js.map +7 -0
- package/dist/session-version-store.d.ts +83 -0
- package/dist/session-version-store.d.ts.map +1 -0
- package/dist/session-version-store.js +117 -0
- package/dist/session-version-store.js.map +7 -0
- package/dist/type-guards.d.ts +59 -0
- package/dist/type-guards.d.ts.map +1 -0
- package/dist/type-guards.js +40 -0
- package/dist/type-guards.js.map +7 -0
- package/dist/types.d.ts +621 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +7 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +323 -0
- package/dist/validation.js.map +7 -0
- package/package.json +135 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import fsRegular from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
const DEFAULT_SERVER_VERSION = "0.1.0";
|
|
5
|
+
const METADATA_FILE = "sessions-metadata.json";
|
|
6
|
+
const MAX_METADATA_SIZE = 1024 * 1024;
|
|
7
|
+
class SessionStore {
|
|
8
|
+
dataDir;
|
|
9
|
+
sessionsDir;
|
|
10
|
+
serverVersion;
|
|
11
|
+
metadataPath;
|
|
12
|
+
metadataCache = null;
|
|
13
|
+
lastLoadTime = 0;
|
|
14
|
+
/** Cache TTL in ms (5 seconds) */
|
|
15
|
+
cacheTtl = 5e3;
|
|
16
|
+
/** Count of metadata resets due to oversized/corrupt files */
|
|
17
|
+
metadataResetCount = 0;
|
|
18
|
+
constructor(config = {}) {
|
|
19
|
+
this.dataDir = config.dataDir ?? path.join(process.env.HOME ?? "~", ".pi", "agent", "server");
|
|
20
|
+
this.sessionsDir = config.sessionsDir ?? path.join(process.env.HOME ?? "~", ".pi", "agent", "sessions");
|
|
21
|
+
this.serverVersion = config.serverVersion ?? DEFAULT_SERVER_VERSION;
|
|
22
|
+
this.metadataPath = path.join(this.dataDir, METADATA_FILE);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Ensure the data directory exists.
|
|
26
|
+
*/
|
|
27
|
+
async ensureDataDir() {
|
|
28
|
+
try {
|
|
29
|
+
await fs.mkdir(this.dataDir, { recursive: true });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code !== "EEXIST") {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Load metadata from disk (with caching).
|
|
38
|
+
*/
|
|
39
|
+
async loadMetadata() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (this.metadataCache && now - this.lastLoadTime < this.cacheTtl) {
|
|
42
|
+
return this.metadataCache;
|
|
43
|
+
}
|
|
44
|
+
await this.ensureDataDir();
|
|
45
|
+
try {
|
|
46
|
+
const stat = await fs.stat(this.metadataPath);
|
|
47
|
+
if (stat.size > MAX_METADATA_SIZE) {
|
|
48
|
+
this.metadataResetCount++;
|
|
49
|
+
const backupPath = `${this.metadataPath}.oversized.${Date.now()}.bak`;
|
|
50
|
+
try {
|
|
51
|
+
await fs.rename(this.metadataPath, backupPath);
|
|
52
|
+
console.error(
|
|
53
|
+
`[SessionStore] CRITICAL: Metadata file too large (${stat.size} bytes > ${MAX_METADATA_SIZE}), backed up to ${backupPath} and resetting`
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
console.error(
|
|
57
|
+
`[SessionStore] CRITICAL: Metadata file too large (${stat.size} bytes), failed to backup: ${this.metadataPath}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
this.metadataCache = /* @__PURE__ */ new Map();
|
|
61
|
+
this.lastLoadTime = now;
|
|
62
|
+
return this.metadataCache;
|
|
63
|
+
}
|
|
64
|
+
const data = await fs.readFile(this.metadataPath, "utf-8");
|
|
65
|
+
const parsed = JSON.parse(data);
|
|
66
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
67
|
+
throw new Error("Invalid metadata format");
|
|
68
|
+
}
|
|
69
|
+
const entries = Array.isArray(parsed.sessions) ? parsed.sessions : Array.isArray(parsed) ? parsed : Object.entries(parsed);
|
|
70
|
+
const map = /* @__PURE__ */ new Map();
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (Array.isArray(entry)) {
|
|
73
|
+
const [key, value] = entry;
|
|
74
|
+
if (typeof key === "string" && this.isValidMetadata(value)) {
|
|
75
|
+
map.set(key, value);
|
|
76
|
+
}
|
|
77
|
+
} else if (this.isValidMetadata(entry)) {
|
|
78
|
+
map.set(entry.sessionId, entry);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.metadataCache = map;
|
|
82
|
+
this.lastLoadTime = now;
|
|
83
|
+
return map;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code === "ENOENT") {
|
|
86
|
+
this.metadataCache = /* @__PURE__ */ new Map();
|
|
87
|
+
this.lastLoadTime = now;
|
|
88
|
+
return this.metadataCache;
|
|
89
|
+
}
|
|
90
|
+
console.error(`[SessionStore] Failed to load metadata:`, error);
|
|
91
|
+
this.metadataCache = /* @__PURE__ */ new Map();
|
|
92
|
+
this.lastLoadTime = now;
|
|
93
|
+
return this.metadataCache;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Validate metadata structure.
|
|
98
|
+
*/
|
|
99
|
+
isValidMetadata(value) {
|
|
100
|
+
if (typeof value !== "object" || value === null) return false;
|
|
101
|
+
const v = value;
|
|
102
|
+
return typeof v.sessionId === "string" && typeof v.sessionFile === "string" && typeof v.cwd === "string" && typeof v.createdAt === "string";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Save metadata to disk.
|
|
106
|
+
*/
|
|
107
|
+
async saveMetadata(metadata) {
|
|
108
|
+
await this.ensureDataDir();
|
|
109
|
+
const data = {
|
|
110
|
+
version: 1,
|
|
111
|
+
serverVersion: this.serverVersion,
|
|
112
|
+
sessions: Array.from(metadata.values())
|
|
113
|
+
};
|
|
114
|
+
const tempPath = `${this.metadataPath}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
|
|
115
|
+
await fs.writeFile(tempPath, JSON.stringify(data, null, 2), "utf-8");
|
|
116
|
+
await fs.rename(tempPath, this.metadataPath);
|
|
117
|
+
this.metadataCache = metadata;
|
|
118
|
+
this.lastLoadTime = Date.now();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Invalidate the cache (force reload on next access).
|
|
122
|
+
*/
|
|
123
|
+
invalidateCache() {
|
|
124
|
+
this.metadataCache = null;
|
|
125
|
+
this.lastLoadTime = 0;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Save session metadata.
|
|
129
|
+
*/
|
|
130
|
+
async save(meta) {
|
|
131
|
+
const metadata = await this.loadMetadata();
|
|
132
|
+
metadata.set(meta.sessionId, {
|
|
133
|
+
...meta,
|
|
134
|
+
serverVersion: this.serverVersion
|
|
135
|
+
});
|
|
136
|
+
await this.saveMetadata(metadata);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Load session metadata by ID.
|
|
140
|
+
*/
|
|
141
|
+
async load(sessionId) {
|
|
142
|
+
const metadata = await this.loadMetadata();
|
|
143
|
+
return metadata.get(sessionId) ?? null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Delete session metadata.
|
|
147
|
+
*/
|
|
148
|
+
async delete(sessionId) {
|
|
149
|
+
const metadata = await this.loadMetadata();
|
|
150
|
+
if (!metadata.has(sessionId)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
metadata.delete(sessionId);
|
|
154
|
+
await this.saveMetadata(metadata);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* List all stored session metadata.
|
|
159
|
+
*/
|
|
160
|
+
async list() {
|
|
161
|
+
const metadata = await this.loadMetadata();
|
|
162
|
+
return Array.from(metadata.values());
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* List stored sessions with resolved info (includes file existence check).
|
|
166
|
+
*/
|
|
167
|
+
async listWithInfo() {
|
|
168
|
+
const metadata = await this.loadMetadata();
|
|
169
|
+
const results = [];
|
|
170
|
+
for (const meta of metadata.values()) {
|
|
171
|
+
let fileExists = false;
|
|
172
|
+
try {
|
|
173
|
+
await fs.access(meta.sessionFile);
|
|
174
|
+
fileExists = true;
|
|
175
|
+
} catch {
|
|
176
|
+
fileExists = false;
|
|
177
|
+
}
|
|
178
|
+
results.push({
|
|
179
|
+
sessionId: meta.sessionId,
|
|
180
|
+
sessionName: meta.sessionName,
|
|
181
|
+
sessionFile: meta.sessionFile,
|
|
182
|
+
cwd: meta.cwd,
|
|
183
|
+
createdAt: meta.createdAt,
|
|
184
|
+
// These may be stale/undefined - will be refreshed when session is loaded
|
|
185
|
+
thinkingLevel: "medium",
|
|
186
|
+
isStreaming: false,
|
|
187
|
+
messageCount: 0,
|
|
188
|
+
fileExists
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
192
|
+
return results;
|
|
193
|
+
}
|
|
194
|
+
// ==========================================================================
|
|
195
|
+
// SESSION DISCOVERY (ADR-0007)
|
|
196
|
+
// ==========================================================================
|
|
197
|
+
/**
|
|
198
|
+
* Discover all session files in the sessions directory.
|
|
199
|
+
* This scans ~/.pi/agent/sessions/ for .jsonl files.
|
|
200
|
+
* Reads the first line of each file to get the correct cwd.
|
|
201
|
+
*/
|
|
202
|
+
async discoverSessions() {
|
|
203
|
+
const results = [];
|
|
204
|
+
try {
|
|
205
|
+
const entries = await fs.readdir(this.sessionsDir, { withFileTypes: true });
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
if (!entry.isDirectory()) continue;
|
|
208
|
+
const subdir = path.join(this.sessionsDir, entry.name);
|
|
209
|
+
try {
|
|
210
|
+
const files = await fs.readdir(subdir);
|
|
211
|
+
for (const file of files) {
|
|
212
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
213
|
+
const filePath = path.join(subdir, file);
|
|
214
|
+
const stats = await fs.stat(filePath);
|
|
215
|
+
const createdAt = this.extractTimestampFromFilename(file) ?? stats.mtime.toISOString();
|
|
216
|
+
const sessionId = file.replace(/\.jsonl$/, "");
|
|
217
|
+
const { cwd, sessionName } = await this.readSessionFileMetadata(filePath);
|
|
218
|
+
results.push({
|
|
219
|
+
sessionId,
|
|
220
|
+
sessionFile: filePath,
|
|
221
|
+
cwd,
|
|
222
|
+
sessionName,
|
|
223
|
+
createdAt,
|
|
224
|
+
thinkingLevel: "medium",
|
|
225
|
+
isStreaming: false,
|
|
226
|
+
messageCount: 0,
|
|
227
|
+
fileExists: true
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
}
|
|
235
|
+
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
236
|
+
return results;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Read the first line of a session file to get metadata.
|
|
240
|
+
* Uses readline to properly handle UTF-8 and avoid truncation issues.
|
|
241
|
+
*/
|
|
242
|
+
async readSessionFileMetadata(filePath) {
|
|
243
|
+
const readline = await import("readline");
|
|
244
|
+
const fileStream = fsRegular.createReadStream(filePath, { encoding: "utf-8" });
|
|
245
|
+
let rl;
|
|
246
|
+
try {
|
|
247
|
+
rl = readline.createInterface({
|
|
248
|
+
input: fileStream,
|
|
249
|
+
crlfDelay: Infinity
|
|
250
|
+
});
|
|
251
|
+
let firstLine;
|
|
252
|
+
for await (const line of rl) {
|
|
253
|
+
firstLine = line;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
if (!firstLine) {
|
|
257
|
+
return { cwd: "/unknown" };
|
|
258
|
+
}
|
|
259
|
+
const meta = JSON.parse(firstLine);
|
|
260
|
+
return {
|
|
261
|
+
cwd: meta.cwd || "/unknown",
|
|
262
|
+
sessionName: meta.sessionName || meta.name || void 0
|
|
263
|
+
};
|
|
264
|
+
} catch {
|
|
265
|
+
return { cwd: "/unknown" };
|
|
266
|
+
} finally {
|
|
267
|
+
try {
|
|
268
|
+
rl?.close();
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
fileStream.destroy();
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Extract timestamp from session filename.
|
|
279
|
+
* 2026-02-22T16-09-11-130Z_6f572984.jsonl → 2026-02-22T16:09:11.130Z
|
|
280
|
+
*/
|
|
281
|
+
extractTimestampFromFilename(filename) {
|
|
282
|
+
const match = filename.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/);
|
|
283
|
+
if (!match) return null;
|
|
284
|
+
const [, year, month, day, hour, min, sec, ms] = match;
|
|
285
|
+
return `${year}-${month}-${day}T${hour}:${min}:${sec}.${ms}Z`;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* List all sessions (stored + discovered), merged.
|
|
289
|
+
* Stored sessions take precedence (they have more metadata).
|
|
290
|
+
*/
|
|
291
|
+
async listAllSessions() {
|
|
292
|
+
const [stored, discovered] = await Promise.all([this.listWithInfo(), this.discoverSessions()]);
|
|
293
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
294
|
+
for (const session of discovered) {
|
|
295
|
+
byPath.set(session.sessionFile, session);
|
|
296
|
+
}
|
|
297
|
+
for (const session of stored) {
|
|
298
|
+
byPath.set(session.sessionFile, session);
|
|
299
|
+
}
|
|
300
|
+
return Array.from(byPath.values()).sort(
|
|
301
|
+
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* List all sessions grouped by working directory.
|
|
306
|
+
* Groups are sorted by most recent session (newest first).
|
|
307
|
+
*/
|
|
308
|
+
async listSessionsGrouped() {
|
|
309
|
+
const sessions = await this.listAllSessions();
|
|
310
|
+
const groups = /* @__PURE__ */ new Map();
|
|
311
|
+
for (const session of sessions) {
|
|
312
|
+
const cwd = session.cwd || "/unknown";
|
|
313
|
+
if (!groups.has(cwd)) {
|
|
314
|
+
groups.set(cwd, []);
|
|
315
|
+
}
|
|
316
|
+
groups.get(cwd).push(session);
|
|
317
|
+
}
|
|
318
|
+
const result = [];
|
|
319
|
+
for (const [cwd, groupSessions] of groups) {
|
|
320
|
+
result.push({
|
|
321
|
+
cwd,
|
|
322
|
+
displayPath: this.formatDisplayPath(cwd),
|
|
323
|
+
sessionCount: groupSessions.length,
|
|
324
|
+
sessions: groupSessions
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
result.sort((a, b) => {
|
|
328
|
+
const aTime = new Date(a.sessions[0]?.createdAt || 0).getTime();
|
|
329
|
+
const bTime = new Date(b.sessions[0]?.createdAt || 0).getTime();
|
|
330
|
+
return bTime - aTime;
|
|
331
|
+
});
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Format a full path for display.
|
|
336
|
+
* /home/tryinget/programming/pi-server → pi-server
|
|
337
|
+
* /home/tryinget → ~
|
|
338
|
+
*/
|
|
339
|
+
formatDisplayPath(cwd) {
|
|
340
|
+
const home = process.env.HOME || "";
|
|
341
|
+
if (!home) return cwd;
|
|
342
|
+
if (cwd === home) return "~";
|
|
343
|
+
if (cwd.startsWith(home + "/")) {
|
|
344
|
+
const relative = cwd.slice(home.length + 1);
|
|
345
|
+
const parts = relative.split("/");
|
|
346
|
+
if (parts.length <= 2) {
|
|
347
|
+
return parts.join("/");
|
|
348
|
+
}
|
|
349
|
+
return parts.slice(-2).join("/");
|
|
350
|
+
}
|
|
351
|
+
return cwd;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Update session name in metadata.
|
|
355
|
+
*/
|
|
356
|
+
async updateName(sessionId, name) {
|
|
357
|
+
const metadata = await this.loadMetadata();
|
|
358
|
+
const existing = metadata.get(sessionId);
|
|
359
|
+
if (!existing) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
existing.sessionName = name;
|
|
363
|
+
await this.saveMetadata(metadata);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Clean up metadata entries for sessions whose files no longer exist.
|
|
368
|
+
*/
|
|
369
|
+
async cleanup() {
|
|
370
|
+
const metadata = await this.loadMetadata();
|
|
371
|
+
const toRemove = [];
|
|
372
|
+
for (const [sessionId, meta] of metadata) {
|
|
373
|
+
try {
|
|
374
|
+
await fs.access(meta.sessionFile);
|
|
375
|
+
} catch {
|
|
376
|
+
toRemove.push(sessionId);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (const sessionId of toRemove) {
|
|
380
|
+
metadata.delete(sessionId);
|
|
381
|
+
}
|
|
382
|
+
if (toRemove.length > 0) {
|
|
383
|
+
await this.saveMetadata(metadata);
|
|
384
|
+
}
|
|
385
|
+
return { removed: toRemove.length, kept: metadata.size };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Get store statistics.
|
|
389
|
+
*/
|
|
390
|
+
async getStats() {
|
|
391
|
+
const metadata = await this.loadMetadata();
|
|
392
|
+
return {
|
|
393
|
+
sessionCount: metadata.size,
|
|
394
|
+
dataDir: this.dataDir,
|
|
395
|
+
metadataPath: this.metadataPath,
|
|
396
|
+
metadataResetCount: this.metadataResetCount
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Get metadata reset count (synchronous, for metrics).
|
|
401
|
+
* This tracks how many times the metadata file was reset due to being
|
|
402
|
+
* oversized or corrupt, indicating potential disk/filesystem issues.
|
|
403
|
+
*/
|
|
404
|
+
getMetadataResetCount() {
|
|
405
|
+
return this.metadataResetCount;
|
|
406
|
+
}
|
|
407
|
+
// ==========================================================================
|
|
408
|
+
// PERIODIC CLEANUP
|
|
409
|
+
// ==========================================================================
|
|
410
|
+
cleanupInterval = null;
|
|
411
|
+
/**
|
|
412
|
+
* Start periodic cleanup of orphaned metadata entries.
|
|
413
|
+
* @param intervalMs Cleanup interval in milliseconds (default: 1 hour)
|
|
414
|
+
*/
|
|
415
|
+
startPeriodicCleanup(intervalMs = 36e5) {
|
|
416
|
+
if (this.cleanupInterval) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
this.cleanupInterval = setInterval(async () => {
|
|
420
|
+
try {
|
|
421
|
+
const result = await this.cleanup();
|
|
422
|
+
if (result.removed > 0) {
|
|
423
|
+
console.log(`[SessionStore] Periodic cleanup removed ${result.removed} orphaned entries`);
|
|
424
|
+
}
|
|
425
|
+
} catch (error) {
|
|
426
|
+
console.error("[SessionStore] Periodic cleanup failed:", error);
|
|
427
|
+
}
|
|
428
|
+
}, intervalMs);
|
|
429
|
+
if (this.cleanupInterval.unref) {
|
|
430
|
+
this.cleanupInterval.unref();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Stop periodic cleanup.
|
|
435
|
+
*/
|
|
436
|
+
stopPeriodicCleanup() {
|
|
437
|
+
if (this.cleanupInterval) {
|
|
438
|
+
clearInterval(this.cleanupInterval);
|
|
439
|
+
this.cleanupInterval = null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
export {
|
|
444
|
+
SessionStore
|
|
445
|
+
};
|
|
446
|
+
//# sourceMappingURL=session-store.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/session-store.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Session Store - persists session metadata across server restarts.\n *\n * The actual session content (messages, etc.) is managed by pi-coding-agent\n * and stored in session files (~/.pi/agent/sessions/*.json).\n *\n * This store tracks:\n * - Which sessions existed (sessionId -> sessionFile mapping)\n * - Session metadata (createdAt, cwd, etc.)\n * - Enables recovery after server restart\n *\n * ADR-0007: Session Persistence\n */\n\nimport fs from \"fs/promises\";\nimport fsRegular from \"fs\";\nimport path from \"path\";\nimport type { SessionInfo } from \"./types.js\";\n\n/** Metadata persisted for each session. */\nexport interface StoredSessionMetadata {\n /** Unique session identifier */\n sessionId: string;\n /** Path to the session file managed by pi-coding-agent */\n sessionFile: string;\n /** Working directory when session was created */\n cwd: string;\n /** ISO timestamp when session was created */\n createdAt: string;\n /** Optional user-defined session name */\n sessionName?: string;\n /** Last known model (may be stale if session was modified externally) */\n modelId?: string;\n /** Server version that created this record (for migrations) */\n serverVersion: string;\n}\n\n/** Input for saving session metadata (serverVersion added automatically). */\nexport type SaveSessionInput = Omit<StoredSessionMetadata, \"serverVersion\">;\n\n/** Session with resolved metadata (combines stored + file system info). */\nexport interface StoredSessionInfo extends SessionInfo {\n /** Path to the session file */\n sessionFile: string;\n /** Working directory */\n cwd: string;\n /** Whether the session file still exists on disk */\n fileExists: boolean;\n}\n\n/** A group of sessions organized by working directory. */\nexport interface SessionGroup {\n /** Full working directory path */\n cwd: string;\n /** Display-friendly path (shortened) */\n displayPath: string;\n /** Number of sessions in this group */\n sessionCount: number;\n /** Sessions in this group, sorted by date (newest first) */\n sessions: StoredSessionInfo[];\n}\n\n/** Configuration for SessionStore */\nexport interface SessionStoreConfig {\n /** Directory to store session metadata (default: ~/.pi/agent/server/) */\n dataDir?: string;\n /** Directory where pi-coding-agent stores sessions (default: ~/.pi/agent/sessions/) */\n sessionsDir?: string;\n /** Server version for migration tracking */\n serverVersion?: string;\n}\n\n/** Default server version if not provided */\nconst DEFAULT_SERVER_VERSION = \"0.1.0\";\n\n/** Metadata file name */\nconst METADATA_FILE = \"sessions-metadata.json\";\n\n/** Maximum metadata file size (prevent OOM from corrupt files) */\nconst MAX_METADATA_SIZE = 1024 * 1024; // 1MB\n\n/**\n * Session metadata store.\n *\n * Thread-safety: All operations are atomic via file locking.\n * Callers should use SessionLockManager for in-memory coordination.\n */\nexport class SessionStore {\n private readonly dataDir: string;\n private readonly sessionsDir: string;\n private readonly serverVersion: string;\n private readonly metadataPath: string;\n private metadataCache: Map<string, StoredSessionMetadata> | null = null;\n private lastLoadTime = 0;\n /** Cache TTL in ms (5 seconds) */\n private readonly cacheTtl = 5000;\n /** Count of metadata resets due to oversized/corrupt files */\n private metadataResetCount = 0;\n\n constructor(config: SessionStoreConfig = {}) {\n this.dataDir = config.dataDir ?? path.join(process.env.HOME ?? \"~\", \".pi\", \"agent\", \"server\");\n this.sessionsDir =\n config.sessionsDir ?? path.join(process.env.HOME ?? \"~\", \".pi\", \"agent\", \"sessions\");\n this.serverVersion = config.serverVersion ?? DEFAULT_SERVER_VERSION;\n this.metadataPath = path.join(this.dataDir, METADATA_FILE);\n }\n\n /**\n * Ensure the data directory exists.\n */\n private async ensureDataDir(): Promise<void> {\n try {\n await fs.mkdir(this.dataDir, { recursive: true });\n } catch (error) {\n if ((error as any).code !== \"EEXIST\") {\n throw error;\n }\n }\n }\n\n /**\n * Load metadata from disk (with caching).\n */\n private async loadMetadata(): Promise<Map<string, StoredSessionMetadata>> {\n const now = Date.now();\n\n // Return cached if fresh\n if (this.metadataCache && now - this.lastLoadTime < this.cacheTtl) {\n return this.metadataCache;\n }\n\n await this.ensureDataDir();\n\n try {\n const stat = await fs.stat(this.metadataPath);\n\n // Safety check: reject oversized files\n if (stat.size > MAX_METADATA_SIZE) {\n this.metadataResetCount++;\n // Backup the oversized file before resetting\n const backupPath = `${this.metadataPath}.oversized.${Date.now()}.bak`;\n try {\n await fs.rename(this.metadataPath, backupPath);\n console.error(\n `[SessionStore] CRITICAL: Metadata file too large (${stat.size} bytes > ${MAX_METADATA_SIZE}), backed up to ${backupPath} and resetting`\n );\n } catch {\n console.error(\n `[SessionStore] CRITICAL: Metadata file too large (${stat.size} bytes), failed to backup: ${this.metadataPath}`\n );\n }\n this.metadataCache = new Map();\n this.lastLoadTime = now;\n return this.metadataCache;\n }\n\n const data = await fs.readFile(this.metadataPath, \"utf-8\");\n const parsed = JSON.parse(data);\n\n if (typeof parsed !== \"object\" || parsed === null) {\n throw new Error(\"Invalid metadata format\");\n }\n\n // Handle both array and object formats\n const entries = Array.isArray(parsed.sessions)\n ? parsed.sessions\n : Array.isArray(parsed)\n ? parsed\n : Object.entries(parsed);\n\n const map = new Map<string, StoredSessionMetadata>();\n\n for (const entry of entries) {\n if (Array.isArray(entry)) {\n // [key, value] format\n const [key, value] = entry;\n if (typeof key === \"string\" && this.isValidMetadata(value)) {\n map.set(key, value);\n }\n } else if (this.isValidMetadata(entry)) {\n // { sessionId, ... } format\n map.set(entry.sessionId, entry);\n }\n }\n\n this.metadataCache = map;\n this.lastLoadTime = now;\n return map;\n } catch (error) {\n if ((error as any).code === \"ENOENT\") {\n // File doesn't exist yet - return empty map\n this.metadataCache = new Map();\n this.lastLoadTime = now;\n return this.metadataCache;\n }\n\n console.error(`[SessionStore] Failed to load metadata:`, error);\n // Return empty on error (don't crash)\n this.metadataCache = new Map();\n this.lastLoadTime = now;\n return this.metadataCache;\n }\n }\n\n /**\n * Validate metadata structure.\n */\n private isValidMetadata(value: unknown): value is StoredSessionMetadata {\n if (typeof value !== \"object\" || value === null) return false;\n const v = value as Record<string, unknown>;\n return (\n typeof v.sessionId === \"string\" &&\n typeof v.sessionFile === \"string\" &&\n typeof v.cwd === \"string\" &&\n typeof v.createdAt === \"string\"\n );\n }\n\n /**\n * Save metadata to disk.\n */\n private async saveMetadata(metadata: Map<string, StoredSessionMetadata>): Promise<void> {\n await this.ensureDataDir();\n\n const data = {\n version: 1,\n serverVersion: this.serverVersion,\n sessions: Array.from(metadata.values()),\n };\n\n // Write to temp file first, then rename (atomic on POSIX)\n // Include PID and random suffix to prevent collision with concurrent saves\n const tempPath = `${this.metadataPath}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;\n await fs.writeFile(tempPath, JSON.stringify(data, null, 2), \"utf-8\");\n await fs.rename(tempPath, this.metadataPath);\n\n // Update cache\n this.metadataCache = metadata;\n this.lastLoadTime = Date.now();\n }\n\n /**\n * Invalidate the cache (force reload on next access).\n */\n invalidateCache(): void {\n this.metadataCache = null;\n this.lastLoadTime = 0;\n }\n\n /**\n * Save session metadata.\n */\n async save(meta: SaveSessionInput): Promise<void> {\n const metadata = await this.loadMetadata();\n metadata.set(meta.sessionId, {\n ...meta,\n serverVersion: this.serverVersion,\n });\n await this.saveMetadata(metadata);\n }\n\n /**\n * Load session metadata by ID.\n */\n async load(sessionId: string): Promise<StoredSessionMetadata | null> {\n const metadata = await this.loadMetadata();\n return metadata.get(sessionId) ?? null;\n }\n\n /**\n * Delete session metadata.\n */\n async delete(sessionId: string): Promise<boolean> {\n const metadata = await this.loadMetadata();\n if (!metadata.has(sessionId)) {\n return false;\n }\n metadata.delete(sessionId);\n await this.saveMetadata(metadata);\n return true;\n }\n\n /**\n * List all stored session metadata.\n */\n async list(): Promise<StoredSessionMetadata[]> {\n const metadata = await this.loadMetadata();\n return Array.from(metadata.values());\n }\n\n /**\n * List stored sessions with resolved info (includes file existence check).\n */\n async listWithInfo(): Promise<StoredSessionInfo[]> {\n const metadata = await this.loadMetadata();\n const results: StoredSessionInfo[] = [];\n\n for (const meta of metadata.values()) {\n let fileExists = false;\n try {\n await fs.access(meta.sessionFile);\n fileExists = true;\n } catch {\n fileExists = false;\n }\n\n results.push({\n sessionId: meta.sessionId,\n sessionName: meta.sessionName,\n sessionFile: meta.sessionFile,\n cwd: meta.cwd,\n createdAt: meta.createdAt,\n // These may be stale/undefined - will be refreshed when session is loaded\n thinkingLevel: \"medium\",\n isStreaming: false,\n messageCount: 0,\n fileExists,\n });\n }\n\n // Sort by creation date (newest first)\n results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());\n\n return results;\n }\n\n // ==========================================================================\n // SESSION DISCOVERY (ADR-0007)\n // ==========================================================================\n\n /**\n * Discover all session files in the sessions directory.\n * This scans ~/.pi/agent/sessions/ for .jsonl files.\n * Reads the first line of each file to get the correct cwd.\n */\n async discoverSessions(): Promise<StoredSessionInfo[]> {\n const results: StoredSessionInfo[] = [];\n\n try {\n const entries = await fs.readdir(this.sessionsDir, { withFileTypes: true });\n\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n const subdir = path.join(this.sessionsDir, entry.name);\n\n try {\n const files = await fs.readdir(subdir);\n\n for (const file of files) {\n if (!file.endsWith(\".jsonl\")) continue;\n\n const filePath = path.join(subdir, file);\n const stats = await fs.stat(filePath);\n\n // Extract timestamp from filename: 2026-02-22T16-09-11-130Z_6f572984.jsonl\n const createdAt = this.extractTimestampFromFilename(file) ?? stats.mtime.toISOString();\n\n // Use file path as session ID (or extract from filename)\n const sessionId = file.replace(/\\.jsonl$/, \"\");\n\n // Read first line to get cwd and sessionName\n const { cwd, sessionName } = await this.readSessionFileMetadata(filePath);\n\n results.push({\n sessionId,\n sessionFile: filePath,\n cwd,\n sessionName,\n createdAt,\n thinkingLevel: \"medium\",\n isStreaming: false,\n messageCount: 0,\n fileExists: true,\n });\n }\n } catch {\n // Ignore errors reading subdirectory\n }\n }\n } catch {\n // Sessions directory doesn't exist yet\n }\n\n // Sort by creation date (newest first)\n results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());\n\n return results;\n }\n\n /**\n * Read the first line of a session file to get metadata.\n * Uses readline to properly handle UTF-8 and avoid truncation issues.\n */\n private async readSessionFileMetadata(\n filePath: string\n ): Promise<{ cwd: string; sessionName?: string }> {\n const readline = await import(\"readline\");\n const fileStream = fsRegular.createReadStream(filePath, { encoding: \"utf-8\" });\n let rl: ReturnType<typeof readline.createInterface> | undefined;\n\n try {\n rl = readline.createInterface({\n input: fileStream,\n crlfDelay: Infinity,\n });\n\n let firstLine: string | undefined;\n for await (const line of rl) {\n firstLine = line;\n break; // Only need the first line\n }\n\n if (!firstLine) {\n return { cwd: \"/unknown\" };\n }\n\n const meta = JSON.parse(firstLine);\n return {\n cwd: meta.cwd || \"/unknown\",\n sessionName: meta.sessionName || meta.name || undefined,\n };\n } catch {\n return { cwd: \"/unknown\" };\n } finally {\n // Always close readline interface first, then destroy the stream\n // This prevents resource leaks if the for-await loop throws\n // Use try-catch to ensure both cleanup steps run even if one fails\n try {\n rl?.close();\n } catch {\n // Ignore close errors\n }\n try {\n fileStream.destroy();\n } catch {\n // Ignore destroy errors\n }\n }\n }\n\n /**\n * Extract timestamp from session filename.\n * 2026-02-22T16-09-11-130Z_6f572984.jsonl \u2192 2026-02-22T16:09:11.130Z\n */\n private extractTimestampFromFilename(filename: string): string | null {\n const match = filename.match(/^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})-(\\d{3})Z/);\n if (!match) return null;\n\n const [, year, month, day, hour, min, sec, ms] = match;\n return `${year}-${month}-${day}T${hour}:${min}:${sec}.${ms}Z`;\n }\n\n /**\n * List all sessions (stored + discovered), merged.\n * Stored sessions take precedence (they have more metadata).\n */\n async listAllSessions(): Promise<StoredSessionInfo[]> {\n const [stored, discovered] = await Promise.all([this.listWithInfo(), this.discoverSessions()]);\n\n // Create map keyed by sessionFile for deduplication\n const byPath = new Map<string, StoredSessionInfo>();\n\n // Add discovered first\n for (const session of discovered) {\n byPath.set(session.sessionFile, session);\n }\n\n // Stored sessions override (they have more metadata)\n for (const session of stored) {\n byPath.set(session.sessionFile, session);\n }\n\n // Sort by creation date (newest first)\n return Array.from(byPath.values()).sort(\n (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()\n );\n }\n\n /**\n * List all sessions grouped by working directory.\n * Groups are sorted by most recent session (newest first).\n */\n async listSessionsGrouped(): Promise<SessionGroup[]> {\n const sessions = await this.listAllSessions();\n\n // Group by cwd\n const groups = new Map<string, StoredSessionInfo[]>();\n for (const session of sessions) {\n const cwd = session.cwd || \"/unknown\";\n if (!groups.has(cwd)) {\n groups.set(cwd, []);\n }\n groups.get(cwd)!.push(session);\n }\n\n // Convert to SessionGroup array\n const result: SessionGroup[] = [];\n for (const [cwd, groupSessions] of groups) {\n result.push({\n cwd,\n displayPath: this.formatDisplayPath(cwd),\n sessionCount: groupSessions.length,\n sessions: groupSessions,\n });\n }\n\n // Sort groups by most recent session\n result.sort((a, b) => {\n const aTime = new Date(a.sessions[0]?.createdAt || 0).getTime();\n const bTime = new Date(b.sessions[0]?.createdAt || 0).getTime();\n return bTime - aTime;\n });\n\n return result;\n }\n\n /**\n * Format a full path for display.\n * /home/tryinget/programming/pi-server \u2192 pi-server\n * /home/tryinget \u2192 ~\n */\n private formatDisplayPath(cwd: string): string {\n const home = process.env.HOME || \"\";\n if (!home) return cwd;\n\n // Replace home with ~\n if (cwd === home) return \"~\";\n if (cwd.startsWith(home + \"/\")) {\n const relative = cwd.slice(home.length + 1);\n // Show just the last 1-2 components\n const parts = relative.split(\"/\");\n if (parts.length <= 2) {\n return parts.join(\"/\");\n }\n return parts.slice(-2).join(\"/\");\n }\n\n return cwd;\n }\n\n /**\n * Update session name in metadata.\n */\n async updateName(sessionId: string, name: string): Promise<boolean> {\n const metadata = await this.loadMetadata();\n const existing = metadata.get(sessionId);\n if (!existing) {\n return false;\n }\n existing.sessionName = name;\n await this.saveMetadata(metadata);\n return true;\n }\n\n /**\n * Clean up metadata entries for sessions whose files no longer exist.\n */\n async cleanup(): Promise<{ removed: number; kept: number }> {\n const metadata = await this.loadMetadata();\n const toRemove: string[] = [];\n\n for (const [sessionId, meta] of metadata) {\n try {\n await fs.access(meta.sessionFile);\n } catch {\n toRemove.push(sessionId);\n }\n }\n\n for (const sessionId of toRemove) {\n metadata.delete(sessionId);\n }\n\n if (toRemove.length > 0) {\n await this.saveMetadata(metadata);\n }\n\n return { removed: toRemove.length, kept: metadata.size };\n }\n\n /**\n * Get store statistics.\n */\n async getStats(): Promise<{\n sessionCount: number;\n dataDir: string;\n metadataPath: string;\n metadataResetCount: number;\n }> {\n const metadata = await this.loadMetadata();\n return {\n sessionCount: metadata.size,\n dataDir: this.dataDir,\n metadataPath: this.metadataPath,\n metadataResetCount: this.metadataResetCount,\n };\n }\n\n /**\n * Get metadata reset count (synchronous, for metrics).\n * This tracks how many times the metadata file was reset due to being\n * oversized or corrupt, indicating potential disk/filesystem issues.\n */\n getMetadataResetCount(): number {\n return this.metadataResetCount;\n }\n\n // ==========================================================================\n // PERIODIC CLEANUP\n // ==========================================================================\n\n private cleanupInterval: NodeJS.Timeout | null = null;\n\n /**\n * Start periodic cleanup of orphaned metadata entries.\n * @param intervalMs Cleanup interval in milliseconds (default: 1 hour)\n */\n startPeriodicCleanup(intervalMs = 3600000): void {\n if (this.cleanupInterval) {\n return; // Already running\n }\n\n this.cleanupInterval = setInterval(async () => {\n try {\n const result = await this.cleanup();\n if (result.removed > 0) {\n console.log(`[SessionStore] Periodic cleanup removed ${result.removed} orphaned entries`);\n }\n } catch (error) {\n console.error(\"[SessionStore] Periodic cleanup failed:\", error);\n }\n }, intervalMs);\n\n // Don't prevent process exit\n if (this.cleanupInterval.unref) {\n this.cleanupInterval.unref();\n }\n }\n\n /**\n * Stop periodic cleanup.\n */\n stopPeriodicCleanup(): void {\n if (this.cleanupInterval) {\n clearInterval(this.cleanupInterval);\n this.cleanupInterval = null;\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Version Store - manages monotonic version counters per session.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Track session version numbers (for optimistic concurrency)
|
|
6
|
+
* - Apply version increments to responses
|
|
7
|
+
*
|
|
8
|
+
* Note: Mutation classification is delegated to command-classification.ts
|
|
9
|
+
* to maintain single source of truth.
|
|
10
|
+
*/
|
|
11
|
+
import type { RpcCommand, RpcResponse } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Statistics about the session version store.
|
|
14
|
+
*/
|
|
15
|
+
export interface SessionVersionStoreStats {
|
|
16
|
+
/** Number of sessions being tracked */
|
|
17
|
+
sessionCount: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Session Version Store - manages monotonic version counters.
|
|
21
|
+
*
|
|
22
|
+
* Extracted from PiSessionManager to isolate:
|
|
23
|
+
* - Version initialization (create session)
|
|
24
|
+
* - Version deletion (delete session)
|
|
25
|
+
* - Version increment logic (mutation classification)
|
|
26
|
+
*/
|
|
27
|
+
export declare class SessionVersionStore {
|
|
28
|
+
/** Monotonic per-session version counter. */
|
|
29
|
+
private sessionVersions;
|
|
30
|
+
/**
|
|
31
|
+
* Get the current version for a session.
|
|
32
|
+
* Returns undefined if session has no version (doesn't exist).
|
|
33
|
+
*/
|
|
34
|
+
getVersion(sessionId: string): number | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Check if a session has a version record.
|
|
37
|
+
*/
|
|
38
|
+
hasVersion(sessionId: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Get statistics about the store state.
|
|
41
|
+
*/
|
|
42
|
+
getStats(): SessionVersionStoreStats;
|
|
43
|
+
/**
|
|
44
|
+
* Initialize version for a new session (starts at 0).
|
|
45
|
+
*/
|
|
46
|
+
initialize(sessionId: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Increment version for a session.
|
|
49
|
+
* Returns the new version number.
|
|
50
|
+
*/
|
|
51
|
+
increment(sessionId: string): number;
|
|
52
|
+
/**
|
|
53
|
+
* Set version for a session explicitly.
|
|
54
|
+
*/
|
|
55
|
+
set(sessionId: string, version: number): void;
|
|
56
|
+
/**
|
|
57
|
+
* Remove version record for a session.
|
|
58
|
+
*/
|
|
59
|
+
delete(sessionId: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* Clear all version records.
|
|
62
|
+
*/
|
|
63
|
+
clear(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Check if a command type mutates session state.
|
|
66
|
+
* Delegates to command-classification.ts for single source of truth.
|
|
67
|
+
* Mutating commands advance the session version.
|
|
68
|
+
*/
|
|
69
|
+
isMutation(commandType: string): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Apply session version to a response.
|
|
72
|
+
*
|
|
73
|
+
* For successful responses:
|
|
74
|
+
* - create_session: initialize new session at version 0
|
|
75
|
+
* - load_session: initialize loaded session at version 0
|
|
76
|
+
* - delete_session: remove version record
|
|
77
|
+
* - other session commands: increment if mutating
|
|
78
|
+
*
|
|
79
|
+
* Failed responses are returned unchanged.
|
|
80
|
+
*/
|
|
81
|
+
applyVersion(command: RpcCommand, response: RpcResponse): RpcResponse;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=session-version-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-version-store.d.ts","sourceRoot":"","sources":["../src/session-version-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAI1D;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,uCAAuC;IACvC,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,qBAAa,mBAAmB;IAC9B,6CAA6C;IAC7C,OAAO,CAAC,eAAe,CAA6B;IAMpD;;;OAGG;IACH,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAIjD;;OAEG;IACH,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;IAItC;;OAEG;IACH,QAAQ,IAAI,wBAAwB;IAQpC;;OAEG;IACH,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAInC;;;OAGG;IACH,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;IAOpC;;OAEG;IACH,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI;IAI7C;;OAEG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/B;;OAEG;IACH,KAAK,IAAI,IAAI;IAQb;;;;OAIG;IACH,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO;IAQxC;;;;;;;;;;OAUG;IACH,YAAY,CAAC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,GAAG,WAAW;CA6CtE"}
|