sidekick-shared 0.26.3 → 0.26.5
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 +24 -0
- package/README.md +7 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +12 -9
- 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/providerFailure.js +5 -2
- 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/sessionMonitor.js +24 -2
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
* `limit` can stop early, stats each file exactly once, and returns results
|
|
10
10
|
* newest first with the size and session id already attached.
|
|
11
11
|
*
|
|
12
|
-
* With `limit`, the early exit is by directory date: a rollout
|
|
13
|
-
* created days ago but is still being written lands in its creation
|
|
14
|
-
* directory, so a very small limit can miss it in
|
|
15
|
-
* older-modified files. Callers that need exact mtime
|
|
16
|
-
* history omit
|
|
12
|
+
* With `limit` or `since`, the early exit is by directory date: a rollout
|
|
13
|
+
* that was created days ago but is still being written lands in its creation
|
|
14
|
+
* day's directory, so a very small limit (or a recent `since`) can miss it in
|
|
15
|
+
* favour of newer-dated, older-modified files. Callers that need exact mtime
|
|
16
|
+
* order over the whole history omit both (the `maxFiles` cap still applies);
|
|
17
|
+
* a live session missed this way is picked up by its next watch event.
|
|
18
|
+
*
|
|
19
|
+
* The async walker accepts a {@link DirectoryListingCache}: unchanged
|
|
20
|
+
* directories are then one stat each instead of a readdir plus a stat per
|
|
21
|
+
* file, which is what makes repeated discovery over a large history cheap.
|
|
17
22
|
*
|
|
18
23
|
* @module providers/rolloutWalker
|
|
19
24
|
*/
|
|
@@ -54,6 +59,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
54
59
|
exports.DEFAULT_ROLLOUT_WALK_MAX_FILES = exports.DEFAULT_ROLLOUT_WALK_MAX_DEPTH = void 0;
|
|
55
60
|
exports.isRolloutFile = isRolloutFile;
|
|
56
61
|
exports.extractRolloutSessionId = extractRolloutSessionId;
|
|
62
|
+
exports.isDatedDirectoryBefore = isDatedDirectoryBefore;
|
|
57
63
|
exports.walkRolloutFiles = walkRolloutFiles;
|
|
58
64
|
exports.walkRolloutFilesAsync = walkRolloutFilesAsync;
|
|
59
65
|
const fs = __importStar(require("fs"));
|
|
@@ -90,8 +96,12 @@ function walkState(options) {
|
|
|
90
96
|
seen: new Set(),
|
|
91
97
|
cap: Math.min(maxFiles, limit),
|
|
92
98
|
maxDepth: options.maxDepth ?? exports.DEFAULT_ROLLOUT_WALK_MAX_DEPTH,
|
|
99
|
+
since: options.since !== undefined && Number.isFinite(options.since) ? options.since : null,
|
|
93
100
|
sessionId: options.sessionId ? options.sessionId.trim().toLowerCase() : null,
|
|
94
101
|
includeEmpty: options.includeEmpty ?? false,
|
|
102
|
+
cache: options.cache ?? null,
|
|
103
|
+
revalidateRecent: options.revalidateRecent ?? false,
|
|
104
|
+
stats: options.stats,
|
|
95
105
|
};
|
|
96
106
|
}
|
|
97
107
|
/** Newest-dated first: directory and file names sort descending. */
|
|
@@ -106,12 +116,14 @@ function matchesFilter(state, name) {
|
|
|
106
116
|
function record(state, fullPath, name, stat) {
|
|
107
117
|
if (!state.includeEmpty && stat.size <= 0)
|
|
108
118
|
return;
|
|
119
|
+
if (state.since !== null && stat.mtimeMs < state.since)
|
|
120
|
+
return;
|
|
109
121
|
if (state.seen.has(fullPath))
|
|
110
122
|
return;
|
|
111
123
|
state.seen.add(fullPath);
|
|
112
124
|
state.results.push({
|
|
113
125
|
path: fullPath,
|
|
114
|
-
mtime: stat.
|
|
126
|
+
mtime: new Date(stat.mtimeMs),
|
|
115
127
|
sizeBytes: stat.size,
|
|
116
128
|
sessionId: extractRolloutSessionId(name),
|
|
117
129
|
});
|
|
@@ -119,9 +131,38 @@ function record(state, fullPath, name, stat) {
|
|
|
119
131
|
function finish(state) {
|
|
120
132
|
return state.results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
121
133
|
}
|
|
122
|
-
|
|
134
|
+
/** Slack added to a dated directory's range so a timezone skew cannot prune a live day. */
|
|
135
|
+
const DATE_DIR_SLACK_MS = 24 * 60 * 60_000;
|
|
136
|
+
/**
|
|
137
|
+
* True when `segments` (the directory names below the root, e.g. `['2026',
|
|
138
|
+
* '09', '04']`) are all numeric and describe a year, month, or day that ended
|
|
139
|
+
* more than a day before `since`. Non-numeric names are never pruned.
|
|
140
|
+
*/
|
|
141
|
+
function isDatedDirectoryBefore(segments, since) {
|
|
142
|
+
if (segments.length === 0 || segments.length > 3)
|
|
143
|
+
return false;
|
|
144
|
+
const numbers = segments.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : NaN));
|
|
145
|
+
if (numbers.some((value) => !Number.isFinite(value)))
|
|
146
|
+
return false;
|
|
147
|
+
const [year, month, day] = numbers;
|
|
148
|
+
if (year < 1970 || year > 9999)
|
|
149
|
+
return false;
|
|
150
|
+
if (segments.length >= 2 && (month < 1 || month > 12))
|
|
151
|
+
return false;
|
|
152
|
+
if (segments.length === 3 && (day < 1 || day > 31))
|
|
153
|
+
return false;
|
|
154
|
+
const rangeEnd = segments.length === 1
|
|
155
|
+
? new Date(year + 1, 0, 1).getTime()
|
|
156
|
+
: segments.length === 2
|
|
157
|
+
? new Date(year, month, 1).getTime()
|
|
158
|
+
: new Date(year, month - 1, day + 1).getTime();
|
|
159
|
+
return rangeEnd + DATE_DIR_SLACK_MS < since;
|
|
160
|
+
}
|
|
161
|
+
function visitSync(state, dir, depth, segments) {
|
|
123
162
|
if (depth > state.maxDepth || state.results.length >= state.cap)
|
|
124
163
|
return;
|
|
164
|
+
if (state.since !== null && isDatedDirectoryBefore(segments, state.since))
|
|
165
|
+
return;
|
|
125
166
|
let entries;
|
|
126
167
|
try {
|
|
127
168
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -146,8 +187,9 @@ function visitSync(state, dir, depth) {
|
|
|
146
187
|
for (const entry of entries) {
|
|
147
188
|
if (state.results.length >= state.cap)
|
|
148
189
|
return;
|
|
149
|
-
if (entry.isDirectory())
|
|
150
|
-
visitSync(state, path.join(dir, entry.name), depth + 1);
|
|
190
|
+
if (entry.isDirectory()) {
|
|
191
|
+
visitSync(state, path.join(dir, entry.name), depth + 1, [...segments, entry.name]);
|
|
192
|
+
}
|
|
151
193
|
}
|
|
152
194
|
}
|
|
153
195
|
/**
|
|
@@ -157,18 +199,52 @@ function visitSync(state, dir, depth) {
|
|
|
157
199
|
function walkRolloutFiles(roots, options = {}) {
|
|
158
200
|
const state = walkState(options);
|
|
159
201
|
for (const root of roots)
|
|
160
|
-
visitSync(state, root, 0);
|
|
202
|
+
visitSync(state, root, 0, []);
|
|
161
203
|
return finish(state);
|
|
162
204
|
}
|
|
163
205
|
function yieldToEventLoop() {
|
|
164
206
|
return new Promise((resolve) => setImmediate(resolve));
|
|
165
207
|
}
|
|
166
|
-
|
|
208
|
+
function byStringDescending(a, b) {
|
|
209
|
+
return b.localeCompare(a);
|
|
210
|
+
}
|
|
211
|
+
async function visitCached(state, cache, dir, depth, segments) {
|
|
167
212
|
if (depth > state.maxDepth || state.results.length >= state.cap)
|
|
168
213
|
return;
|
|
214
|
+
if (state.since !== null && isDatedDirectoryBefore(segments, state.since))
|
|
215
|
+
return;
|
|
216
|
+
const listing = await cache.listDirectory(dir, (name) => matchesFilter(state, name), {
|
|
217
|
+
revalidateRecent: state.revalidateRecent,
|
|
218
|
+
stats: state.stats,
|
|
219
|
+
});
|
|
220
|
+
if (!listing)
|
|
221
|
+
return;
|
|
222
|
+
const names = [...listing.files.keys()].sort(byStringDescending);
|
|
223
|
+
for (const name of names) {
|
|
224
|
+
if (state.results.length >= state.cap)
|
|
225
|
+
return;
|
|
226
|
+
const stat = listing.files.get(name);
|
|
227
|
+
if (!stat)
|
|
228
|
+
continue;
|
|
229
|
+
record(state, path.join(dir, name), name, { size: stat.sizeBytes, mtimeMs: stat.mtimeMs });
|
|
230
|
+
}
|
|
231
|
+
await yieldToEventLoop();
|
|
232
|
+
for (const name of [...listing.subdirectories].sort(byStringDescending)) {
|
|
233
|
+
if (state.results.length >= state.cap)
|
|
234
|
+
return;
|
|
235
|
+
await visitCached(state, cache, path.join(dir, name), depth + 1, [...segments, name]);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
async function visitAsync(state, dir, depth, segments) {
|
|
239
|
+
if (depth > state.maxDepth || state.results.length >= state.cap)
|
|
240
|
+
return;
|
|
241
|
+
if (state.since !== null && isDatedDirectoryBefore(segments, state.since))
|
|
242
|
+
return;
|
|
169
243
|
let entries;
|
|
170
244
|
try {
|
|
171
245
|
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
246
|
+
if (state.stats)
|
|
247
|
+
state.stats.directoriesListed = (state.stats.directoriesListed ?? 0) + 1;
|
|
172
248
|
}
|
|
173
249
|
catch {
|
|
174
250
|
return;
|
|
@@ -182,7 +258,10 @@ async function visitAsync(state, dir, depth) {
|
|
|
182
258
|
continue;
|
|
183
259
|
const fullPath = path.join(dir, entry.name);
|
|
184
260
|
try {
|
|
185
|
-
|
|
261
|
+
const stat = await fs.promises.stat(fullPath);
|
|
262
|
+
if (state.stats)
|
|
263
|
+
state.stats.filesStatted = (state.stats.filesStatted ?? 0) + 1;
|
|
264
|
+
record(state, fullPath, entry.name, stat);
|
|
186
265
|
}
|
|
187
266
|
catch {
|
|
188
267
|
// Skip files that vanish between readdir and stat.
|
|
@@ -196,14 +275,19 @@ async function visitAsync(state, dir, depth) {
|
|
|
196
275
|
for (const entry of entries) {
|
|
197
276
|
if (state.results.length >= state.cap)
|
|
198
277
|
return;
|
|
199
|
-
if (entry.isDirectory())
|
|
200
|
-
await visitAsync(state, path.join(dir, entry.name), depth + 1);
|
|
278
|
+
if (entry.isDirectory()) {
|
|
279
|
+
await visitAsync(state, path.join(dir, entry.name), depth + 1, [...segments, entry.name]);
|
|
280
|
+
}
|
|
201
281
|
}
|
|
202
282
|
}
|
|
203
283
|
/** Async twin of {@link walkRolloutFiles} that yields between directories. */
|
|
204
284
|
async function walkRolloutFilesAsync(roots, options = {}) {
|
|
205
285
|
const state = walkState(options);
|
|
206
|
-
for (const root of roots)
|
|
207
|
-
|
|
286
|
+
for (const root of roots) {
|
|
287
|
+
if (state.cache)
|
|
288
|
+
await visitCached(state, state.cache, root, 0, []);
|
|
289
|
+
else
|
|
290
|
+
await visitAsync(state, root, 0, []);
|
|
291
|
+
}
|
|
208
292
|
return finish(state);
|
|
209
293
|
}
|
|
@@ -59,6 +59,46 @@ export interface SessionFileInfo {
|
|
|
59
59
|
/** Optional provider-recorded creation timestamp. */
|
|
60
60
|
createdAt?: Date;
|
|
61
61
|
}
|
|
62
|
+
/** Payload-free counters of what an enumeration cost. */
|
|
63
|
+
export interface SessionEnumerationCounters {
|
|
64
|
+
directoriesListed?: number;
|
|
65
|
+
directoriesStatted?: number;
|
|
66
|
+
filesStatted?: number;
|
|
67
|
+
}
|
|
68
|
+
/** Options for `listSessionFilesAsync()`; every field is optional and additive. */
|
|
69
|
+
export interface ListSessionFilesOptions {
|
|
70
|
+
/** Return at most this many files, newest first (may skip an old-directory live session; see rolloutWalker). */
|
|
71
|
+
limit?: number;
|
|
72
|
+
/** Only files modified at or after this epoch-ms. */
|
|
73
|
+
since?: number;
|
|
74
|
+
/** Re-stat recently modified files even when their directory is unchanged. */
|
|
75
|
+
revalidateRecent?: boolean;
|
|
76
|
+
/** Counters the enumerator increments for real readdir/stat calls. */
|
|
77
|
+
stats?: SessionEnumerationCounters;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* What a recursive-watch event under one of the provider's watch roots maps
|
|
81
|
+
* to, resolved with at most one stat.
|
|
82
|
+
*
|
|
83
|
+
* - `ignored`: structurally not a session of this provider (wrong depth or
|
|
84
|
+
* extension, another workspace); nothing to do.
|
|
85
|
+
* - `unknown`: a directory or something the provider cannot classify; the
|
|
86
|
+
* caller should fall back to a full enumeration.
|
|
87
|
+
* - `missing`: the path names a session that is gone or still empty.
|
|
88
|
+
* - `present`: the session file with its current stat.
|
|
89
|
+
*/
|
|
90
|
+
export type WatchedSessionFile = {
|
|
91
|
+
status: 'ignored';
|
|
92
|
+
} | {
|
|
93
|
+
status: 'unknown';
|
|
94
|
+
} | {
|
|
95
|
+
status: 'missing';
|
|
96
|
+
path: string;
|
|
97
|
+
sessionId: string;
|
|
98
|
+
} | {
|
|
99
|
+
status: 'present';
|
|
100
|
+
file: SessionFileInfo;
|
|
101
|
+
};
|
|
62
102
|
/** How much of a session `readSessionStats()` could read. */
|
|
63
103
|
export type SessionFileStatsAvailability = 'full' | 'partial' | 'unavailable';
|
|
64
104
|
/**
|
|
@@ -225,7 +265,14 @@ export interface SessionProviderBase {
|
|
|
225
265
|
*/
|
|
226
266
|
listAllSessionFiles?(): SessionFileInfo[];
|
|
227
267
|
/** Event-loop-safe enumeration used by async preview and subscription APIs. */
|
|
228
|
-
listSessionFilesAsync?(workspacePath?: string): Promise<SessionFileInfo[]>;
|
|
268
|
+
listSessionFilesAsync?(workspacePath?: string, options?: ListSessionFilesOptions): Promise<SessionFileInfo[]>;
|
|
269
|
+
/**
|
|
270
|
+
* Resolve a recursive-watch event (`root` from `getWatchRoots()`, the
|
|
271
|
+
* root-relative `relativePath` the watcher reported) to a session file with
|
|
272
|
+
* at most one stat, applying the same workspace filter as
|
|
273
|
+
* `listSessionFilesAsync(workspacePath)`.
|
|
274
|
+
*/
|
|
275
|
+
statWatchedSessionFile?(root: string, relativePath: string, workspacePath?: string): Promise<WatchedSessionFile>;
|
|
229
276
|
/** Batch label extraction. DB-backed implementations use at most one query. */
|
|
230
277
|
extractSessionLabelsAsync?(sessionPaths: readonly string[]): Promise<Map<string, string | null>>;
|
|
231
278
|
/** Tests whether a provider can monitor a directory path, including synthetic DB-backed paths. */
|
package/dist/sessionMonitor.js
CHANGED
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.SessionMonitor = void 0;
|
|
37
37
|
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
38
39
|
const EventAggregator_1 = require("./aggregation/EventAggregator");
|
|
39
40
|
const observedSessionCollector_1 = require("./observedSessionCollector");
|
|
40
41
|
/**
|
|
@@ -119,6 +120,22 @@ class SessionMonitor {
|
|
|
119
120
|
];
|
|
120
121
|
},
|
|
121
122
|
read: () => null,
|
|
123
|
+
// One session is watched, so an event naming any other file costs
|
|
124
|
+
// nothing; an event naming this file costs one stat.
|
|
125
|
+
resolveReference: async (signal) => {
|
|
126
|
+
if (!signal.root || typeof signal.filename !== 'string' || !signal.filename) {
|
|
127
|
+
return { status: 'unknown' };
|
|
128
|
+
}
|
|
129
|
+
if (!this.sessionPath)
|
|
130
|
+
return { status: 'ignored' };
|
|
131
|
+
const eventPath = path.resolve(signal.root, signal.filename);
|
|
132
|
+
if (eventPath !== path.resolve(this.sessionPath))
|
|
133
|
+
return { status: 'ignored' };
|
|
134
|
+
const [reference] = await source.discover();
|
|
135
|
+
return reference
|
|
136
|
+
? { status: 'present', reference }
|
|
137
|
+
: { status: 'missing', sessionId: this.provider.getSessionId(this.sessionPath) };
|
|
138
|
+
},
|
|
122
139
|
subscribe: (listener, sourceOptions) => {
|
|
123
140
|
const watchers = new Set();
|
|
124
141
|
let pollTimer = null;
|
|
@@ -131,7 +148,12 @@ class SessionMonitor {
|
|
|
131
148
|
}
|
|
132
149
|
for (const root of new Set(roots)) {
|
|
133
150
|
try {
|
|
134
|
-
const watcher = fs.watch(root, { persistent: false, recursive: true }, listener
|
|
151
|
+
const watcher = fs.watch(root, { persistent: false, recursive: true }, (eventType, filename) => listener({
|
|
152
|
+
trigger: 'event',
|
|
153
|
+
root,
|
|
154
|
+
eventType,
|
|
155
|
+
filename: filename === null || filename === undefined ? null : String(filename),
|
|
156
|
+
}));
|
|
135
157
|
watchers.add(watcher);
|
|
136
158
|
watcher.on('error', () => {
|
|
137
159
|
watchers.delete(watcher);
|
|
@@ -149,7 +171,7 @@ class SessionMonitor {
|
|
|
149
171
|
}
|
|
150
172
|
const pollIntervalMs = sourceOptions?.pollIntervalMs ?? 0;
|
|
151
173
|
if (pollIntervalMs > 0) {
|
|
152
|
-
pollTimer = setInterval(listener, pollIntervalMs);
|
|
174
|
+
pollTimer = setInterval(() => listener({ trigger: 'poll' }), pollIntervalMs);
|
|
153
175
|
pollTimer.unref?.();
|
|
154
176
|
}
|
|
155
177
|
return {
|