session-steward 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +13 -0
- package/bin/session-steward-cli.mjs +14 -0
- package/dist/assets/index-B8U6eLCA.css +2 -0
- package/dist/assets/index-JaDHeyls.js +9 -0
- package/dist/index.html +2 -2
- package/lib/cli.mjs +134 -2
- package/lib/providers/claude-code/events.mjs +498 -0
- package/lib/providers/claude-code/index.mjs +2 -0
- package/lib/providers/claude-code/store.mjs +6 -1
- package/lib/providers/codex/database-families.mjs +2 -2
- package/lib/providers/codex/events.mjs +779 -0
- package/lib/providers/codex/index.mjs +2 -0
- package/lib/providers/codex/store.mjs +82 -4
- package/lib/server.mjs +62 -1
- package/lib/session-event-reader.mjs +126 -0
- package/lib/session-events.mjs +307 -0
- package/lib/storage/jsonl.mjs +148 -0
- package/package.json +6 -2
- package/dist/assets/index-BQ6SUqXr.css +0 -2
- package/dist/assets/index-QhQbSn0H.js +0 -9
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readSessionEvents } from "./events.mjs";
|
|
1
2
|
import {
|
|
2
3
|
assertDeepCleanupSupported,
|
|
3
4
|
diagnoseStorageCompatibility,
|
|
@@ -38,6 +39,7 @@ export const codexProvider = Object.freeze({
|
|
|
38
39
|
loadSessionStore,
|
|
39
40
|
planSessionDeletion,
|
|
40
41
|
preflightSessionDeletion,
|
|
42
|
+
readSessionEvents,
|
|
41
43
|
restoreSessionDeletionBackup,
|
|
42
44
|
verifySessionDeletion,
|
|
43
45
|
});
|
|
@@ -460,6 +460,7 @@ export function getCodexPaths(codexHomeInput, { refresh = false } = {}) {
|
|
|
460
460
|
|
|
461
461
|
return {
|
|
462
462
|
archivedSessionsDirectory,
|
|
463
|
+
attachmentsDirectory: path.join(codexHome, "attachments"),
|
|
463
464
|
codexHome,
|
|
464
465
|
desktopStateBackupPath: path.join(codexHome, ".codex-global-state.json.bak"),
|
|
465
466
|
desktopStatePath: path.join(codexHome, ".codex-global-state.json"),
|
|
@@ -477,6 +478,7 @@ export function getCodexPaths(codexHomeInput, { refresh = false } = {}) {
|
|
|
477
478
|
stateDatabasePath,
|
|
478
479
|
stateDatabases: allValidDatabases(state),
|
|
479
480
|
stateDatabasePaths: allValidDatabases(state).map((database) => database.path),
|
|
481
|
+
threadWriterLocksDirectory: path.join(codexHome, "thread-writer-locks"),
|
|
480
482
|
transcriptDirectories: [sessionsDirectory, archivedSessionsDirectory],
|
|
481
483
|
};
|
|
482
484
|
}
|
|
@@ -768,6 +770,7 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
768
770
|
});
|
|
769
771
|
|
|
770
772
|
return {
|
|
773
|
+
attachmentsDirectory: paths.attachmentsDirectory,
|
|
771
774
|
childIdsByParentId,
|
|
772
775
|
codexHome: paths.codexHome,
|
|
773
776
|
desktopStateBackupPath: paths.desktopStateBackupPath,
|
|
@@ -787,6 +790,7 @@ export async function loadSessionStore({ codexHome }) {
|
|
|
787
790
|
stateDatabasePath: paths.stateDatabasePath,
|
|
788
791
|
stateDatabasePaths: paths.stateDatabasePaths,
|
|
789
792
|
stateDatabases: paths.stateDatabases,
|
|
793
|
+
threadWriterLocksDirectory: paths.threadWriterLocksDirectory,
|
|
790
794
|
resolvedDatabases: paths.resolvedDatabases,
|
|
791
795
|
logsDatabasePaths: paths.logsDatabasePaths,
|
|
792
796
|
memoryDatabasePaths: paths.memoryDatabasePaths,
|
|
@@ -850,6 +854,16 @@ export async function diagnoseStorageCompatibility({ codexHome }) {
|
|
|
850
854
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".sqlite") && !recognizedDatabases.has(entry.name))
|
|
851
855
|
.map((entry) => `Other local database found: ${entry.name}`)
|
|
852
856
|
.sort();
|
|
857
|
+
try {
|
|
858
|
+
const attachmentEntries = await fs.readdir(paths.attachmentsDirectory, { withFileTypes: true });
|
|
859
|
+
if (attachmentEntries.some((entry) => entry.name !== ".DS_Store")) {
|
|
860
|
+
newlyDiscovered.push("Session attachments are kept because their ownership cannot be verified safely.");
|
|
861
|
+
}
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (error?.code !== "ENOENT") {
|
|
864
|
+
newlyDiscovered.push("The session attachments folder could not be examined.");
|
|
865
|
+
}
|
|
866
|
+
}
|
|
853
867
|
const status = missing.length > 0 || changed.length > 0
|
|
854
868
|
? "unsupported"
|
|
855
869
|
: newlyDiscovered.length > 0
|
|
@@ -906,7 +920,14 @@ function stateSchema(database) {
|
|
|
906
920
|
const spawn = database.tables?.thread_spawn_edges?.exists !== undefined
|
|
907
921
|
? database.tables.thread_spawn_edges
|
|
908
922
|
: inspectSqliteTable(database.path, "thread_spawn_edges");
|
|
909
|
-
const
|
|
923
|
+
const dynamicTools = database.tables?.thread_dynamic_tools?.exists !== undefined
|
|
924
|
+
? database.tables.thread_dynamic_tools
|
|
925
|
+
: inspectSqliteTable(database.path, "thread_dynamic_tools");
|
|
926
|
+
const schema = {
|
|
927
|
+
columns,
|
|
928
|
+
hasDynamicTools: Boolean(dynamicTools?.exists),
|
|
929
|
+
hasSpawnEdges: Boolean(spawn?.exists),
|
|
930
|
+
};
|
|
910
931
|
stateSchemaCache.set(database, schema);
|
|
911
932
|
return schema;
|
|
912
933
|
}
|
|
@@ -1935,6 +1956,10 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1935
1956
|
|
|
1936
1957
|
return count;
|
|
1937
1958
|
}, 0);
|
|
1959
|
+
const dynamicToolRowCount = (store.stateDatabases ?? [{ path: store.stateDatabasePath }])
|
|
1960
|
+
.reduce((total, database) => total + (stateSchema(database).hasDynamicTools
|
|
1961
|
+
? countRowsForIds(database.path, "thread_dynamic_tools", "thread_id", deletionIds)
|
|
1962
|
+
: 0), 0);
|
|
1938
1963
|
const logRowCount = (store.logsDatabasePaths ?? [store.logsDatabasePath].filter(Boolean))
|
|
1939
1964
|
.reduce((total, databasePath) => total + countRowsForIds(databasePath, "logs", "thread_id", deletionIds), 0);
|
|
1940
1965
|
const memoryRowCount = (store.memoryDatabasePaths ?? [store.memoryDatabasePath].filter(Boolean))
|
|
@@ -1953,6 +1978,7 @@ export async function planSessionDeletion({ recordIds, store }) {
|
|
|
1953
1978
|
backup: store.hasDesktopStateBackup ? "pending" : "absent",
|
|
1954
1979
|
current: store.hasDesktopState ? "pending" : "absent",
|
|
1955
1980
|
},
|
|
1981
|
+
dynamicToolRowCount,
|
|
1956
1982
|
goalRowCount,
|
|
1957
1983
|
historyMatchCount: historyMatches.count,
|
|
1958
1984
|
ids: deletionIds,
|
|
@@ -2016,6 +2042,7 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
2016
2042
|
hash.update(`${store.codexHome}\0${scope}\0`);
|
|
2017
2043
|
|
|
2018
2044
|
const counts = [
|
|
2045
|
+
plan.dynamicToolRowCount,
|
|
2019
2046
|
plan.historyMatchCount,
|
|
2020
2047
|
plan.logRowCount,
|
|
2021
2048
|
plan.sessionIndexMatchCount,
|
|
@@ -2053,6 +2080,9 @@ export async function fingerprintSessionDeletion({ plan, scope, store }) {
|
|
|
2053
2080
|
|
|
2054
2081
|
for (const database of store.stateDatabases ?? [{ path: store.stateDatabasePath }]) {
|
|
2055
2082
|
fingerprintRowsForIds(hash, database.path, "threads", "id", plan.ids);
|
|
2083
|
+
if (stateSchema(database).hasDynamicTools) {
|
|
2084
|
+
fingerprintRowsForIds(hash, database.path, "thread_dynamic_tools", "thread_id", plan.ids);
|
|
2085
|
+
}
|
|
2056
2086
|
if (stateSchema(database).hasSpawnEdges) {
|
|
2057
2087
|
fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "parent_thread_id", plan.ids);
|
|
2058
2088
|
fingerprintRowsForIds(hash, database.path, "thread_spawn_edges", "child_thread_id", plan.ids);
|
|
@@ -2129,6 +2159,36 @@ async function getAvailableDiskBytes(directoryPath) {
|
|
|
2129
2159
|
return stats.bavail * stats.bsize;
|
|
2130
2160
|
}
|
|
2131
2161
|
|
|
2162
|
+
async function findActiveThreadIds(store, ids) {
|
|
2163
|
+
if (!store.threadWriterLocksDirectory) return { available: false, ids: [] };
|
|
2164
|
+
|
|
2165
|
+
let entries;
|
|
2166
|
+
try {
|
|
2167
|
+
entries = await fs.readdir(store.threadWriterLocksDirectory, { withFileTypes: true });
|
|
2168
|
+
} catch (error) {
|
|
2169
|
+
if (error?.code === "ENOENT") return { available: false, ids: [] };
|
|
2170
|
+
throw error;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const lockedIds = new Set(entries
|
|
2174
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".lock"))
|
|
2175
|
+
.map((entry) => entry.name.slice(0, -5)));
|
|
2176
|
+
return {
|
|
2177
|
+
available: true,
|
|
2178
|
+
ids: ids.filter((id) => lockedIds.has(id)),
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function activeThreadError(ids) {
|
|
2183
|
+
const error = new Error(
|
|
2184
|
+
ids.length === 1
|
|
2185
|
+
? "Close the selected Codex session before cleanup."
|
|
2186
|
+
: `Close the ${ids.length} selected Codex sessions that are still open before cleanup.`,
|
|
2187
|
+
);
|
|
2188
|
+
error.activeThreadIds = ids;
|
|
2189
|
+
return error;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2132
2192
|
function formatBytes(bytes) {
|
|
2133
2193
|
if (bytes < 1024) {
|
|
2134
2194
|
return `${bytes} bytes`;
|
|
@@ -2164,6 +2224,9 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope
|
|
|
2164
2224
|
throw new Error("The selected Codex home is missing a required session store.");
|
|
2165
2225
|
}
|
|
2166
2226
|
|
|
2227
|
+
const activeThreads = await findActiveThreadIds(store, plan.ids);
|
|
2228
|
+
if (activeThreads.ids.length > 0) throw activeThreadError(activeThreads.ids);
|
|
2229
|
+
|
|
2167
2230
|
const deletedIdSet = new Set(plan.ids);
|
|
2168
2231
|
const desktopStates = await Promise.all([
|
|
2169
2232
|
store.hasDesktopState ? readJsonFile(store.desktopStatePath) : null,
|
|
@@ -2192,7 +2255,7 @@ export async function preflightSessionDeletion({ availableDiskBytes, plan, scope
|
|
|
2192
2255
|
backup: store.hasDesktopStateBackup ? "supported" : "absent",
|
|
2193
2256
|
current: store.hasDesktopState ? "supported" : "absent",
|
|
2194
2257
|
},
|
|
2195
|
-
activeThreadDetection: "unavailable",
|
|
2258
|
+
activeThreadDetection: activeThreads.available ? "writer-locks" : "unavailable",
|
|
2196
2259
|
missingRequiredPaths,
|
|
2197
2260
|
};
|
|
2198
2261
|
}
|
|
@@ -2384,6 +2447,11 @@ export async function executeSessionDeletion({
|
|
|
2384
2447
|
throw cleanupCancelled(backupDirectory);
|
|
2385
2448
|
}
|
|
2386
2449
|
|
|
2450
|
+
const activeThreads = await findActiveThreadIds(store, plan.ids);
|
|
2451
|
+
if (activeThreads.ids.length > 0) {
|
|
2452
|
+
throw cleanupErrorWithBackup(activeThreadError(activeThreads.ids), backupDirectory);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2387
2455
|
await reportProgress(onProgress, {
|
|
2388
2456
|
canCancel: false,
|
|
2389
2457
|
message: "Removing selected session data",
|
|
@@ -2413,10 +2481,14 @@ export async function executeSessionDeletion({
|
|
|
2413
2481
|
}
|
|
2414
2482
|
for (const { database, ids } of plan.stateTargets ?? (store.stateDatabases ?? [{ path: store.stateDatabasePath }]).map((database) => ({ database, ids: plan.ids }))) {
|
|
2415
2483
|
const stateStatements = [];
|
|
2416
|
-
const
|
|
2484
|
+
const schema = stateSchema(database);
|
|
2417
2485
|
for (const idBatch of batches(ids)) {
|
|
2418
2486
|
const idPlaceholders = placeholders(idBatch);
|
|
2419
|
-
if (
|
|
2487
|
+
if (schema.hasDynamicTools) stateStatements.push({
|
|
2488
|
+
parameters: idBatch,
|
|
2489
|
+
sql: `delete from thread_dynamic_tools where thread_id in (${idPlaceholders})`,
|
|
2490
|
+
});
|
|
2491
|
+
if (schema.hasSpawnEdges) stateStatements.push({
|
|
2420
2492
|
parameters: [...idBatch, ...idBatch],
|
|
2421
2493
|
sql: `delete from thread_spawn_edges where parent_thread_id in (${idPlaceholders}) or child_thread_id in (${idPlaceholders})`,
|
|
2422
2494
|
});
|
|
@@ -2749,6 +2821,10 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
2749
2821
|
const deletedIdSet = new Set(plan.ids);
|
|
2750
2822
|
const remainingThreads = (store.stateDatabasePaths ?? [store.stateDatabasePath])
|
|
2751
2823
|
.flatMap((databasePath) => findRowsForIds(databasePath, "threads", "id", plan.ids));
|
|
2824
|
+
const remainingDynamicTools = (store.stateDatabases ?? [{ path: store.stateDatabasePath }])
|
|
2825
|
+
.flatMap((database) => stateSchema(database).hasDynamicTools
|
|
2826
|
+
? findRowsForIds(database.path, "thread_dynamic_tools", "thread_id", plan.ids)
|
|
2827
|
+
: []);
|
|
2752
2828
|
const remainingMemoryRecords = scope === "deep" && store.hasMemoryDatabase
|
|
2753
2829
|
? (store.memoryDatabasePaths ?? [store.memoryDatabasePath]).flatMap((databasePath) =>
|
|
2754
2830
|
findRowsForIds(databasePath, "stage1_outputs", "thread_id", plan.ids))
|
|
@@ -2804,6 +2880,7 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
2804
2880
|
return {
|
|
2805
2881
|
complete:
|
|
2806
2882
|
remainingThreads.length === 0 &&
|
|
2883
|
+
remainingDynamicTools.length === 0 &&
|
|
2807
2884
|
remainingMemoryRecords.length === 0 &&
|
|
2808
2885
|
remainingGoalRecords.length === 0 &&
|
|
2809
2886
|
remainingLogRecords.length === 0 &&
|
|
@@ -2812,6 +2889,7 @@ export async function verifySessionDeletion({ plan, scope = "deep", store }) {
|
|
|
2812
2889
|
remainingTranscriptPaths.length === 0 &&
|
|
2813
2890
|
remainingDesktopStateReferences.length === 0,
|
|
2814
2891
|
remainingDesktopStateReferences,
|
|
2892
|
+
remainingDynamicTools,
|
|
2815
2893
|
remainingGoalRecords,
|
|
2816
2894
|
remainingHistoryEntries,
|
|
2817
2895
|
remainingHistoryEntryCount: historyMatches.count,
|
package/lib/server.mjs
CHANGED
|
@@ -6,6 +6,10 @@ import path from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
8
|
import { getProvider, listProviders } from "./providers/index.mjs";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SESSION_EVENT_LIMIT,
|
|
11
|
+
MAX_SESSION_EVENT_LIMIT,
|
|
12
|
+
} from "./session-event-reader.mjs";
|
|
9
13
|
import { createProviderSettings } from "./settings.mjs";
|
|
10
14
|
import { classifyInstalledVersion } from "./version-support.mjs";
|
|
11
15
|
|
|
@@ -174,6 +178,35 @@ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER)
|
|
|
174
178
|
return Math.min(parsed, maximum);
|
|
175
179
|
}
|
|
176
180
|
|
|
181
|
+
function getSessionEventLimit(value) {
|
|
182
|
+
if (value === null) return DEFAULT_SESSION_EVENT_LIMIT;
|
|
183
|
+
if (!/^\d+$/u.test(value)) {
|
|
184
|
+
throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const limit = Number(value);
|
|
188
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
|
|
189
|
+
throw new Error(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return limit;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getSessionId(value) {
|
|
196
|
+
if (
|
|
197
|
+
typeof value !== "string"
|
|
198
|
+
|| value.length === 0
|
|
199
|
+
|| value.length > 512
|
|
200
|
+
|| value.trim() !== value
|
|
201
|
+
|| value.includes("\0")
|
|
202
|
+
|| /[\\/]/u.test(value)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error("Enter a valid session ID.");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
function getInactiveBeforeMs(value) {
|
|
178
211
|
if (value === null || value === "") {
|
|
179
212
|
return null;
|
|
@@ -811,10 +844,38 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
|
|
|
811
844
|
return;
|
|
812
845
|
}
|
|
813
846
|
|
|
847
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/session-events") {
|
|
848
|
+
const controller = new AbortController();
|
|
849
|
+
const abortRead = () => controller.abort();
|
|
850
|
+
request.once("aborted", abortRead);
|
|
851
|
+
response.once("close", () => {
|
|
852
|
+
if (!response.writableEnded) abortRead();
|
|
853
|
+
});
|
|
854
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
855
|
+
const provider = getProvider(providerId);
|
|
856
|
+
const id = getSessionId(requestUrl.searchParams.get("id"));
|
|
857
|
+
const result = await provider.readSessionEvents({
|
|
858
|
+
...providerOptions(providerId, settings.getHome(providerId)),
|
|
859
|
+
id,
|
|
860
|
+
limit: getSessionEventLimit(requestUrl.searchParams.get("limit")),
|
|
861
|
+
signal: controller.signal,
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
if (controller.signal.aborted) return;
|
|
865
|
+
|
|
866
|
+
if (!result) {
|
|
867
|
+
sendJson(response, 404, { error: "Session not found." });
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
sendJson(response, 200, result);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
814
875
|
if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
|
|
815
876
|
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
816
877
|
const provider = getProvider(providerId);
|
|
817
|
-
const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
|
|
878
|
+
const id = getSessionId(decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length)));
|
|
818
879
|
const record = await provider.getSessionRecord({
|
|
819
880
|
...providerOptions(providerId, settings.getHome(providerId)),
|
|
820
881
|
id,
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESSION_EVENT_READ_MODE,
|
|
3
|
+
SESSION_EVENT_WINDOW_END,
|
|
4
|
+
} from "./session-events.mjs";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SESSION_EVENT_LIMIT = 100;
|
|
7
|
+
export const MAX_SESSION_EVENT_LIMIT = 1_000;
|
|
8
|
+
|
|
9
|
+
const MAX_PENDING_SESSION_EVENTS = 2_048;
|
|
10
|
+
const MAX_UNMAPPED_SESSION_EVENT_TYPES = 128;
|
|
11
|
+
const SESSION_EVENT_READ_MODES = new Set(Object.values(SESSION_EVENT_READ_MODE));
|
|
12
|
+
const WRAPPED_CONTEXT_PATTERN = /^<([A-Za-z][\w:.-]*[_-][\w:.-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/u;
|
|
13
|
+
|
|
14
|
+
export function isInjectedSessionAsk(value) {
|
|
15
|
+
if (typeof value !== "string") return false;
|
|
16
|
+
let remaining = value.trim();
|
|
17
|
+
if (!remaining) return false;
|
|
18
|
+
if (/^#{1,6}\s+[^\n]*\binstructions?\b/iu.test(remaining)) return true;
|
|
19
|
+
let matched = false;
|
|
20
|
+
|
|
21
|
+
while (remaining) {
|
|
22
|
+
const match = WRAPPED_CONTEXT_PATTERN.exec(remaining);
|
|
23
|
+
if (!match) return false;
|
|
24
|
+
matched = true;
|
|
25
|
+
remaining = remaining.slice(match[0].length).trimStart();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return matched;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createSessionEventReadState({
|
|
32
|
+
limit = DEFAULT_SESSION_EVENT_LIMIT,
|
|
33
|
+
mode = SESSION_EVENT_READ_MODE.RECENT,
|
|
34
|
+
} = {}) {
|
|
35
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_SESSION_EVENT_LIMIT) {
|
|
36
|
+
throw new TypeError(`limit must be between 1 and ${MAX_SESSION_EVENT_LIMIT}.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!SESSION_EVENT_READ_MODES.has(mode)) {
|
|
40
|
+
throw new TypeError(`Unsupported session event read mode: ${mode}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const events = new Array(limit);
|
|
44
|
+
const pending = new Map();
|
|
45
|
+
const pendingIds = new WeakMap();
|
|
46
|
+
let length = 0;
|
|
47
|
+
let start = 0;
|
|
48
|
+
|
|
49
|
+
function untrack(event) {
|
|
50
|
+
const pendingId = pendingIds.get(event);
|
|
51
|
+
if (pendingId && pending.get(pendingId) === event) pending.delete(pendingId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
add(event, { pendingId = null } = {}) {
|
|
56
|
+
if (pendingId) {
|
|
57
|
+
pending.delete(pendingId);
|
|
58
|
+
pending.set(pendingId, event);
|
|
59
|
+
pendingIds.set(event, pendingId);
|
|
60
|
+
|
|
61
|
+
while (pending.size > MAX_PENDING_SESSION_EVENTS) {
|
|
62
|
+
pending.delete(pending.keys().next().value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (length < limit) {
|
|
67
|
+
events[(start + length) % limit] = event;
|
|
68
|
+
length += 1;
|
|
69
|
+
} else if (mode === SESSION_EVENT_READ_MODE.RECENT) {
|
|
70
|
+
untrack(events[start]);
|
|
71
|
+
events[start] = event;
|
|
72
|
+
start = (start + 1) % limit;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return mode === SESSION_EVENT_READ_MODE.PREVIEW && length === limit;
|
|
76
|
+
},
|
|
77
|
+
resolve(pendingId, update, { consume = true } = {}) {
|
|
78
|
+
const event = pending.get(pendingId);
|
|
79
|
+
if (!event) return false;
|
|
80
|
+
if (consume) pending.delete(pendingId);
|
|
81
|
+
update(event);
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
values() {
|
|
85
|
+
return Array.from({ length }, (_, index) => events[(start + index) % limit]);
|
|
86
|
+
},
|
|
87
|
+
window({ complete, stoppedEarly }) {
|
|
88
|
+
return {
|
|
89
|
+
complete,
|
|
90
|
+
end: stoppedEarly
|
|
91
|
+
? SESSION_EVENT_WINDOW_END.OLDEST
|
|
92
|
+
: complete
|
|
93
|
+
? SESSION_EVENT_WINDOW_END.NEWEST
|
|
94
|
+
: SESSION_EVENT_WINDOW_END.PARTIAL,
|
|
95
|
+
outcomesMayBeUnresolved: stoppedEarly || !complete,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createUnmappedSessionEventTracker() {
|
|
102
|
+
const counts = new Map();
|
|
103
|
+
let other = 0;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
add(type) {
|
|
107
|
+
const normalizedType = typeof type === "string" && type.trim()
|
|
108
|
+
? type.trim()
|
|
109
|
+
: "unknown";
|
|
110
|
+
if (counts.has(normalizedType)) {
|
|
111
|
+
counts.set(normalizedType, counts.get(normalizedType) + 1);
|
|
112
|
+
} else if (counts.size < MAX_UNMAPPED_SESSION_EVENT_TYPES) {
|
|
113
|
+
counts.set(normalizedType, 1);
|
|
114
|
+
} else {
|
|
115
|
+
other += 1;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
values(limit = 5) {
|
|
119
|
+
const entries = [...counts].map(([type, count]) => ({ count, type }));
|
|
120
|
+
if (other > 0) entries.push({ count: other, type: "other unmapped types" });
|
|
121
|
+
return entries
|
|
122
|
+
.sort((left, right) => right.count - left.count || left.type.localeCompare(right.type))
|
|
123
|
+
.slice(0, limit);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|