omnirush 0.1.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/assets/collect-once.ts +126 -0
- package/assets/extensions/omnirush/auth.js +419 -0
- package/assets/extensions/omnirush/collector-lib.ts +873 -0
- package/assets/extensions/omnirush/collector.ts +100 -0
- package/assets/extensions/omnirush/commands.ts +34 -0
- package/assets/extensions/omnirush/index.ts +18 -0
- package/assets/extensions/omnirush/recorder.ts +38 -0
- package/assets/extensions/omnirush/refresh.ts +12 -0
- package/assets/extensions/omnirush/sota-lib.ts +62 -0
- package/assets/extensions/omnirush/sota.ts +221 -0
- package/assets/extensions/omnirush/usage.ts +21 -0
- package/assets/models.json +21 -0
- package/package.json +35 -0
- package/scripts/package.sh +22 -0
- package/scripts/postinstall.js +78 -0
- package/src/bin.js +456 -0
- package/src/lib.js +179 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
// Omnirush workspace collector — CLI port of the GUI reference
|
|
2
|
+
// implementation (gui-reference/apps/server/src/workspace-collector.ts,
|
|
3
|
+
// 772 lines). Ported faithfully: watch + debounce + change journal +
|
|
4
|
+
// session ledger + caps + redaction. Deviations, both forced by the CLI
|
|
5
|
+
// runtime:
|
|
6
|
+
// 1. minimatch is replaced by an internal gitignore-style matcher
|
|
7
|
+
// (`ignoreMatch`) — pi extensions are loaded through jiti with a
|
|
8
|
+
// fixed virtual-module set and cannot import npm packages.
|
|
9
|
+
// 2. Uploads go straight to the manager /collect endpoint with the
|
|
10
|
+
// device access token; an optional `refresh` callback gives the
|
|
11
|
+
// broker's on-401 single-flight refresh + retry-once behavior.
|
|
12
|
+
//
|
|
13
|
+
// Runs under the bundled bun (which provides node:zlib zstdCompress;
|
|
14
|
+
// plain Node needs >= 22.15). All helpers used by node:test are pure or
|
|
15
|
+
// injectable, so the suite exercises them on any runtime.
|
|
16
|
+
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
20
|
+
import { lstat, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
21
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
22
|
+
import { promisify } from "node:util";
|
|
23
|
+
import { zstdCompress as zstdCompressCb } from "node:zlib";
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
export const MAX_COLLECTOR_FILE_BYTES = 1024 * 1024;
|
|
28
|
+
export const MAX_COLLECTOR_SESSION_BYTES = 64 * 1024 * 1024;
|
|
29
|
+
export const MAX_SNAPSHOT_BYTES = 20 * 1024 * 1024;
|
|
30
|
+
export const MAX_TRACE_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
export const MAX_FILES = 20_000;
|
|
32
|
+
export const MAX_COMPRESSED_BYTES = 16 * 1024 * 1024;
|
|
33
|
+
export const CHANGE_DEBOUNCE_MS = 2_000;
|
|
34
|
+
export const FALLBACK_SCAN_MS = 10_000;
|
|
35
|
+
export const MAX_CHANGE_JOURNAL_ENTRIES = 512;
|
|
36
|
+
export const MAX_CHANGE_JOURNAL_BYTES = 768 * 1024;
|
|
37
|
+
export const MAX_SESSION_LEDGER_ENTRIES = 512;
|
|
38
|
+
export const SESSION_LEDGER_FILE = "omnirush-collector-sessions.json";
|
|
39
|
+
|
|
40
|
+
type SnapshotType = "start" | "change" | "trace" | "end";
|
|
41
|
+
|
|
42
|
+
export type CollectorFile = {
|
|
43
|
+
path: string;
|
|
44
|
+
content: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type TraceEvent = {
|
|
48
|
+
at: string;
|
|
49
|
+
type: string;
|
|
50
|
+
data?: unknown;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ChangeJournalEntry = {
|
|
54
|
+
path: string;
|
|
55
|
+
at: string;
|
|
56
|
+
status: "present" | "deleted" | "skipped";
|
|
57
|
+
content?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type SessionLedgerRecord = {
|
|
61
|
+
segment: number;
|
|
62
|
+
nextSequence: number;
|
|
63
|
+
sentBytes?: number;
|
|
64
|
+
lastMessageId?: string;
|
|
65
|
+
lastSeenAt: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type SessionLedger = {
|
|
69
|
+
version: 1;
|
|
70
|
+
sessions: Record<string, SessionLedgerRecord>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type SessionState = {
|
|
74
|
+
id: string;
|
|
75
|
+
root: string;
|
|
76
|
+
workspaceId: string;
|
|
77
|
+
sentBytes: number;
|
|
78
|
+
segment: number;
|
|
79
|
+
sequence: number;
|
|
80
|
+
resumed: boolean;
|
|
81
|
+
lastMessageId?: string;
|
|
82
|
+
lastSignature: string;
|
|
83
|
+
started: boolean;
|
|
84
|
+
finished: boolean;
|
|
85
|
+
changeTimer: ReturnType<typeof setTimeout> | null;
|
|
86
|
+
scanTimer: ReturnType<typeof setInterval> | null;
|
|
87
|
+
watcher: FSWatcher | null;
|
|
88
|
+
trace: TraceEvent[];
|
|
89
|
+
changeJournal: Map<string, ChangeJournalEntry>;
|
|
90
|
+
changeJournalBytes: number;
|
|
91
|
+
changeCaptureTail: Promise<void>;
|
|
92
|
+
ready: Promise<void>;
|
|
93
|
+
tail: Promise<void>;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type CollectorOptions = {
|
|
97
|
+
gatewayUrl?: string;
|
|
98
|
+
accessToken?: string;
|
|
99
|
+
fetch?: typeof fetch;
|
|
100
|
+
/** Returns the rotated access token, or null when refresh failed. */
|
|
101
|
+
refresh?: (tokenUsed: string) => Promise<string | null>;
|
|
102
|
+
upload?: (sessionId: string, compressed: Uint8Array) => Promise<Response>;
|
|
103
|
+
stateDir?: string;
|
|
104
|
+
log?: (level: "info" | "warn", message: string, attributes?: Record<string, unknown>) => void;
|
|
105
|
+
changeDebounceMs?: number;
|
|
106
|
+
fallbackScanMs?: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const DENIED_EXACT_NAMES = new Set([
|
|
110
|
+
".git",
|
|
111
|
+
".ssh",
|
|
112
|
+
"node_modules",
|
|
113
|
+
"keys",
|
|
114
|
+
"secrets",
|
|
115
|
+
".npmrc",
|
|
116
|
+
".pypirc",
|
|
117
|
+
"credentials",
|
|
118
|
+
"credentials.json",
|
|
119
|
+
"id_rsa",
|
|
120
|
+
"id_ed25519",
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const DENIED_SUFFIXES = [".pem", ".key", ".p12", ".pfx", ".jks", ".keystore"];
|
|
124
|
+
const DENIED_CREDENTIAL_NAME = /(^|[._-])(?:credentials?|secrets?|private[_-]?keys?)([._-]|$)/i;
|
|
125
|
+
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
|
126
|
+
[/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, "[REDACTED]"],
|
|
127
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]"],
|
|
128
|
+
[/\b(?:sk|rk|pk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]"],
|
|
129
|
+
[/^([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)\s*=\s*)([^\s#]{6,})$/gim, "$1[REDACTED]"],
|
|
130
|
+
];
|
|
131
|
+
const PII_PATTERNS: Array<[RegExp, string]> = [
|
|
132
|
+
[/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_PII]"],
|
|
133
|
+
[/(?<!\w)(?:\+\d{1,3}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4})(?!\w)/g, "[REDACTED_PII]"],
|
|
134
|
+
[/(?<!\w)\d{3}-\d{2}-\d{4}(?!\w)/g, "[REDACTED_PII]"],
|
|
135
|
+
[/(?<![\d.])(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}(?!\d)/g, "[REDACTED_PII]"],
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
export function resolveCollectUrl(rawGatewayUrl: string | undefined): string | null {
|
|
139
|
+
const value = rawGatewayUrl?.trim();
|
|
140
|
+
if (!value) return null;
|
|
141
|
+
try {
|
|
142
|
+
const url = new URL(value);
|
|
143
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname))) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "") + "/collect";
|
|
147
|
+
url.search = "";
|
|
148
|
+
url.hash = "";
|
|
149
|
+
return url.toString();
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function portablePath(root: string, path: string): string {
|
|
156
|
+
return relative(root, path).split(sep).join("/");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isCollectorPathDenied(path: string): boolean {
|
|
160
|
+
const parts = path.replaceAll("\\", "/").split("/").filter(Boolean);
|
|
161
|
+
return parts.some((part) => {
|
|
162
|
+
const lower = part.toLowerCase();
|
|
163
|
+
return lower.startsWith(".env")
|
|
164
|
+
|| DENIED_EXACT_NAMES.has(lower)
|
|
165
|
+
|| DENIED_SUFFIXES.some((suffix) => lower.endsWith(suffix))
|
|
166
|
+
|| DENIED_CREDENTIAL_NAME.test(lower);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function redactCollectorText(input: string): { text: string; count: number } {
|
|
171
|
+
let text = input;
|
|
172
|
+
let count = 0;
|
|
173
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
|
174
|
+
pattern.lastIndex = 0;
|
|
175
|
+
const matches = text.match(pattern);
|
|
176
|
+
count += matches?.length ?? 0;
|
|
177
|
+
pattern.lastIndex = 0;
|
|
178
|
+
text = text.replace(pattern, replacement);
|
|
179
|
+
}
|
|
180
|
+
for (const [pattern, replacement] of PII_PATTERNS) {
|
|
181
|
+
pattern.lastIndex = 0;
|
|
182
|
+
const matches = text.match(pattern);
|
|
183
|
+
count += matches?.length ?? 0;
|
|
184
|
+
pattern.lastIndex = 0;
|
|
185
|
+
text = text.replace(pattern, replacement);
|
|
186
|
+
}
|
|
187
|
+
return { text, count };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isBinary(buffer: Buffer): boolean {
|
|
191
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 8_192));
|
|
192
|
+
if (sample.includes(0)) return true;
|
|
193
|
+
let suspicious = 0;
|
|
194
|
+
for (const byte of sample) {
|
|
195
|
+
if (byte < 7 || (byte > 13 && byte < 32)) suspicious += 1;
|
|
196
|
+
}
|
|
197
|
+
return sample.length > 0 && suspicious / sample.length > 0.1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Minimal gitignore-style glob matcher (the minimatch replacement).
|
|
202
|
+
* Supports `*` (within a segment), `**` (across segments, including a
|
|
203
|
+
* leading globstar-slash that matches zero directories), `?`, and
|
|
204
|
+
* matchBase semantics: a pattern without `/` matches the file name in
|
|
205
|
+
* any directory. Always dot-matches (gitignore semantics for `*`).
|
|
206
|
+
*/
|
|
207
|
+
export function ignoreMatch(path: string, rawPattern: string): boolean {
|
|
208
|
+
const pattern = rawPattern.replace(/^\//, "").replace(/\/$/, "/**").replace(/\/{2,}/g, "/");
|
|
209
|
+
if (!pattern) return false;
|
|
210
|
+
const hasSlash = pattern.includes("/");
|
|
211
|
+
let re = "^";
|
|
212
|
+
let i = 0;
|
|
213
|
+
while (i < pattern.length) {
|
|
214
|
+
const char = pattern[i];
|
|
215
|
+
if (char === "*") {
|
|
216
|
+
if (pattern[i + 1] === "*") {
|
|
217
|
+
let j = i;
|
|
218
|
+
while (pattern[j] === "*") j += 1;
|
|
219
|
+
if (pattern[j] === "/" && (i === 0 || pattern[i - 1] === "/")) {
|
|
220
|
+
// Leading or interior `**/` — zero or more whole directories.
|
|
221
|
+
re += "(?:[^/]+/)*";
|
|
222
|
+
i = j + 1;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
re += ".*";
|
|
226
|
+
i = j;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
re += "[^/]*";
|
|
230
|
+
i += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (char === "?") {
|
|
234
|
+
re += "[^/]";
|
|
235
|
+
i += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
re += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
239
|
+
i += 1;
|
|
240
|
+
}
|
|
241
|
+
const anchored = `${re}$`;
|
|
242
|
+
if (regexMatch(path, anchored)) return true;
|
|
243
|
+
// matchBase: pattern without a slash also matches the file name alone.
|
|
244
|
+
if (!hasSlash) {
|
|
245
|
+
const base = path.split("/").pop() ?? "";
|
|
246
|
+
return regexMatch(base, anchored);
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function regexMatch(value: string, source: string): boolean {
|
|
252
|
+
try {
|
|
253
|
+
return new RegExp(source).test(value);
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function ignoredByRules(path: string, rules: string[]): boolean {
|
|
260
|
+
let ignored = false;
|
|
261
|
+
for (const raw of rules) {
|
|
262
|
+
const negated = raw.startsWith("!");
|
|
263
|
+
const body = negated ? raw.slice(1) : raw;
|
|
264
|
+
const pattern = body.replace(/^\//, "").replace(/\/$/, "/**");
|
|
265
|
+
if (!pattern) continue;
|
|
266
|
+
if (ignoreMatch(path, pattern)) ignored = !negated;
|
|
267
|
+
}
|
|
268
|
+
return ignored;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function gitIgnoresPath(root: string, path: string): Promise<boolean> {
|
|
272
|
+
try {
|
|
273
|
+
await execFileAsync("git", ["-C", root, "check-ignore", "--no-index", "-q", "--", path], {
|
|
274
|
+
timeout: 5_000,
|
|
275
|
+
maxBuffer: 64 * 1024,
|
|
276
|
+
});
|
|
277
|
+
return true;
|
|
278
|
+
} catch {
|
|
279
|
+
// Exit status 1 means the path is not ignored. A non-git workspace also
|
|
280
|
+
// falls through here and is handled by the normal snapshot walker.
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function readSessionLedger(path: string | null): Promise<SessionLedger> {
|
|
286
|
+
if (!path) return { version: 1, sessions: {} };
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as Partial<SessionLedger>;
|
|
289
|
+
if (parsed.version !== 1 || !parsed.sessions || typeof parsed.sessions !== "object") {
|
|
290
|
+
return { version: 1, sessions: {} };
|
|
291
|
+
}
|
|
292
|
+
const sessions = Object.fromEntries(
|
|
293
|
+
Object.entries(parsed.sessions as Record<string, unknown>).flatMap(([sessionId, value]) => {
|
|
294
|
+
if (!value || typeof value !== "object") return [];
|
|
295
|
+
const record = value as Partial<SessionLedgerRecord>;
|
|
296
|
+
if (typeof record.segment !== "number" || !Number.isSafeInteger(record.segment) || record.segment < 1
|
|
297
|
+
|| typeof record.nextSequence !== "number" || !Number.isSafeInteger(record.nextSequence) || record.nextSequence < 0
|
|
298
|
+
|| (record.sentBytes !== undefined && (!Number.isSafeInteger(record.sentBytes) || record.sentBytes < 0))
|
|
299
|
+
|| typeof record.lastSeenAt !== "string") return [];
|
|
300
|
+
return [[sessionId, record as SessionLedgerRecord] as const];
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
return { version: 1, sessions };
|
|
304
|
+
} catch {
|
|
305
|
+
return { version: 1, sessions: {} };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function boundedTracePayload(
|
|
310
|
+
state: Pick<SessionState, "id" | "workspaceId" | "segment" | "resumed">,
|
|
311
|
+
events: TraceEvent[],
|
|
312
|
+
maxBytes = MAX_TRACE_BYTES,
|
|
313
|
+
): Buffer {
|
|
314
|
+
const encode = (selected: TraceEvent[], truncated: boolean, includedCount = selected.length) => Buffer.from(redactCollectorText(JSON.stringify({
|
|
315
|
+
schema_version: 1,
|
|
316
|
+
session_id: state.id,
|
|
317
|
+
workspace_id: state.workspaceId,
|
|
318
|
+
session_segment: state.segment,
|
|
319
|
+
session_resumed: state.resumed,
|
|
320
|
+
trace_truncated: truncated,
|
|
321
|
+
dropped_event_count: Math.max(0, events.length - includedCount),
|
|
322
|
+
events: selected,
|
|
323
|
+
})).text);
|
|
324
|
+
|
|
325
|
+
const selected: TraceEvent[] = [];
|
|
326
|
+
for (const event of events) {
|
|
327
|
+
const candidate = encode([...selected, event], true);
|
|
328
|
+
if (candidate.byteLength > maxBytes) break;
|
|
329
|
+
selected.push(event);
|
|
330
|
+
}
|
|
331
|
+
let payload = encode(selected, selected.length < events.length);
|
|
332
|
+
while (payload.byteLength > maxBytes && selected.length > 0) {
|
|
333
|
+
selected.pop();
|
|
334
|
+
payload = encode(selected, true);
|
|
335
|
+
}
|
|
336
|
+
if (selected.length === 0 && events.length > 0) {
|
|
337
|
+
const marker: TraceEvent = {
|
|
338
|
+
at: new Date().toISOString(),
|
|
339
|
+
type: "trace.truncated",
|
|
340
|
+
data: { dropped_event_count: events.length },
|
|
341
|
+
};
|
|
342
|
+
const marked = encode([marker], true, 0);
|
|
343
|
+
if (marked.byteLength <= maxBytes) return marked;
|
|
344
|
+
}
|
|
345
|
+
return payload;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function readGitignoreFile(directory: string): Promise<string[]> {
|
|
349
|
+
try {
|
|
350
|
+
return (await readFile(resolve(directory, ".gitignore"), "utf8"))
|
|
351
|
+
.split(/\r?\n/)
|
|
352
|
+
.map((line) => line.trim())
|
|
353
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
354
|
+
} catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function walkFallback(root: string, directory = root, rules: string[] = [], output: string[] = []): Promise<string[]> {
|
|
360
|
+
const localRules = await readGitignoreFile(directory);
|
|
361
|
+
const prefix = portablePath(root, directory);
|
|
362
|
+
const ignoreRules = [...rules, ...localRules.map((rule) => {
|
|
363
|
+
const negated = rule.startsWith("!");
|
|
364
|
+
const body = negated ? rule.slice(1) : rule;
|
|
365
|
+
const scoped = prefix ? `${prefix}/${body}` : body;
|
|
366
|
+
return negated ? `!${scoped}` : scoped;
|
|
367
|
+
})];
|
|
368
|
+
let entries;
|
|
369
|
+
try {
|
|
370
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
371
|
+
} catch {
|
|
372
|
+
return output;
|
|
373
|
+
}
|
|
374
|
+
for (const entry of entries) {
|
|
375
|
+
if (output.length >= MAX_FILES) break;
|
|
376
|
+
const fullPath = resolve(directory, entry.name);
|
|
377
|
+
const path = portablePath(root, fullPath);
|
|
378
|
+
if (!path || isCollectorPathDenied(path) || ignoredByRules(path, ignoreRules)) continue;
|
|
379
|
+
if (entry.isDirectory()) await walkFallback(root, fullPath, ignoreRules, output);
|
|
380
|
+
else if (entry.isFile()) output.push(path);
|
|
381
|
+
}
|
|
382
|
+
return output;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function listWorkspaceFiles(root: string): Promise<string[]> {
|
|
386
|
+
try {
|
|
387
|
+
const { stdout } = await execFileAsync("git", ["-C", root, "ls-files", "-co", "--exclude-standard", "-z"], {
|
|
388
|
+
encoding: "buffer",
|
|
389
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
390
|
+
timeout: 15_000,
|
|
391
|
+
});
|
|
392
|
+
return Buffer.from(stdout)
|
|
393
|
+
.toString("utf8")
|
|
394
|
+
.split("\0")
|
|
395
|
+
.filter((path) => path && !isCollectorPathDenied(path))
|
|
396
|
+
.slice(0, MAX_FILES);
|
|
397
|
+
} catch {
|
|
398
|
+
return walkFallback(root);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function gitMetadata(root: string): Promise<Record<string, string | null>> {
|
|
403
|
+
const git = async (...args: string[]) => {
|
|
404
|
+
try {
|
|
405
|
+
const { stdout } = await execFileAsync("git", ["-C", root, ...args], { timeout: 5_000, maxBuffer: 64 * 1024 });
|
|
406
|
+
return String(stdout).trim() || null;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const [commit, branch, status] = await Promise.all([
|
|
412
|
+
git("rev-parse", "HEAD"),
|
|
413
|
+
git("branch", "--show-current"),
|
|
414
|
+
git("status", "--porcelain", "--untracked-files=no"),
|
|
415
|
+
]);
|
|
416
|
+
return { commit, branch, dirty: status ? "true" : "false" };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function workspaceSignature(root: string): Promise<string> {
|
|
420
|
+
const hash = createHash("sha256");
|
|
421
|
+
for (const path of await listWorkspaceFiles(root)) {
|
|
422
|
+
try {
|
|
423
|
+
const file = await lstat(resolve(root, path));
|
|
424
|
+
if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_COLLECTOR_FILE_BYTES) continue;
|
|
425
|
+
hash.update(path).update("\0").update(String(file.size)).update("\0").update(String(file.mtimeMs)).update("\0");
|
|
426
|
+
} catch {
|
|
427
|
+
// A file can disappear while the workspace is being scanned.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return hash.digest("hex");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function collectFiles(
|
|
434
|
+
root: string,
|
|
435
|
+
byteLimit: number,
|
|
436
|
+
workspaceId: string,
|
|
437
|
+
session?: Pick<SessionState, "id" | "segment" | "resumed">,
|
|
438
|
+
): Promise<CollectorFile[]> {
|
|
439
|
+
const files: CollectorFile[] = [];
|
|
440
|
+
let used = 0;
|
|
441
|
+
const metadata = JSON.stringify({
|
|
442
|
+
workspace_id: workspaceId,
|
|
443
|
+
...(session ? {
|
|
444
|
+
session_id: session.id,
|
|
445
|
+
session_segment: session.segment,
|
|
446
|
+
session_resumed: session.resumed,
|
|
447
|
+
} : {}),
|
|
448
|
+
root_name: root.split(sep).filter(Boolean).at(-1) ?? "workspace",
|
|
449
|
+
git: await gitMetadata(root),
|
|
450
|
+
});
|
|
451
|
+
files.push({ path: "__omnirush__/workspace.json", content: metadata });
|
|
452
|
+
used += Buffer.byteLength(metadata);
|
|
453
|
+
|
|
454
|
+
for (const path of await listWorkspaceFiles(root)) {
|
|
455
|
+
if (files.length >= MAX_FILES || used >= byteLimit) break;
|
|
456
|
+
try {
|
|
457
|
+
const absolute = resolve(root, path);
|
|
458
|
+
if (portablePath(root, absolute).startsWith("../")) continue;
|
|
459
|
+
const file = await lstat(absolute);
|
|
460
|
+
if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_COLLECTOR_FILE_BYTES) continue;
|
|
461
|
+
const buffer = await readFile(absolute);
|
|
462
|
+
if (buffer.length > MAX_COLLECTOR_FILE_BYTES || isBinary(buffer)) continue;
|
|
463
|
+
const redacted = redactCollectorText(buffer.toString("utf8")).text;
|
|
464
|
+
const size = Buffer.byteLength(redacted);
|
|
465
|
+
if (used + size > byteLimit) continue;
|
|
466
|
+
files.push({ path, content: redacted });
|
|
467
|
+
used += size;
|
|
468
|
+
} catch {
|
|
469
|
+
// Workspaces are live; races are expected and retried by the next snapshot.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return files;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function compressZstd(payload: Buffer): Promise<Buffer> {
|
|
476
|
+
return new Promise((resolvePromise, reject) => {
|
|
477
|
+
try {
|
|
478
|
+
const maybePromise = zstdCompressCb(payload, (error: unknown, result: Buffer) => {
|
|
479
|
+
if (error) reject(error);
|
|
480
|
+
else resolvePromise(result);
|
|
481
|
+
});
|
|
482
|
+
if (maybePromise && typeof (maybePromise as any).then === "function") {
|
|
483
|
+
(maybePromise as Promise<Buffer>).then(resolvePromise, reject);
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
reject(error);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The uncompressed collect envelope (contract: schema_version 1). */
|
|
492
|
+
export function buildEnvelopePayload(
|
|
493
|
+
state: Pick<SessionState, "id" | "segment" | "resumed"> & { sequence?: number },
|
|
494
|
+
snapshotType: SnapshotType,
|
|
495
|
+
files: CollectorFile[],
|
|
496
|
+
): Buffer {
|
|
497
|
+
return Buffer.from(JSON.stringify({
|
|
498
|
+
schema_version: 1,
|
|
499
|
+
session_id: state.id,
|
|
500
|
+
session_segment: state.segment,
|
|
501
|
+
session_resumed: state.resumed,
|
|
502
|
+
sequence: (state.sequence ?? 0) + 1,
|
|
503
|
+
snapshot_type: snapshotType,
|
|
504
|
+
files,
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export class WorkspaceCollector {
|
|
509
|
+
private readonly collectUrl: string | null;
|
|
510
|
+
private token: string;
|
|
511
|
+
private readonly fetcher: typeof fetch;
|
|
512
|
+
private readonly refresh?: CollectorOptions["refresh"];
|
|
513
|
+
private readonly uploader?: CollectorOptions["upload"];
|
|
514
|
+
private readonly log: NonNullable<CollectorOptions["log"]>;
|
|
515
|
+
private readonly ledgerPath: string | null;
|
|
516
|
+
private readonly ledgerReady: Promise<void>;
|
|
517
|
+
private ledger: SessionLedger = { version: 1, sessions: {} };
|
|
518
|
+
private ledgerWriteTail: Promise<void> = Promise.resolve();
|
|
519
|
+
private readonly sessions = new Map<string, SessionState>();
|
|
520
|
+
private readonly changeDebounceMs: number;
|
|
521
|
+
private readonly fallbackScanMs: number;
|
|
522
|
+
|
|
523
|
+
constructor(options: CollectorOptions = {}) {
|
|
524
|
+
this.collectUrl = resolveCollectUrl(options.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL);
|
|
525
|
+
this.token = (options.accessToken ?? process.env.OMNIRUSH_ACCESS_TOKEN ?? "").trim();
|
|
526
|
+
this.fetcher = options.fetch ?? ((input: any, init?: any) => fetch(input, init));
|
|
527
|
+
this.refresh = options.refresh;
|
|
528
|
+
this.uploader = options.upload;
|
|
529
|
+
this.log = options.log ?? (() => undefined);
|
|
530
|
+
this.ledgerPath = options.stateDir ? join(resolve(options.stateDir), SESSION_LEDGER_FILE) : null;
|
|
531
|
+
this.ledgerReady = this.loadLedger();
|
|
532
|
+
this.changeDebounceMs = options.changeDebounceMs ?? CHANGE_DEBOUNCE_MS;
|
|
533
|
+
this.fallbackScanMs = options.fallbackScanMs ?? FALLBACK_SCAN_MS;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private async loadLedger(): Promise<void> {
|
|
537
|
+
this.ledger = await readSessionLedger(this.ledgerPath);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private async saveLedger(): Promise<void> {
|
|
541
|
+
if (!this.ledgerPath) return;
|
|
542
|
+
await this.ledgerReady;
|
|
543
|
+
const entries = Object.entries(this.ledger.sessions)
|
|
544
|
+
.sort(([, left], [, right]) => right.lastSeenAt.localeCompare(left.lastSeenAt))
|
|
545
|
+
.slice(0, MAX_SESSION_LEDGER_ENTRIES);
|
|
546
|
+
this.ledger.sessions = Object.fromEntries(entries);
|
|
547
|
+
const snapshot = JSON.stringify(this.ledger);
|
|
548
|
+
this.ledgerWriteTail = this.ledgerWriteTail
|
|
549
|
+
.catch(() => undefined)
|
|
550
|
+
.then(async () => {
|
|
551
|
+
await mkdir(dirname(this.ledgerPath!), { recursive: true, mode: 0o700 });
|
|
552
|
+
await writeFile(this.ledgerPath!, snapshot, { encoding: "utf8", mode: 0o600 });
|
|
553
|
+
});
|
|
554
|
+
await this.ledgerWriteTail;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private async prepareSession(state: SessionState): Promise<void> {
|
|
558
|
+
await this.ledgerReady;
|
|
559
|
+
const previous = this.ledger.sessions[state.id];
|
|
560
|
+
state.segment = (previous?.segment ?? 0) + 1;
|
|
561
|
+
state.sequence = previous?.nextSequence ?? 0;
|
|
562
|
+
state.sentBytes = previous?.sentBytes ?? 0;
|
|
563
|
+
state.lastMessageId = previous?.lastMessageId;
|
|
564
|
+
state.resumed = Boolean(previous);
|
|
565
|
+
if (state.resumed) {
|
|
566
|
+
state.trace.push({
|
|
567
|
+
at: new Date().toISOString(),
|
|
568
|
+
type: "session.resumed",
|
|
569
|
+
data: { session_segment: state.segment, previous_segment: previous?.segment ?? null },
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
this.ledger.sessions[state.id] = {
|
|
573
|
+
segment: state.segment,
|
|
574
|
+
nextSequence: state.sequence,
|
|
575
|
+
sentBytes: state.sentBytes,
|
|
576
|
+
...(state.lastMessageId ? { lastMessageId: state.lastMessageId } : {}),
|
|
577
|
+
lastSeenAt: new Date().toISOString(),
|
|
578
|
+
};
|
|
579
|
+
await this.saveLedger();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private async persistSession(state: SessionState): Promise<void> {
|
|
583
|
+
await this.ledgerReady;
|
|
584
|
+
this.ledger.sessions[state.id] = {
|
|
585
|
+
segment: state.segment,
|
|
586
|
+
nextSequence: state.sequence,
|
|
587
|
+
sentBytes: state.sentBytes,
|
|
588
|
+
...(state.lastMessageId ? { lastMessageId: state.lastMessageId } : {}),
|
|
589
|
+
lastSeenAt: new Date().toISOString(),
|
|
590
|
+
};
|
|
591
|
+
await this.saveLedger();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async sessionCheckpoint(sessionId: string): Promise<{ resumed: boolean; segment?: number; lastMessageId?: string }> {
|
|
595
|
+
const state = this.sessions.get(sessionId);
|
|
596
|
+
if (!state) return { resumed: false };
|
|
597
|
+
await state.ready;
|
|
598
|
+
return { resumed: state.resumed, segment: state.segment, lastMessageId: state.lastMessageId };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async setSessionCheckpoint(sessionId: string, messageId: string): Promise<void> {
|
|
602
|
+
const state = this.sessions.get(sessionId);
|
|
603
|
+
if (!state || state.finished) return;
|
|
604
|
+
await state.ready;
|
|
605
|
+
state.lastMessageId = messageId;
|
|
606
|
+
await this.persistSession(state);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
get enabled(): boolean {
|
|
610
|
+
return Boolean(this.uploader || (this.collectUrl && this.token));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
startSession(sessionId: string, workspaceId: string, root: string): void {
|
|
614
|
+
if (!this.enabled || this.sessions.has(sessionId) || !/^[A-Za-z0-9._:-]{8,128}$/.test(sessionId)) return;
|
|
615
|
+
const state: SessionState = {
|
|
616
|
+
id: sessionId,
|
|
617
|
+
root,
|
|
618
|
+
workspaceId,
|
|
619
|
+
sentBytes: 0,
|
|
620
|
+
segment: 1,
|
|
621
|
+
sequence: 0,
|
|
622
|
+
resumed: false,
|
|
623
|
+
lastSignature: "",
|
|
624
|
+
started: false,
|
|
625
|
+
finished: false,
|
|
626
|
+
changeTimer: null,
|
|
627
|
+
scanTimer: null,
|
|
628
|
+
watcher: null,
|
|
629
|
+
trace: [],
|
|
630
|
+
changeJournal: new Map(),
|
|
631
|
+
changeJournalBytes: 0,
|
|
632
|
+
changeCaptureTail: Promise.resolve(),
|
|
633
|
+
ready: Promise.resolve(),
|
|
634
|
+
tail: Promise.resolve(),
|
|
635
|
+
};
|
|
636
|
+
state.ready = this.prepareSession(state);
|
|
637
|
+
this.sessions.set(sessionId, state);
|
|
638
|
+
this.enqueue(state, async () => {
|
|
639
|
+
await state.ready;
|
|
640
|
+
state.lastSignature = await workspaceSignature(root);
|
|
641
|
+
await this.uploadWorkspace(state, "start");
|
|
642
|
+
state.started = true;
|
|
643
|
+
});
|
|
644
|
+
try {
|
|
645
|
+
state.watcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
646
|
+
if (filename && isCollectorPathDenied(String(filename))) return;
|
|
647
|
+
if (filename) this.queueChangedPath(state, String(filename));
|
|
648
|
+
this.scheduleChange(state);
|
|
649
|
+
});
|
|
650
|
+
state.watcher.on("error", () => {
|
|
651
|
+
state.watcher?.close();
|
|
652
|
+
state.watcher = null;
|
|
653
|
+
});
|
|
654
|
+
} catch {
|
|
655
|
+
state.watcher = null;
|
|
656
|
+
}
|
|
657
|
+
state.scanTimer = setInterval(() => {
|
|
658
|
+
void workspaceSignature(root).then((signature) => {
|
|
659
|
+
if (signature && state.lastSignature && signature !== state.lastSignature) this.scheduleChange(state);
|
|
660
|
+
state.lastSignature = signature;
|
|
661
|
+
}).catch(() => undefined);
|
|
662
|
+
}, this.fallbackScanMs);
|
|
663
|
+
state.scanTimer.unref?.();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
recordTrace(sessionId: string, type: string, data?: unknown): void {
|
|
667
|
+
const state = this.sessions.get(sessionId);
|
|
668
|
+
if (!state || state.finished) return;
|
|
669
|
+
state.trace.push({ at: new Date().toISOString(), type, ...(data === undefined ? {} : { data }) });
|
|
670
|
+
if (state.trace.length > 5_000) state.trace.splice(0, state.trace.length - 5_000);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
finishSession(sessionId: string, finalTrace?: unknown): void {
|
|
674
|
+
const state = this.sessions.get(sessionId);
|
|
675
|
+
if (!state || state.finished) return;
|
|
676
|
+
const pendingTrace = state.trace.splice(0);
|
|
677
|
+
state.finished = true;
|
|
678
|
+
if (state.changeTimer) clearTimeout(state.changeTimer);
|
|
679
|
+
if (state.scanTimer) clearInterval(state.scanTimer);
|
|
680
|
+
state.watcher?.close();
|
|
681
|
+
this.enqueue(state, async () => {
|
|
682
|
+
await state.ready;
|
|
683
|
+
if (state.trace.length > 0) pendingTrace.push(...state.trace.splice(0));
|
|
684
|
+
await state.changeCaptureTail;
|
|
685
|
+
if (finalTrace !== undefined) pendingTrace.push({ at: new Date().toISOString(), type: "session.completed", data: finalTrace });
|
|
686
|
+
await this.uploadTrace(state, pendingTrace);
|
|
687
|
+
await this.uploadWorkspace(state, "end");
|
|
688
|
+
this.sessions.delete(sessionId);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
flushTrace(sessionId: string, finalTrace?: unknown): void {
|
|
693
|
+
const state = this.sessions.get(sessionId);
|
|
694
|
+
if (!state || state.finished) return;
|
|
695
|
+
const pendingTrace = state.trace.splice(0);
|
|
696
|
+
if (finalTrace !== undefined) pendingTrace.push({ at: new Date().toISOString(), type: "turn.completed", data: finalTrace });
|
|
697
|
+
if (pendingTrace.length === 0) return;
|
|
698
|
+
this.enqueue(state, async () => {
|
|
699
|
+
await state.ready;
|
|
700
|
+
if (state.trace.length > 0) pendingTrace.push(...state.trace.splice(0));
|
|
701
|
+
await this.uploadTrace(state, pendingTrace);
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async stop(): Promise<void> {
|
|
706
|
+
for (const sessionId of [...this.sessions.keys()]) this.finishSession(sessionId);
|
|
707
|
+
await Promise.allSettled([...this.sessions.values()].map((state) => state.tail));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
private scheduleChange(state: SessionState): void {
|
|
711
|
+
if (state.finished) return;
|
|
712
|
+
if (state.changeTimer) clearTimeout(state.changeTimer);
|
|
713
|
+
state.changeTimer = setTimeout(() => {
|
|
714
|
+
state.changeTimer = null;
|
|
715
|
+
this.enqueue(state, async () => {
|
|
716
|
+
const signature = await workspaceSignature(state.root);
|
|
717
|
+
if (!signature || signature === state.lastSignature) return;
|
|
718
|
+
state.lastSignature = signature;
|
|
719
|
+
await this.uploadWorkspace(state, "change");
|
|
720
|
+
});
|
|
721
|
+
}, this.changeDebounceMs);
|
|
722
|
+
state.changeTimer.unref?.();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private queueChangedPath(state: SessionState, filename: string): void {
|
|
726
|
+
if (state.finished) return;
|
|
727
|
+
state.changeCaptureTail = state.changeCaptureTail
|
|
728
|
+
.catch(() => undefined)
|
|
729
|
+
.then(() => this.captureChangedPath(state, filename))
|
|
730
|
+
.catch((error: unknown) => {
|
|
731
|
+
this.log("warn", "OmniRush changed-file capture failed", {
|
|
732
|
+
sessionId: state.id,
|
|
733
|
+
path: filename,
|
|
734
|
+
error: error instanceof Error ? error.message : "unknown",
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private async captureChangedPath(state: SessionState, filename: string): Promise<void> {
|
|
740
|
+
const absolute = resolve(state.root, filename);
|
|
741
|
+
const path = portablePath(state.root, absolute);
|
|
742
|
+
if (!path || path.startsWith("../") || isCollectorPathDenied(path) || await gitIgnoresPath(state.root, path)) return;
|
|
743
|
+
let entry: ChangeJournalEntry;
|
|
744
|
+
try {
|
|
745
|
+
const file = await lstat(absolute);
|
|
746
|
+
if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_COLLECTOR_FILE_BYTES) {
|
|
747
|
+
entry = { path, at: new Date().toISOString(), status: "skipped" };
|
|
748
|
+
} else {
|
|
749
|
+
const buffer = await readFile(absolute);
|
|
750
|
+
if (buffer.length > MAX_COLLECTOR_FILE_BYTES || isBinary(buffer)) {
|
|
751
|
+
entry = { path, at: new Date().toISOString(), status: "skipped" };
|
|
752
|
+
} else {
|
|
753
|
+
entry = {
|
|
754
|
+
path,
|
|
755
|
+
at: new Date().toISOString(),
|
|
756
|
+
status: "present",
|
|
757
|
+
content: redactCollectorText(buffer.toString("utf8")).text,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
} catch {
|
|
762
|
+
entry = { path, at: new Date().toISOString(), status: "deleted" };
|
|
763
|
+
}
|
|
764
|
+
const previous = state.changeJournal.get(path);
|
|
765
|
+
if (previous) state.changeJournalBytes -= Buffer.byteLength(JSON.stringify(previous));
|
|
766
|
+
state.changeJournal.set(path, entry);
|
|
767
|
+
state.changeJournalBytes += Buffer.byteLength(JSON.stringify(entry));
|
|
768
|
+
while (state.changeJournal.size > MAX_CHANGE_JOURNAL_ENTRIES || state.changeJournalBytes > MAX_CHANGE_JOURNAL_BYTES) {
|
|
769
|
+
const oldest = state.changeJournal.keys().next().value as string | undefined;
|
|
770
|
+
if (!oldest) break;
|
|
771
|
+
const removed = state.changeJournal.get(oldest);
|
|
772
|
+
if (removed) state.changeJournalBytes -= Buffer.byteLength(JSON.stringify(removed));
|
|
773
|
+
state.changeJournal.delete(oldest);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private acknowledgeJournal(state: SessionState, entries: ChangeJournalEntry[]): void {
|
|
778
|
+
for (const entry of entries) {
|
|
779
|
+
if (state.changeJournal.get(entry.path)?.at === entry.at) {
|
|
780
|
+
state.changeJournal.delete(entry.path);
|
|
781
|
+
state.changeJournalBytes -= Buffer.byteLength(JSON.stringify(entry));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private enqueue(state: SessionState, operation: () => Promise<void>): void {
|
|
787
|
+
state.tail = state.tail.then(operation).catch((error: unknown) => {
|
|
788
|
+
this.log("warn", "OmniRush collection operation failed", {
|
|
789
|
+
sessionId: state.id,
|
|
790
|
+
error: error instanceof Error ? error.message : "unknown",
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private async uploadWorkspace(state: SessionState, type: Exclude<SnapshotType, "trace">): Promise<void> {
|
|
796
|
+
const remaining = MAX_COLLECTOR_SESSION_BYTES - state.sentBytes;
|
|
797
|
+
if (remaining <= 1_024) return;
|
|
798
|
+
const files = await collectFiles(
|
|
799
|
+
state.root,
|
|
800
|
+
Math.min(MAX_SNAPSHOT_BYTES, remaining - 1_024),
|
|
801
|
+
state.workspaceId,
|
|
802
|
+
state,
|
|
803
|
+
);
|
|
804
|
+
const journal = type === "change" || type === "end" ? [...state.changeJournal.values()] : [];
|
|
805
|
+
if (journal.length > 0) {
|
|
806
|
+
files.push({
|
|
807
|
+
path: "__omnirush__/changes.json",
|
|
808
|
+
content: JSON.stringify({ schema_version: 1, session_id: state.id, entries: journal }),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
const uploaded = await this.uploadEnvelope(state, type, files);
|
|
812
|
+
if (uploaded && journal.length > 0) this.acknowledgeJournal(state, journal);
|
|
813
|
+
state.lastSignature = await workspaceSignature(state.root);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private async uploadTrace(state: SessionState, traceEvents: TraceEvent[]): Promise<void> {
|
|
817
|
+
const remaining = MAX_COLLECTOR_SESSION_BYTES - state.sentBytes;
|
|
818
|
+
if (remaining <= 1_024 || traceEvents.length === 0) return;
|
|
819
|
+
const bounded = boundedTracePayload(state, traceEvents, Math.min(MAX_TRACE_BYTES, remaining - 1_024));
|
|
820
|
+
await this.uploadEnvelope(state, "trace", [{ path: "__omnirush__/trace.json", content: bounded.toString("utf8") }]);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
private async uploadEnvelope(state: SessionState, snapshotType: SnapshotType, files: CollectorFile[]): Promise<boolean> {
|
|
824
|
+
if (!this.collectUrl && !this.uploader) return false;
|
|
825
|
+
const payload = buildEnvelopePayload(state, snapshotType, files);
|
|
826
|
+
if (state.sentBytes + payload.length > MAX_COLLECTOR_SESSION_BYTES) return false;
|
|
827
|
+
const compressed = await compressZstd(payload);
|
|
828
|
+
if (compressed.length > MAX_COMPRESSED_BYTES) {
|
|
829
|
+
this.log("warn", "OmniRush collection payload exceeded compressed limit", { sessionId: state.id, snapshotType });
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
const send = () => this.uploader
|
|
833
|
+
? this.uploader(state.id, compressed)
|
|
834
|
+
: this.fetcher(this.collectUrl!, {
|
|
835
|
+
method: "POST",
|
|
836
|
+
headers: {
|
|
837
|
+
Authorization: `Bearer ${this.token}`,
|
|
838
|
+
"Content-Type": "application/zstd",
|
|
839
|
+
"X-Omnirush-Session-ID": state.id,
|
|
840
|
+
},
|
|
841
|
+
body: compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength) as ArrayBuffer,
|
|
842
|
+
signal: AbortSignal.timeout(30_000),
|
|
843
|
+
});
|
|
844
|
+
let response = await send();
|
|
845
|
+
if (response.status === 401 && this.refresh) {
|
|
846
|
+
// Broker pattern: single-flight refresh, retry exactly once.
|
|
847
|
+
const rotated = await this.refresh(this.token).catch(() => null);
|
|
848
|
+
if (rotated) {
|
|
849
|
+
this.token = rotated;
|
|
850
|
+
response = await send();
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (!response.ok) {
|
|
854
|
+
await response.body?.cancel().catch(() => undefined);
|
|
855
|
+
throw new Error(`collector upload failed with status ${response.status}`);
|
|
856
|
+
}
|
|
857
|
+
state.sentBytes += payload.length;
|
|
858
|
+
state.sequence += 1;
|
|
859
|
+
await this.persistSession(state).catch((error: unknown) => {
|
|
860
|
+
this.log("warn", "OmniRush session ledger update failed", {
|
|
861
|
+
sessionId: state.id,
|
|
862
|
+
error: error instanceof Error ? error.message : "unknown",
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
this.log("info", "OmniRush collection artifact uploaded", {
|
|
866
|
+
sessionId: state.id,
|
|
867
|
+
snapshotType,
|
|
868
|
+
fileCount: files.length,
|
|
869
|
+
compressedBytes: compressed.length,
|
|
870
|
+
});
|
|
871
|
+
return true;
|
|
872
|
+
}
|
|
873
|
+
}
|