sidekick-shared 0.26.4 → 0.26.6
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 +52 -0
- package/README.md +59 -51
- package/dist/accountChanges.js +21 -2
- package/dist/accountHealth.d.ts +72 -0
- package/dist/accountHealth.js +286 -0
- package/dist/accountLaunch.d.ts +30 -0
- package/dist/accountLaunch.js +124 -0
- package/dist/accountManager.d.ts +33 -8
- package/dist/accountManager.js +183 -50
- package/dist/accountRegistry.d.ts +10 -0
- package/dist/accountRegistry.js +42 -22
- package/dist/accountSwitch.d.ts +43 -0
- package/dist/accountSwitch.js +115 -0
- package/dist/accountSync.d.ts +30 -0
- package/dist/accountSync.js +180 -0
- package/dist/accountSyncTypes.d.ts +25 -0
- package/dist/accountSyncTypes.js +7 -0
- package/dist/accounts.d.ts +58 -6
- package/dist/accounts.js +408 -189
- package/dist/claudeCredentials.d.ts +38 -0
- package/dist/claudeCredentials.js +74 -0
- package/dist/claudeProfiles.d.ts +40 -0
- package/dist/claudeProfiles.js +102 -10
- package/dist/codexAuth.d.ts +32 -0
- package/dist/codexAuth.js +127 -0
- package/dist/codexPaths.d.ts +21 -0
- package/dist/codexPaths.js +117 -0
- package/dist/codexProfiles.d.ts +58 -5
- package/dist/codexProfiles.js +574 -289
- package/dist/credentialIO.d.ts +12 -5
- package/dist/credentialIO.js +63 -29
- package/dist/credentials.d.ts +8 -0
- package/dist/credentials.js +28 -32
- package/dist/ensureDefaultAccounts.d.ts +8 -0
- package/dist/ensureDefaultAccounts.js +42 -82
- package/dist/index.d.ts +31 -12
- package/dist/index.js +68 -13
- package/dist/keepAlive.d.ts +44 -0
- package/dist/keepAlive.js +106 -0
- package/dist/node.d.ts +4 -1
- package/dist/node.js +4 -1
- package/dist/observedSessionCollector.d.ts +117 -5
- package/dist/observedSessionCollector.js +483 -76
- package/dist/processDetection.d.ts +32 -0
- package/dist/processDetection.js +210 -0
- package/dist/providers/claudeCode.d.ts +23 -2
- package/dist/providers/claudeCode.js +143 -51
- package/dist/providers/codex.d.ts +10 -2
- package/dist/providers/codex.js +95 -12
- package/dist/providers/directoryListingCache.d.ts +85 -0
- package/dist/providers/directoryListingCache.js +244 -0
- package/dist/providers/rolloutWalker.d.ts +28 -5
- package/dist/providers/rolloutWalker.js +100 -16
- package/dist/providers/types.d.ts +48 -1
- package/dist/schemas/accountManager.d.ts +283 -1
- package/dist/schemas/accountManager.js +122 -1
- package/dist/schemas/assistantTurn.d.ts +2 -2
- package/dist/schemas/index.d.ts +6 -1
- package/dist/schemas/index.js +14 -1
- package/dist/schemas/observedSessionV1.d.ts +21 -21
- package/dist/schemas/quota.d.ts +18 -18
- package/dist/schemas/quotaHistory.d.ts +1 -1
- package/dist/schemas/stateFile.d.ts +5 -5
- package/dist/sessionMonitor.js +24 -2
- package/dist/terminalSync.d.ts +5 -5
- package/dist/terminalSync.js +7 -112
- package/dist/writers/atomic.d.ts +2 -0
- package/dist/writers/atomic.js +49 -2
- package/package.json +1 -1
package/dist/providers/codex.js
CHANGED
|
@@ -54,6 +54,7 @@ const codexParser_1 = require("../parsers/codexParser");
|
|
|
54
54
|
const codexDatabase_1 = require("./codexDatabase");
|
|
55
55
|
const codexProfiles_1 = require("../codexProfiles");
|
|
56
56
|
const rolloutWalker_1 = require("./rolloutWalker");
|
|
57
|
+
const directoryListingCache_1 = require("./directoryListingCache");
|
|
57
58
|
const diagnostics_1 = require("./diagnostics");
|
|
58
59
|
const modelContext_1 = require("../modelContext");
|
|
59
60
|
const observedContextWindows_1 = require("../observedContextWindows");
|
|
@@ -193,7 +194,13 @@ function readSessionMeta(rolloutPath) {
|
|
|
193
194
|
}
|
|
194
195
|
return null;
|
|
195
196
|
}
|
|
196
|
-
|
|
197
|
+
/**
|
|
198
|
+
* First line of a rollout, chunk-read under the same 256 KiB cap as
|
|
199
|
+
* readFirstLines(). `complete` is false while the line has no newline yet
|
|
200
|
+
* (a rollout that was just created) or the file could not be read, so
|
|
201
|
+
* callers know not to cache the answer.
|
|
202
|
+
*/
|
|
203
|
+
async function readFirstLineAsync(rolloutPath) {
|
|
197
204
|
// Mirrors readSessionMeta()/readFirstLines(): chunk-read until the first
|
|
198
205
|
// line is complete under the same 256 KiB cap — session_meta carries
|
|
199
206
|
// `instructions` and can far exceed a single chunk.
|
|
@@ -216,18 +223,26 @@ async function readSessionMetaAsync(rolloutPath) {
|
|
|
216
223
|
newlineFound = chunk.includes(0x0a);
|
|
217
224
|
}
|
|
218
225
|
const firstLine = Buffer.concat(chunks).toString('utf8').split('\n', 1)[0]?.trim();
|
|
219
|
-
|
|
220
|
-
return null;
|
|
221
|
-
const parsed = JSON.parse(firstLine);
|
|
222
|
-
return parsed.type === 'session_meta' ? parsed.payload : null;
|
|
226
|
+
return { line: firstLine || null, complete: newlineFound || totalRead >= maxBytes };
|
|
223
227
|
}
|
|
224
228
|
catch {
|
|
225
|
-
return null;
|
|
229
|
+
return { line: null, complete: false };
|
|
226
230
|
}
|
|
227
231
|
finally {
|
|
228
232
|
await handle?.close().catch(() => undefined);
|
|
229
233
|
}
|
|
230
234
|
}
|
|
235
|
+
function parseSessionMetaLine(line) {
|
|
236
|
+
if (!line)
|
|
237
|
+
return null;
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(line);
|
|
240
|
+
return parsed.type === 'session_meta' ? parsed.payload : null;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
231
246
|
/**
|
|
232
247
|
* Read the CWD from a rollout file's session_meta. Cached per path, bounded
|
|
233
248
|
* so a long-lived host watching a large history cannot grow it without limit
|
|
@@ -257,11 +272,23 @@ async function readSessionCwdAsync(rolloutPath) {
|
|
|
257
272
|
const cached = cwdCache.get(rolloutPath);
|
|
258
273
|
if (cached !== undefined)
|
|
259
274
|
return cached;
|
|
260
|
-
const
|
|
261
|
-
const cwd =
|
|
262
|
-
|
|
275
|
+
const { line, complete } = await readFirstLineAsync(rolloutPath);
|
|
276
|
+
const cwd = parseSessionMetaLine(line)?.cwd || null;
|
|
277
|
+
// A rollout observed before its session_meta line is flushed has no cwd
|
|
278
|
+
// yet; caching that null would hide the session from its workspace until
|
|
279
|
+
// the entry aged out, so only conclusive answers are remembered.
|
|
280
|
+
if (cwd !== null || complete)
|
|
281
|
+
rememberCwd(rolloutPath, cwd);
|
|
263
282
|
return cwd;
|
|
264
283
|
}
|
|
284
|
+
/** Built-in enumerators report explicit zeros rather than leaving a counter unset. */
|
|
285
|
+
function initializeCounters(stats) {
|
|
286
|
+
if (!stats)
|
|
287
|
+
return;
|
|
288
|
+
stats.directoriesListed ??= 0;
|
|
289
|
+
stats.directoriesStatted ??= 0;
|
|
290
|
+
stats.filesStatted ??= 0;
|
|
291
|
+
}
|
|
265
292
|
/** Check if a session's CWD matches a workspace path. */
|
|
266
293
|
function cwdMatches(sessionCwd, workspacePath) {
|
|
267
294
|
const normalizedSession = normalizePath(sessionCwd);
|
|
@@ -646,6 +673,8 @@ class CodexProvider {
|
|
|
646
673
|
dynamicContextWindowLimit = null;
|
|
647
674
|
lastRateLimitsData = null;
|
|
648
675
|
lastTokenUsageData = null;
|
|
676
|
+
/** Dated rollout-directory listings reused across async enumerations. */
|
|
677
|
+
directoryCache = new directoryListingCache_1.DirectoryListingCache();
|
|
649
678
|
constructor(options = {}) {
|
|
650
679
|
this.diagnostics = new diagnostics_1.ProviderDiagnosticTracker(this.id, options);
|
|
651
680
|
}
|
|
@@ -802,10 +831,22 @@ class CodexProvider {
|
|
|
802
831
|
this.recordDatabaseFallback('listAllSessionFiles', true);
|
|
803
832
|
return files;
|
|
804
833
|
}
|
|
805
|
-
async listSessionFilesAsync(workspacePath) {
|
|
834
|
+
async listSessionFilesAsync(workspacePath, options = {}) {
|
|
806
835
|
const results = [];
|
|
807
|
-
|
|
808
|
-
|
|
836
|
+
initializeCounters(options.stats);
|
|
837
|
+
// One capped walk across the homes (deduplicated, newest first) through
|
|
838
|
+
// the directory cache. With a workspace filter the limit applies after
|
|
839
|
+
// filtering, so the walk itself is bounded only by its file cap.
|
|
840
|
+
const files = await (0, rolloutWalker_1.walkRolloutFilesAsync)(getSessionsDirs(), {
|
|
841
|
+
limit: workspacePath ? undefined : options.limit,
|
|
842
|
+
since: options.since,
|
|
843
|
+
cache: this.directoryCache,
|
|
844
|
+
revalidateRecent: options.revalidateRecent,
|
|
845
|
+
stats: options.stats,
|
|
846
|
+
});
|
|
847
|
+
for (const file of files) {
|
|
848
|
+
if (options.limit !== undefined && results.length >= options.limit)
|
|
849
|
+
break;
|
|
809
850
|
const info = { ...file };
|
|
810
851
|
if (workspacePath) {
|
|
811
852
|
const cwd = await readSessionCwdAsync(file.path);
|
|
@@ -821,6 +862,47 @@ class CodexProvider {
|
|
|
821
862
|
this.recordDatabaseFallback('listSessionFilesAsync', true);
|
|
822
863
|
return results;
|
|
823
864
|
}
|
|
865
|
+
/**
|
|
866
|
+
* Resolve a recursive-watch event under a sessions root with one stat.
|
|
867
|
+
* Rollouts are matched by name at any depth the walker would visit; a
|
|
868
|
+
* dated directory appearing or vanishing needs a full walk.
|
|
869
|
+
*/
|
|
870
|
+
async statWatchedSessionFile(root, relativePath, workspacePath) {
|
|
871
|
+
const segments = (0, directoryListingCache_1.splitWatchedPath)(relativePath);
|
|
872
|
+
if (!segments)
|
|
873
|
+
return { status: 'unknown' };
|
|
874
|
+
const fullPath = path.join(root, ...segments);
|
|
875
|
+
const name = segments[segments.length - 1];
|
|
876
|
+
if (segments.length - 1 > rolloutWalker_1.DEFAULT_ROLLOUT_WALK_MAX_DEPTH)
|
|
877
|
+
return { status: 'ignored' };
|
|
878
|
+
if (!(0, rolloutWalker_1.isRolloutFile)(name)) {
|
|
879
|
+
this.directoryCache.invalidate(fullPath);
|
|
880
|
+
try {
|
|
881
|
+
const stat = await fs.promises.stat(fullPath);
|
|
882
|
+
return stat.isDirectory() ? { status: 'unknown' } : { status: 'ignored' };
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
return { status: 'unknown' };
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const sessionId = (0, rolloutWalker_1.extractRolloutSessionId)(name);
|
|
889
|
+
const stat = await this.directoryCache.refreshFile(fullPath);
|
|
890
|
+
if (!stat || stat.sizeBytes <= 0)
|
|
891
|
+
return { status: 'missing', path: fullPath, sessionId };
|
|
892
|
+
const file = {
|
|
893
|
+
path: fullPath,
|
|
894
|
+
mtime: new Date(stat.mtimeMs),
|
|
895
|
+
sizeBytes: stat.sizeBytes,
|
|
896
|
+
sessionId,
|
|
897
|
+
};
|
|
898
|
+
if (workspacePath) {
|
|
899
|
+
const cwd = await readSessionCwdAsync(fullPath);
|
|
900
|
+
if (!cwd || !cwdMatches(cwd, workspacePath))
|
|
901
|
+
return { status: 'ignored' };
|
|
902
|
+
file.workspacePath = cwd;
|
|
903
|
+
}
|
|
904
|
+
return { status: 'present', file };
|
|
905
|
+
}
|
|
824
906
|
getAllProjectFolders(workspacePath) {
|
|
825
907
|
const folders = [];
|
|
826
908
|
const seenCwds = new Map();
|
|
@@ -1153,6 +1235,7 @@ class CodexProvider {
|
|
|
1153
1235
|
this.dynamicContextWindowLimit = null;
|
|
1154
1236
|
this.lastRateLimitsData = null;
|
|
1155
1237
|
this.lastTokenUsageData = null;
|
|
1238
|
+
this.directoryCache.invalidate();
|
|
1156
1239
|
}
|
|
1157
1240
|
recordDatabaseFallback(operation, usable) {
|
|
1158
1241
|
if (!this.dbInitialized) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory listings keyed by path and validated by the directory's own
|
|
3
|
+
* mtime and inode.
|
|
4
|
+
*
|
|
5
|
+
* A create, delete, or rename inside a directory bumps that directory's
|
|
6
|
+
* mtime; an append to an existing file does not. So a listing can be reused
|
|
7
|
+
* for as long as the directory's mtime and inode are unchanged, and the per
|
|
8
|
+
* file size/mtime it carries is only refreshed by `refreshFile()` (the watch
|
|
9
|
+
* event path) or by `revalidateRecent` on catch-up passes. Full discovery is
|
|
10
|
+
* then one stat per directory instead of one readdir per directory plus one
|
|
11
|
+
* stat per file.
|
|
12
|
+
*
|
|
13
|
+
* @module providers/directoryListingCache
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
export interface CachedFileStat {
|
|
17
|
+
sizeBytes: number;
|
|
18
|
+
mtimeMs: number;
|
|
19
|
+
}
|
|
20
|
+
export interface CachedDirectoryListing {
|
|
21
|
+
/** Directory mtime when the listing was taken. */
|
|
22
|
+
mtimeMs: number;
|
|
23
|
+
ino: number;
|
|
24
|
+
/** Wall-clock time the listing was taken (coarse-mtime guard). */
|
|
25
|
+
listedAtMs: number;
|
|
26
|
+
/** Files accepted by the caller's filter, with the stat seen last. */
|
|
27
|
+
files: Map<string, CachedFileStat>;
|
|
28
|
+
/** Subdirectory names, unsorted. */
|
|
29
|
+
subdirectories: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Payload-free counters a caller can pass to measure what a pass cost. */
|
|
32
|
+
export interface DirectoryListingCounters {
|
|
33
|
+
directoriesListed?: number;
|
|
34
|
+
directoriesStatted?: number;
|
|
35
|
+
filesStatted?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface DirectoryListingCacheOptions {
|
|
38
|
+
/** Bound on cached directories, least-recently-used first (default 10 000). */
|
|
39
|
+
maxDirectories?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Files whose cached mtime is within this window are re-stat'ed when a
|
|
42
|
+
* caller asks for `revalidateRecent` (default 30 minutes).
|
|
43
|
+
*/
|
|
44
|
+
recentWindowMs?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface ListDirectoryOptions {
|
|
47
|
+
/** Re-stat files modified within the recent window even if the directory is unchanged. */
|
|
48
|
+
revalidateRecent?: boolean;
|
|
49
|
+
/** A stat of the directory the caller already took; saves one syscall. */
|
|
50
|
+
dirStat?: fs.Stats;
|
|
51
|
+
stats?: DirectoryListingCounters;
|
|
52
|
+
}
|
|
53
|
+
export declare const DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES = 10000;
|
|
54
|
+
export declare const DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS: number;
|
|
55
|
+
export declare class DirectoryListingCache {
|
|
56
|
+
private readonly listings;
|
|
57
|
+
private readonly maxDirectories;
|
|
58
|
+
private readonly recentWindowMs;
|
|
59
|
+
constructor(options?: DirectoryListingCacheOptions);
|
|
60
|
+
get size(): number;
|
|
61
|
+
/** The cached listing without touching the filesystem, if any. */
|
|
62
|
+
peek(dir: string): CachedDirectoryListing | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* List `dir`, reusing the cached listing when the directory is unchanged.
|
|
65
|
+
* Returns null when `dir` is missing, unreadable, or not a directory.
|
|
66
|
+
*/
|
|
67
|
+
listDirectory(dir: string, accept: (name: string) => boolean, options?: ListDirectoryOptions): Promise<CachedDirectoryListing | null>;
|
|
68
|
+
/**
|
|
69
|
+
* Stat one file and reflect the result in its parent's cached listing when
|
|
70
|
+
* that listing exists. Returns null when the path is missing or not a file.
|
|
71
|
+
* Never creates a listing: a partial listing must not pass for a full one.
|
|
72
|
+
*/
|
|
73
|
+
refreshFile(fullPath: string, stats?: DirectoryListingCounters): Promise<CachedFileStat | null>;
|
|
74
|
+
/** Drop one directory's listing, or every listing. */
|
|
75
|
+
invalidate(dir?: string): void;
|
|
76
|
+
private revalidateRecent;
|
|
77
|
+
private isRecent;
|
|
78
|
+
private touch;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Split a watcher-reported root-relative path into segments. Both separators
|
|
82
|
+
* are accepted (Windows watchers report backslashes). Returns null for an
|
|
83
|
+
* empty path or one that escapes the root.
|
|
84
|
+
*/
|
|
85
|
+
export declare function splitWatchedPath(relativePath: string): string[] | null;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Directory listings keyed by path and validated by the directory's own
|
|
4
|
+
* mtime and inode.
|
|
5
|
+
*
|
|
6
|
+
* A create, delete, or rename inside a directory bumps that directory's
|
|
7
|
+
* mtime; an append to an existing file does not. So a listing can be reused
|
|
8
|
+
* for as long as the directory's mtime and inode are unchanged, and the per
|
|
9
|
+
* file size/mtime it carries is only refreshed by `refreshFile()` (the watch
|
|
10
|
+
* event path) or by `revalidateRecent` on catch-up passes. Full discovery is
|
|
11
|
+
* then one stat per directory instead of one readdir per directory plus one
|
|
12
|
+
* stat per file.
|
|
13
|
+
*
|
|
14
|
+
* @module providers/directoryListingCache
|
|
15
|
+
*/
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.DirectoryListingCache = exports.DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS = exports.DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES = void 0;
|
|
51
|
+
exports.splitWatchedPath = splitWatchedPath;
|
|
52
|
+
const fs = __importStar(require("fs"));
|
|
53
|
+
const path = __importStar(require("path"));
|
|
54
|
+
exports.DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES = 10_000;
|
|
55
|
+
exports.DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS = 30 * 60_000;
|
|
56
|
+
/**
|
|
57
|
+
* A directory whose mtime is within this many milliseconds of the moment it
|
|
58
|
+
* was listed may still receive entries that land on the same coarse mtime
|
|
59
|
+
* (HFS+, exFAT, SMB), so it is re-listed rather than trusted.
|
|
60
|
+
*/
|
|
61
|
+
const COARSE_MTIME_GUARD_MS = 2_000;
|
|
62
|
+
class DirectoryListingCache {
|
|
63
|
+
listings = new Map();
|
|
64
|
+
maxDirectories;
|
|
65
|
+
recentWindowMs;
|
|
66
|
+
constructor(options = {}) {
|
|
67
|
+
this.maxDirectories = Math.max(1, Math.floor(options.maxDirectories ?? exports.DEFAULT_DIRECTORY_CACHE_MAX_DIRECTORIES));
|
|
68
|
+
this.recentWindowMs = Math.max(0, options.recentWindowMs ?? exports.DEFAULT_DIRECTORY_CACHE_RECENT_WINDOW_MS);
|
|
69
|
+
}
|
|
70
|
+
get size() {
|
|
71
|
+
return this.listings.size;
|
|
72
|
+
}
|
|
73
|
+
/** The cached listing without touching the filesystem, if any. */
|
|
74
|
+
peek(dir) {
|
|
75
|
+
return this.listings.get(dir);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* List `dir`, reusing the cached listing when the directory is unchanged.
|
|
79
|
+
* Returns null when `dir` is missing, unreadable, or not a directory.
|
|
80
|
+
*/
|
|
81
|
+
async listDirectory(dir, accept, options = {}) {
|
|
82
|
+
let dirStat = options.dirStat;
|
|
83
|
+
if (!dirStat) {
|
|
84
|
+
try {
|
|
85
|
+
dirStat = await fs.promises.stat(dir);
|
|
86
|
+
count(options.stats, 'directoriesStatted');
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
this.listings.delete(dir);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!dirStat.isDirectory()) {
|
|
94
|
+
this.listings.delete(dir);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const cached = this.listings.get(dir);
|
|
98
|
+
const unchanged = cached !== undefined &&
|
|
99
|
+
cached.mtimeMs === dirStat.mtimeMs &&
|
|
100
|
+
cached.ino === dirStat.ino &&
|
|
101
|
+
dirStat.mtimeMs < cached.listedAtMs - COARSE_MTIME_GUARD_MS;
|
|
102
|
+
if (cached && unchanged) {
|
|
103
|
+
this.touch(dir, cached);
|
|
104
|
+
if (options.revalidateRecent)
|
|
105
|
+
await this.revalidateRecent(dir, cached, options.stats);
|
|
106
|
+
return cached;
|
|
107
|
+
}
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
111
|
+
count(options.stats, 'directoriesListed');
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
this.listings.delete(dir);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const listedAtMs = Date.now();
|
|
118
|
+
const files = new Map();
|
|
119
|
+
const subdirectories = [];
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
subdirectories.push(entry.name);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const isCandidateFile = entry.isFile() && accept(entry.name);
|
|
126
|
+
if (!isCandidateFile && !entry.isSymbolicLink())
|
|
127
|
+
continue;
|
|
128
|
+
const fullPath = path.join(dir, entry.name);
|
|
129
|
+
// A file already in the listing keeps its cached stat unless it is
|
|
130
|
+
// recent enough to be live; a directory change is a create, delete, or
|
|
131
|
+
// rename, and only the new names need a fresh stat.
|
|
132
|
+
const previous = cached?.files.get(entry.name);
|
|
133
|
+
if (isCandidateFile && previous && !this.isRecent(previous, listedAtMs)) {
|
|
134
|
+
files.set(entry.name, previous);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (entry.isSymbolicLink() && !accept(entry.name)) {
|
|
138
|
+
// Symlinked directories are followed so linked project folders count.
|
|
139
|
+
try {
|
|
140
|
+
const target = await fs.promises.stat(fullPath);
|
|
141
|
+
count(options.stats, 'directoriesStatted');
|
|
142
|
+
if (target.isDirectory())
|
|
143
|
+
subdirectories.push(entry.name);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Dangling link.
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const stat = await fs.promises.stat(fullPath);
|
|
152
|
+
count(options.stats, 'filesStatted');
|
|
153
|
+
if (stat.isFile())
|
|
154
|
+
files.set(entry.name, { sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
155
|
+
else if (stat.isDirectory())
|
|
156
|
+
subdirectories.push(entry.name);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Vanished between readdir and stat.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const listing = {
|
|
163
|
+
mtimeMs: dirStat.mtimeMs,
|
|
164
|
+
ino: dirStat.ino,
|
|
165
|
+
listedAtMs,
|
|
166
|
+
files,
|
|
167
|
+
subdirectories,
|
|
168
|
+
};
|
|
169
|
+
this.touch(dir, listing);
|
|
170
|
+
return listing;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Stat one file and reflect the result in its parent's cached listing when
|
|
174
|
+
* that listing exists. Returns null when the path is missing or not a file.
|
|
175
|
+
* Never creates a listing: a partial listing must not pass for a full one.
|
|
176
|
+
*/
|
|
177
|
+
async refreshFile(fullPath, stats) {
|
|
178
|
+
const dir = path.dirname(fullPath);
|
|
179
|
+
const name = path.basename(fullPath);
|
|
180
|
+
const listing = this.listings.get(dir);
|
|
181
|
+
try {
|
|
182
|
+
const stat = await fs.promises.stat(fullPath);
|
|
183
|
+
count(stats, 'filesStatted');
|
|
184
|
+
if (!stat.isFile()) {
|
|
185
|
+
listing?.files.delete(name);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const fileStat = { sizeBytes: stat.size, mtimeMs: stat.mtimeMs };
|
|
189
|
+
listing?.files.set(name, fileStat);
|
|
190
|
+
return fileStat;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
listing?.files.delete(name);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Drop one directory's listing, or every listing. */
|
|
198
|
+
invalidate(dir) {
|
|
199
|
+
if (dir === undefined)
|
|
200
|
+
this.listings.clear();
|
|
201
|
+
else
|
|
202
|
+
this.listings.delete(dir);
|
|
203
|
+
}
|
|
204
|
+
async revalidateRecent(dir, listing, stats) {
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
for (const [name, cached] of [...listing.files]) {
|
|
207
|
+
if (!this.isRecent(cached, now))
|
|
208
|
+
continue;
|
|
209
|
+
await this.refreshFile(path.join(dir, name), stats);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
isRecent(stat, now) {
|
|
213
|
+
return now - stat.mtimeMs <= this.recentWindowMs;
|
|
214
|
+
}
|
|
215
|
+
touch(dir, listing) {
|
|
216
|
+
this.listings.delete(dir);
|
|
217
|
+
this.listings.set(dir, listing);
|
|
218
|
+
while (this.listings.size > this.maxDirectories) {
|
|
219
|
+
const oldest = this.listings.keys().next().value;
|
|
220
|
+
if (oldest === undefined)
|
|
221
|
+
break;
|
|
222
|
+
this.listings.delete(oldest);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
exports.DirectoryListingCache = DirectoryListingCache;
|
|
227
|
+
/**
|
|
228
|
+
* Split a watcher-reported root-relative path into segments. Both separators
|
|
229
|
+
* are accepted (Windows watchers report backslashes). Returns null for an
|
|
230
|
+
* empty path or one that escapes the root.
|
|
231
|
+
*/
|
|
232
|
+
function splitWatchedPath(relativePath) {
|
|
233
|
+
const segments = relativePath.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
|
234
|
+
if (segments.length === 0)
|
|
235
|
+
return null;
|
|
236
|
+
if (segments.some((segment) => segment === '.' || segment === '..'))
|
|
237
|
+
return null;
|
|
238
|
+
return segments;
|
|
239
|
+
}
|
|
240
|
+
function count(stats, field) {
|
|
241
|
+
if (!stats)
|
|
242
|
+
return;
|
|
243
|
+
stats[field] = (stats[field] ?? 0) + 1;
|
|
244
|
+
}
|
|
@@ -8,14 +8,20 @@
|
|
|
8
8
|
* `limit` can stop early, stats each file exactly once, and returns results
|
|
9
9
|
* newest first with the size and session id already attached.
|
|
10
10
|
*
|
|
11
|
-
* With `limit`, the early exit is by directory date: a rollout
|
|
12
|
-
* created days ago but is still being written lands in its creation
|
|
13
|
-
* directory, so a very small limit can miss it in
|
|
14
|
-
* older-modified files. Callers that need exact mtime
|
|
15
|
-
* history omit
|
|
11
|
+
* With `limit` or `since`, the early exit is by directory date: a rollout
|
|
12
|
+
* that was created days ago but is still being written lands in its creation
|
|
13
|
+
* day's directory, so a very small limit (or a recent `since`) can miss it in
|
|
14
|
+
* favour of newer-dated, older-modified files. Callers that need exact mtime
|
|
15
|
+
* order over the whole history omit both (the `maxFiles` cap still applies);
|
|
16
|
+
* a live session missed this way is picked up by its next watch event.
|
|
17
|
+
*
|
|
18
|
+
* The async walker accepts a {@link DirectoryListingCache}: unchanged
|
|
19
|
+
* directories are then one stat each instead of a readdir plus a stat per
|
|
20
|
+
* file, which is what makes repeated discovery over a large history cheap.
|
|
16
21
|
*
|
|
17
22
|
* @module providers/rolloutWalker
|
|
18
23
|
*/
|
|
24
|
+
import type { DirectoryListingCache, DirectoryListingCounters } from './directoryListingCache';
|
|
19
25
|
export interface RolloutFileInfo {
|
|
20
26
|
path: string;
|
|
21
27
|
mtime: Date;
|
|
@@ -30,10 +36,21 @@ export interface WalkRolloutFilesOptions {
|
|
|
30
36
|
maxFiles?: number;
|
|
31
37
|
/** Stop once this many files are collected, visiting newest-dated directories first. */
|
|
32
38
|
limit?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Skip files modified before this epoch-ms, and skip dated directories
|
|
41
|
+
* whose whole day, month, or year ended more than a day before it.
|
|
42
|
+
*/
|
|
43
|
+
since?: number;
|
|
33
44
|
/** Only files whose name carries this session id (case-insensitive). */
|
|
34
45
|
sessionId?: string;
|
|
35
46
|
/** Include zero-byte files (default false). */
|
|
36
47
|
includeEmpty?: boolean;
|
|
48
|
+
/** Reuse directory listings across walks (async walker only). */
|
|
49
|
+
cache?: DirectoryListingCache;
|
|
50
|
+
/** With `cache`: re-stat recently modified files even in unchanged directories. */
|
|
51
|
+
revalidateRecent?: boolean;
|
|
52
|
+
/** Payload-free counters of what the walk cost. */
|
|
53
|
+
stats?: DirectoryListingCounters;
|
|
37
54
|
}
|
|
38
55
|
export declare const DEFAULT_ROLLOUT_WALK_MAX_DEPTH = 6;
|
|
39
56
|
export declare const DEFAULT_ROLLOUT_WALK_MAX_FILES = 20000;
|
|
@@ -44,6 +61,12 @@ export declare function isRolloutFile(filename: string): boolean;
|
|
|
44
61
|
* Format: rollout-<timestamp>-<uuid>.jsonl -> <uuid>
|
|
45
62
|
*/
|
|
46
63
|
export declare function extractRolloutSessionId(filename: string): string;
|
|
64
|
+
/**
|
|
65
|
+
* True when `segments` (the directory names below the root, e.g. `['2026',
|
|
66
|
+
* '09', '04']`) are all numeric and describe a year, month, or day that ended
|
|
67
|
+
* more than a day before `since`. Non-numeric names are never pruned.
|
|
68
|
+
*/
|
|
69
|
+
export declare function isDatedDirectoryBefore(segments: readonly string[], since: number): boolean;
|
|
47
70
|
/**
|
|
48
71
|
* Walk one or more `sessions` roots for rollout files, newest first.
|
|
49
72
|
* Missing or unreadable directories are skipped; paths are deduplicated.
|