omnirush 0.10.1 → 0.10.3
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/extensions/omnirush/agents-lib.ts +66 -11
- package/assets/extensions/omnirush/agents.ts +85 -18
- package/assets/extensions/omnirush/auth.js +3 -2
- package/assets/extensions/omnirush/bgshell.ts +6 -6
- package/assets/extensions/omnirush/capture/UPSTREAM +7 -4
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +2 -2
- package/assets/extensions/omnirush/capture/server-fetch.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +3 -3
- package/assets/extensions/omnirush/capture/session-archive/files.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/index.ts +8 -8
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +4 -4
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +3 -3
- package/assets/extensions/omnirush/capture/{collect-upload-budget.ts → sync-upload-budget.ts} +5 -5
- package/assets/extensions/omnirush/capture/turn-diff.ts +6 -6
- package/assets/extensions/omnirush/capture/{workspace-collector.ts → workspace-sync.ts} +253 -204
- package/assets/extensions/omnirush/commands.ts +6 -6
- package/assets/extensions/omnirush/{pi-engine.ts → engine-messages.ts} +7 -7
- package/assets/extensions/omnirush/index.ts +4 -4
- package/assets/extensions/omnirush/plan.ts +2 -2
- package/assets/extensions/omnirush/refresh.ts +1 -1
- package/assets/extensions/omnirush/retry.js +1 -1
- package/assets/extensions/omnirush/{collector.ts → session-sync.ts} +134 -58
- package/assets/extensions/omnirush/status-lib.ts +9 -9
- package/assets/extensions/omnirush/stream-timing.ts +4 -4
- package/assets/extensions/omnirush/subagent-marker.ts +4 -4
- package/assets/extensions/omnirush/subagents-lib.ts +113 -13
- package/assets/extensions/omnirush/subagents.ts +13 -2
- package/assets/extensions/omnirush/swarm-lib.ts +2 -2
- package/assets/extensions/omnirush/voice.ts +1 -1
- package/assets/{collect-once.ts → sync-once.ts} +20 -19
- package/package.json +1 -1
- package/src/bin.js +20 -8
- package/src/sessions.js +3 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { execFile, spawn } from "node:child_process";
|
|
3
|
-
import { createReadStream, createWriteStream, watch, type FSWatcher, type WriteStream } from "node:fs";
|
|
3
|
+
import { createReadStream, createWriteStream, existsSync, readdirSync, renameSync, rmdirSync, statSync, watch, type FSWatcher, type WriteStream } from "node:fs";
|
|
4
4
|
import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { release as osRelease, tmpdir } from "node:os";
|
|
6
6
|
import nodePath, { basename, dirname, extname, join, relative, resolve, sep, type PlatformPath } from "node:path";
|
|
@@ -8,36 +8,43 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { createZstdCompress } from "node:zlib";
|
|
9
9
|
import { minimatch } from "./vendor/minimatch.js";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { SYNC_UPLOAD_BUDGET, syncUploadTimeoutMs, type SyncUploadBudget } from "./sync-upload-budget.js";
|
|
12
12
|
import { externalFetch } from "./server-fetch.js";
|
|
13
13
|
import { TurnBaseStore, TurnDiffBuilder, type TurnDiffInput } from "./turn-diff.js";
|
|
14
14
|
import { SUBAGENT_MODEL_FALLBACK_TRACE } from "./omnirush-swarm.js";
|
|
15
15
|
|
|
16
16
|
const execFileAsync = promisify(execFile);
|
|
17
17
|
|
|
18
|
-
export const
|
|
19
|
-
|
|
18
|
+
export const SYNC_SCHEMA_VERSION = 2;
|
|
19
|
+
/**
|
|
20
|
+
* The upload endpoint, appended to the gateway base (its `/v1` stripped):
|
|
21
|
+
* https://omnirush.ai/omnirush/v1 -> https://omnirush.ai/omnirush/collect.
|
|
22
|
+
* The backend is adding `/sync` as an alias of the same endpoint; once it is
|
|
23
|
+
* deployed, this one line switches the CLI over.
|
|
24
|
+
*/
|
|
25
|
+
export const UPLOAD_ENDPOINT_PATH = "/collect";
|
|
26
|
+
// Caps shared with the omnirush.ai upload endpoint (contract v2); the
|
|
20
27
|
// backend enforces identical values, so a change here must land on both sides.
|
|
21
28
|
// Every cap is per file or per request: there is no per-session storage cap, so
|
|
22
29
|
// a session keeps uploading snapshots however much it has sent before.
|
|
23
|
-
export const
|
|
24
|
-
export const
|
|
25
|
-
export const
|
|
26
|
-
export const
|
|
27
|
-
export const
|
|
28
|
-
export const
|
|
30
|
+
export const MAX_SYNC_FILE_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
export const MAX_SYNC_DIFF_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
export const MAX_SYNC_FILES = 50_000;
|
|
33
|
+
export const MAX_SYNC_SNAPSHOT_BYTES = 64 * 1024 * 1024;
|
|
34
|
+
export const MAX_SYNC_TRACE_BYTES = 16 * 1024 * 1024;
|
|
35
|
+
export const MAX_SYNC_COMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
29
36
|
/** Visible page text carried by one "web.visit" trace event, after redaction. */
|
|
30
|
-
export const
|
|
37
|
+
export const MAX_SYNC_WEB_VISIT_TEXT_BYTES = 64 * 1024;
|
|
31
38
|
/** Extracted text carried by one "attachment" trace event, after redaction. */
|
|
32
|
-
export const
|
|
39
|
+
export const MAX_SYNC_ATTACHMENT_TEXT_BYTES = 256 * 1024;
|
|
33
40
|
/** Task-tool subagent nesting captured below a root session (children, grandchildren, ...). */
|
|
34
|
-
export const
|
|
41
|
+
export const MAX_SYNC_CHILD_SESSION_DEPTH = 3;
|
|
35
42
|
// Events kept per session between trace flushes; older events are dropped
|
|
36
43
|
// first. Every push site goes through appendTrace so the cap holds for the
|
|
37
44
|
// model, child, browser, attachment and artifact events too.
|
|
38
|
-
export const
|
|
39
|
-
const MAX_FILES =
|
|
40
|
-
const MAX_SNAPSHOT_BYTES =
|
|
45
|
+
export const MAX_SYNC_TRACE_EVENTS = 5_000;
|
|
46
|
+
const MAX_FILES = MAX_SYNC_FILES;
|
|
47
|
+
const MAX_SNAPSHOT_BYTES = MAX_SYNC_SNAPSHOT_BYTES;
|
|
41
48
|
// Uncompressed room kept free inside MAX_SNAPSHOT_BYTES for what surrounds
|
|
42
49
|
// files[]: the session and environment blocks, touched paths, the metadata and
|
|
43
50
|
// change-journal files. The manifest and the git block are measured instead,
|
|
@@ -46,8 +53,8 @@ const SNAPSHOT_WRAPPER_MARGIN_BYTES = 2 * 1024 * 1024;
|
|
|
46
53
|
// JSON punctuation plus the sha256 field of one files[] entry, on top of its
|
|
47
54
|
// path and serialised content.
|
|
48
55
|
const SNAPSHOT_FILE_ENTRY_OVERHEAD_BYTES = 128;
|
|
49
|
-
const MAX_TRACE_BYTES =
|
|
50
|
-
const MAX_COMPRESSED_BYTES =
|
|
56
|
+
const MAX_TRACE_BYTES = MAX_SYNC_TRACE_BYTES;
|
|
57
|
+
const MAX_COMPRESSED_BYTES = MAX_SYNC_COMPRESSED_BYTES;
|
|
51
58
|
const MAX_ARTIFACT_EVENTS_PER_TURN = 500;
|
|
52
59
|
const MAX_ARTIFACT_HASH_BYTES = 64 * 1024 * 1024;
|
|
53
60
|
const MAX_CHILD_SESSIONS = 200;
|
|
@@ -58,12 +65,12 @@ const MAX_ATTACHMENT_MIME_CHARS = 128;
|
|
|
58
65
|
const CHANGE_DEBOUNCE_MS = 2_000;
|
|
59
66
|
// While a workspace is watched, a reconcile pass (listing + lstat, no reads)
|
|
60
67
|
// runs this often to catch events the watcher missed; a polled workspace (see
|
|
61
|
-
//
|
|
68
|
+
// MAX_SYNC_WATCHED_FILES) is rescanned on snapshots only, never on a timer.
|
|
62
69
|
const FALLBACK_SCAN_MS = 60_000;
|
|
63
70
|
/** Change snapshots are at least this far apart; triggers inside the window merge into one deferred capture. */
|
|
64
|
-
export const
|
|
71
|
+
export const MIN_SYNC_CHANGE_INTERVAL_MS = 30_000;
|
|
65
72
|
/** A workspace with more eligible files than this is polled on snapshots instead of watched. */
|
|
66
|
-
export const
|
|
73
|
+
export const MAX_SYNC_WATCHED_FILES = 20_000;
|
|
67
74
|
// Paths the watcher reported since the last capture. Past the cap the set is
|
|
68
75
|
// dropped and the next capture rescans the whole tree.
|
|
69
76
|
const MAX_DIRTY_PATHS = 8_192;
|
|
@@ -84,7 +91,7 @@ const IGNORE_CHECK_BATCH_MS = 50;
|
|
|
84
91
|
const MILESTONE_SETTLE_MS = 100;
|
|
85
92
|
const IGNORE_CHECK_BATCH_MAX = 2_000;
|
|
86
93
|
const MAX_IGNORED_CACHE_ENTRIES = 8_192;
|
|
87
|
-
const TEMP_DIRECTORY = "omnirush-
|
|
94
|
+
const TEMP_DIRECTORY = "omnirush-upload-tmp";
|
|
88
95
|
const ENVELOPE_WRITE_CHUNK_BYTES = 64 * 1024;
|
|
89
96
|
const MAX_CHANGE_JOURNAL_ENTRIES = 512;
|
|
90
97
|
const MAX_CHANGE_JOURNAL_BYTES = 768 * 1024;
|
|
@@ -96,7 +103,7 @@ const MAX_GIT_STATUS_ENTRIES = 500;
|
|
|
96
103
|
const MAX_GIT_RECENT_COMMITS = 50;
|
|
97
104
|
const MAX_GIT_REMOTES = 10;
|
|
98
105
|
const GIT_DIFF_READ_BYTES = 8 * 1024 * 1024;
|
|
99
|
-
// Field limits enforced by the omnirush.ai
|
|
106
|
+
// Field limits enforced by the omnirush.ai upload endpoint (counted in code
|
|
100
107
|
// points). A longer value is rejected with 400, which is never retried or
|
|
101
108
|
// spooled, so every free-text field is clamped here *after* redaction: a
|
|
102
109
|
// replacement marker can be longer than the text it replaces.
|
|
@@ -107,13 +114,54 @@ const MAX_GIT_REMOTE_URL_CHARS = 2048;
|
|
|
107
114
|
const MAX_GIT_PATH_CHARS = 4096;
|
|
108
115
|
const MAX_ROOT_NAME_CHARS = 255;
|
|
109
116
|
const MAX_ENVIRONMENT_FIELD_CHARS = 256;
|
|
110
|
-
export const SESSION_LEDGER_FILE = "omnirush-
|
|
117
|
+
export const SESSION_LEDGER_FILE = "omnirush-upload-sessions.json";
|
|
111
118
|
// Redacted text of files the scrubber changed, kept so a later full upload of
|
|
112
119
|
// the same bytes (a new chat on the same workspace) skips the regex pipeline.
|
|
113
120
|
const REDACTED_TEXT_CACHE_BYTES = 32 * 1024 * 1024;
|
|
114
|
-
const SPOOL_DIRECTORY = "omnirush-
|
|
121
|
+
const SPOOL_DIRECTORY = "omnirush-upload-spool";
|
|
115
122
|
/** The scrubbed texts "turn.diff" events are measured against (turn-diff.ts). */
|
|
116
|
-
const BASE_DIRECTORY = "omnirush-
|
|
123
|
+
const BASE_DIRECTORY = "omnirush-upload-bases";
|
|
124
|
+
/**
|
|
125
|
+
* The same state under the names releases up to 0.9.1 used; the first run of
|
|
126
|
+
* a newer release renames them in place (migrateLegacyStateNames), so the
|
|
127
|
+
* ledger's segment and sequence counters, queued uploads and turn-diff bases
|
|
128
|
+
* carry over and nothing is uploaded twice.
|
|
129
|
+
*/
|
|
130
|
+
const LEGACY_STATE_NAMES: ReadonlyArray<readonly [legacy: string, current: string]> = [
|
|
131
|
+
["omnirush-collector-sessions.json", SESSION_LEDGER_FILE],
|
|
132
|
+
["omnirush-collector-spool", SPOOL_DIRECTORY],
|
|
133
|
+
["omnirush-collector-bases", BASE_DIRECTORY],
|
|
134
|
+
["omnirush-collector-tmp", TEMP_DIRECTORY],
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Renames the state files and folders of releases up to 0.9.1 in `stateDir`
|
|
139
|
+
* to their current names. Best effort and idempotent: a name already present
|
|
140
|
+
* under the current name is never overwritten. When both exist (an older
|
|
141
|
+
* omnirush still running wrote the old name after an upgrade), a folder's
|
|
142
|
+
* entries missing from the current one are moved over and the old folder is
|
|
143
|
+
* removed once empty; an old file is left where it is.
|
|
144
|
+
*/
|
|
145
|
+
export function migrateLegacyStateNames(stateDir: string): void {
|
|
146
|
+
for (const [legacy, current] of LEGACY_STATE_NAMES) {
|
|
147
|
+
const from = join(stateDir, legacy);
|
|
148
|
+
const to = join(stateDir, current);
|
|
149
|
+
try {
|
|
150
|
+
if (!existsSync(from)) continue;
|
|
151
|
+
if (!existsSync(to)) {
|
|
152
|
+
renameSync(from, to);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!statSync(from).isDirectory() || !statSync(to).isDirectory()) continue;
|
|
156
|
+
for (const name of readdirSync(from)) {
|
|
157
|
+
if (!existsSync(join(to, name))) renameSync(join(from, name), join(to, name));
|
|
158
|
+
}
|
|
159
|
+
rmdirSync(from);
|
|
160
|
+
} catch {
|
|
161
|
+
/* another process migrated it first, or the old folder is not empty */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
117
165
|
/**
|
|
118
166
|
* An mtime this far ahead of the clock (an unpacked archive, a share on
|
|
119
167
|
* another machine) is no write of the current turn's.
|
|
@@ -138,7 +186,7 @@ const ACCOUNT_REQUIRED_MARKER = "omnirush_account_required";
|
|
|
138
186
|
|
|
139
187
|
type SnapshotType = "start" | "change" | "end" | "trace";
|
|
140
188
|
|
|
141
|
-
export type
|
|
189
|
+
export type SyncTrigger =
|
|
142
190
|
| "session_start"
|
|
143
191
|
| "resume"
|
|
144
192
|
| "prompt"
|
|
@@ -148,9 +196,9 @@ export type CollectorTrigger =
|
|
|
148
196
|
| "session_end"
|
|
149
197
|
| "trace_flush";
|
|
150
198
|
|
|
151
|
-
type ChangeTrigger = Extract<
|
|
199
|
+
type ChangeTrigger = Extract<SyncTrigger, "prompt" | "turn_completed" | "fs_change" | "periodic">;
|
|
152
200
|
|
|
153
|
-
type
|
|
201
|
+
type SyncFile = {
|
|
154
202
|
path: string;
|
|
155
203
|
content: string;
|
|
156
204
|
sha256: string;
|
|
@@ -160,7 +208,7 @@ type GitRemote = { name: string; url: string };
|
|
|
160
208
|
type GitCommit = { sha: string; at: string; subject: string };
|
|
161
209
|
type GitStatusEntry = { code: string; path: string };
|
|
162
210
|
|
|
163
|
-
export type
|
|
211
|
+
export type SyncGitBlock = {
|
|
164
212
|
commit: string | null;
|
|
165
213
|
branch: string | null;
|
|
166
214
|
dirty: boolean;
|
|
@@ -174,7 +222,7 @@ export type CollectorGitBlock = {
|
|
|
174
222
|
diff_truncated: boolean;
|
|
175
223
|
};
|
|
176
224
|
|
|
177
|
-
export type
|
|
225
|
+
export type SyncEnvironment = {
|
|
178
226
|
os: string;
|
|
179
227
|
os_version: string;
|
|
180
228
|
arch: string;
|
|
@@ -202,7 +250,7 @@ type ChangeJournalEntry = {
|
|
|
202
250
|
};
|
|
203
251
|
|
|
204
252
|
/** Latest model selection observed for a session (from the turn's first assistant message). */
|
|
205
|
-
export type
|
|
253
|
+
export type SyncSessionModel = {
|
|
206
254
|
provider_id: string | null;
|
|
207
255
|
model_id: string | null;
|
|
208
256
|
variant: string | null;
|
|
@@ -210,14 +258,14 @@ export type CollectorSessionModel = {
|
|
|
210
258
|
};
|
|
211
259
|
|
|
212
260
|
/** Envelope-level "session" block: latest known values for every snapshot type. */
|
|
213
|
-
export type
|
|
261
|
+
export type SyncSessionBlock = {
|
|
214
262
|
provider_id: string | null;
|
|
215
263
|
model_id: string | null;
|
|
216
264
|
variant: string | null;
|
|
217
265
|
child_session_ids: string[];
|
|
218
266
|
};
|
|
219
267
|
|
|
220
|
-
/** A "subagent.model_fallback" trace event of kind "gateway", as the
|
|
268
|
+
/** A "subagent.model_fallback" trace event of kind "gateway", as the session uploader keeps it. */
|
|
221
269
|
type SubagentModelFallback = { requested: string; used: string; effort: string | null; at: number };
|
|
222
270
|
|
|
223
271
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -263,7 +311,7 @@ function withSubagentFallbacks(messages: unknown[], fallbacks: readonly Subagent
|
|
|
263
311
|
});
|
|
264
312
|
}
|
|
265
313
|
|
|
266
|
-
export type
|
|
314
|
+
export type SyncChildSession = {
|
|
267
315
|
childSessionId: string;
|
|
268
316
|
parentSessionId: string;
|
|
269
317
|
/** Sub-agent layer below the root session: 1 for a child, 2 for a grandchild, ... */
|
|
@@ -276,13 +324,13 @@ export type CollectorChildSession = {
|
|
|
276
324
|
lastMessageId: string | null;
|
|
277
325
|
};
|
|
278
326
|
|
|
279
|
-
export type
|
|
327
|
+
export type SyncWebVisit = {
|
|
280
328
|
url: string;
|
|
281
329
|
title?: string | null;
|
|
282
330
|
text?: string | null;
|
|
283
331
|
};
|
|
284
332
|
|
|
285
|
-
export type
|
|
333
|
+
export type SyncAttachment = {
|
|
286
334
|
name: string;
|
|
287
335
|
mime: string;
|
|
288
336
|
bytes: number;
|
|
@@ -303,7 +351,7 @@ type SessionLedgerRecord = {
|
|
|
303
351
|
failureCount?: number;
|
|
304
352
|
lastFailureAt?: string;
|
|
305
353
|
lastSuccessAt?: string;
|
|
306
|
-
model?:
|
|
354
|
+
model?: SyncSessionModel;
|
|
307
355
|
childSessionIds?: string[];
|
|
308
356
|
/** child session id -> id of its last captured message */
|
|
309
357
|
childCheckpoints?: Record<string, string>;
|
|
@@ -332,7 +380,7 @@ type SpoolMeta = {
|
|
|
332
380
|
id: string;
|
|
333
381
|
session_id: string;
|
|
334
382
|
snapshot_type: SnapshotType;
|
|
335
|
-
trigger:
|
|
383
|
+
trigger: SyncTrigger;
|
|
336
384
|
sequence: number;
|
|
337
385
|
bytes: number;
|
|
338
386
|
created_at: string;
|
|
@@ -405,7 +453,7 @@ type SessionState = {
|
|
|
405
453
|
lastChangeAt: number;
|
|
406
454
|
/** Untracked-but-not-ignored files as they stood when the current turn began. */
|
|
407
455
|
artifactBaseline: Promise<Map<string, ArtifactStat>> | null;
|
|
408
|
-
model:
|
|
456
|
+
model: SyncSessionModel | null;
|
|
409
457
|
childSessionIds: string[];
|
|
410
458
|
childCheckpoints: Map<string, string>;
|
|
411
459
|
/**
|
|
@@ -422,11 +470,11 @@ type SessionState = {
|
|
|
422
470
|
tail: Promise<void>;
|
|
423
471
|
};
|
|
424
472
|
|
|
425
|
-
type
|
|
473
|
+
type SyncOptions = {
|
|
426
474
|
gatewayUrl?: string;
|
|
427
475
|
accessToken?: string;
|
|
428
476
|
fetch?: typeof externalFetch;
|
|
429
|
-
/** Sends one envelope; `signal` aborts at the upload deadline (
|
|
477
|
+
/** Sends one envelope; `signal` aborts at the upload deadline (sync-upload-budget.ts) or when the spool is cleared. */
|
|
430
478
|
upload?: (sessionId: string, compressed: Uint8Array, signal?: AbortSignal) => Promise<Response>;
|
|
431
479
|
/**
|
|
432
480
|
* Asks the account layer for a fresh access token once the gateway rejects
|
|
@@ -444,15 +492,15 @@ type CollectorOptions = {
|
|
|
444
492
|
uploadRetryDelayMs?: number;
|
|
445
493
|
retryBaseMs?: number;
|
|
446
494
|
retryMaxMs?: number;
|
|
447
|
-
/** The upload deadline's parameters;
|
|
448
|
-
uploadBudget?:
|
|
495
|
+
/** The upload deadline's parameters; SYNC_UPLOAD_BUDGET unless a test shrinks it. */
|
|
496
|
+
uploadBudget?: SyncUploadBudget;
|
|
449
497
|
spoolMaxEntries?: number;
|
|
450
498
|
spoolMaxBytes?: number;
|
|
451
499
|
/** Uncompressed envelope cap; the backend's MAX_SNAPSHOT_BYTES unless a test lowers it. */
|
|
452
500
|
snapshotMaxBytes?: number;
|
|
453
|
-
/** Minimum spacing between change snapshots;
|
|
501
|
+
/** Minimum spacing between change snapshots; MIN_SYNC_CHANGE_INTERVAL_MS unless a test lowers it. */
|
|
454
502
|
minChangeIntervalMs?: number;
|
|
455
|
-
/** Watcher cap;
|
|
503
|
+
/** Watcher cap; MAX_SYNC_WATCHED_FILES unless a test lowers it. */
|
|
456
504
|
maxWatchedFiles?: number;
|
|
457
505
|
/** Budget of the redacted-text cache; REDACTED_TEXT_CACHE_BYTES unless a test changes it. */
|
|
458
506
|
redactedTextCacheBytes?: number;
|
|
@@ -474,7 +522,7 @@ type CollectorOptions = {
|
|
|
474
522
|
/**
|
|
475
523
|
* CLI: extra top-level envelope fields, written after `session`
|
|
476
524
|
* (`client_version`, `client_platform`: optional metadata the backend
|
|
477
|
-
* indexes; see
|
|
525
|
+
* indexes; see session uploader.py `sanitize_snapshot`).
|
|
478
526
|
*/
|
|
479
527
|
envelopeMetadata?: Record<string, string>;
|
|
480
528
|
};
|
|
@@ -712,23 +760,23 @@ const PRIVACY_POLICY = {
|
|
|
712
760
|
denied_path_classes: [".env*", "credentials", "keys", "secrets", "tokens", "passwords", ".aws", ".ssh", ".gnupg", ".git", "node_modules", "binaries_over_4MiB"],
|
|
713
761
|
redaction: ["provider_secrets", "secret_assignments", "private_keys", "pii"],
|
|
714
762
|
manifest_hash_basis: "sha256_of_redacted_utf8",
|
|
715
|
-
max_file_bytes:
|
|
763
|
+
max_file_bytes: MAX_SYNC_FILE_BYTES,
|
|
716
764
|
max_files: MAX_FILES,
|
|
717
|
-
max_diff_bytes:
|
|
765
|
+
max_diff_bytes: MAX_SYNC_DIFF_BYTES,
|
|
718
766
|
} as const;
|
|
719
767
|
|
|
720
768
|
/**
|
|
721
|
-
*
|
|
722
|
-
* upload hook, or
|
|
769
|
+
* WorkspaceSync's `enabled` for these options, without building one: an
|
|
770
|
+
* upload hook, or an upload URL and a bearer (from the options or the
|
|
723
771
|
* environment, as the constructor reads them).
|
|
724
772
|
*/
|
|
725
|
-
export function
|
|
773
|
+
export function workspaceSyncEnabled(input: { upload: boolean; gatewayUrl?: string; accessToken?: string }): boolean {
|
|
726
774
|
if (input.upload) return true;
|
|
727
775
|
const token = (input.accessToken ?? process.env.OMNIRUSH_ACCESS_TOKEN ?? "").trim();
|
|
728
|
-
return Boolean(
|
|
776
|
+
return Boolean(resolveSyncUrl(input.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL) && token);
|
|
729
777
|
}
|
|
730
778
|
|
|
731
|
-
function
|
|
779
|
+
function resolveSyncUrl(rawGatewayUrl: string | undefined): string | null {
|
|
732
780
|
const value = rawGatewayUrl?.trim();
|
|
733
781
|
if (!value) return null;
|
|
734
782
|
try {
|
|
@@ -736,7 +784,7 @@ function resolveCollectUrl(rawGatewayUrl: string | undefined): string | null {
|
|
|
736
784
|
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname))) {
|
|
737
785
|
return null;
|
|
738
786
|
}
|
|
739
|
-
url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "") +
|
|
787
|
+
url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "") + UPLOAD_ENDPOINT_PATH;
|
|
740
788
|
url.search = "";
|
|
741
789
|
url.hash = "";
|
|
742
790
|
return url.toString();
|
|
@@ -767,15 +815,15 @@ export function workspaceRelativePath(root: string, candidate: string, paths: Pl
|
|
|
767
815
|
return path.split(paths.sep).join("/");
|
|
768
816
|
}
|
|
769
817
|
|
|
770
|
-
export function
|
|
771
|
-
return
|
|
818
|
+
export function syncPathForUpload(path: string): string {
|
|
819
|
+
return redactSyncText(path).text;
|
|
772
820
|
}
|
|
773
821
|
|
|
774
822
|
/**
|
|
775
823
|
* Truncates text to `limit` code points without splitting a surrogate pair.
|
|
776
824
|
* The backend counts code points, so this never exceeds its limit.
|
|
777
825
|
*/
|
|
778
|
-
export function
|
|
826
|
+
export function clampSyncText(text: string, limit: number): string {
|
|
779
827
|
if (text.length <= limit) return text;
|
|
780
828
|
const points = Array.from(text);
|
|
781
829
|
return points.length <= limit ? text : points.slice(0, limit).join("");
|
|
@@ -786,7 +834,7 @@ export function clampCollectorText(text: string, limit: number): string {
|
|
|
786
834
|
* Clamps text to a UTF-8 byte budget without splitting a code point. Used for
|
|
787
835
|
* the free-text fields of trace events whose caps are expressed in bytes.
|
|
788
836
|
*/
|
|
789
|
-
export function
|
|
837
|
+
export function clampSyncBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
790
838
|
if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false };
|
|
791
839
|
let low = 0;
|
|
792
840
|
let high = text.length;
|
|
@@ -802,16 +850,16 @@ export function clampCollectorBytes(text: string, maxBytes: number): { text: str
|
|
|
802
850
|
}
|
|
803
851
|
|
|
804
852
|
/** Redacts then clamps free text destined for a trace event. */
|
|
805
|
-
function
|
|
853
|
+
function syncTextForTrace(text: string | null | undefined, maxBytes: number): { text: string | null; truncated: boolean } {
|
|
806
854
|
if (typeof text !== "string") return { text: null, truncated: false };
|
|
807
|
-
return
|
|
855
|
+
return clampSyncBytes(redactSyncText(text).text, maxBytes);
|
|
808
856
|
}
|
|
809
857
|
|
|
810
858
|
/**
|
|
811
859
|
* Whether a browser visit may be traced. Local pages, inline documents and
|
|
812
860
|
* browser-internal URLs never leave the machine.
|
|
813
861
|
*/
|
|
814
|
-
export function
|
|
862
|
+
export function isSyncableWebUrl(raw: string): boolean {
|
|
815
863
|
let url: URL;
|
|
816
864
|
try {
|
|
817
865
|
url = new URL(raw);
|
|
@@ -826,7 +874,7 @@ export function isCollectableWebUrl(raw: string): boolean {
|
|
|
826
874
|
}
|
|
827
875
|
|
|
828
876
|
function workspaceRootName(root: string): string {
|
|
829
|
-
return
|
|
877
|
+
return clampSyncText(syncPathForUpload(root.split(sep).filter(Boolean).at(-1) ?? "workspace"), MAX_ROOT_NAME_CHARS);
|
|
830
878
|
}
|
|
831
879
|
|
|
832
880
|
function sha256Hex(input: string | Buffer): string {
|
|
@@ -860,13 +908,13 @@ function hasScrubbedExtension(lower: string): boolean {
|
|
|
860
908
|
}
|
|
861
909
|
|
|
862
910
|
/**
|
|
863
|
-
* Whether the
|
|
911
|
+
* Whether the session uploader denies every file under this directory path, whatever
|
|
864
912
|
* the file is called: one of its components meets the unconditional rules
|
|
865
913
|
* (`.ssh`, `.aws`, `.gnupg`, `keys`, `secrets`, `credentials*`, `.env*`,
|
|
866
914
|
* `node_modules`, `.git`, a key-store suffix, ...). The project archive
|
|
867
915
|
* refuses such a folder as the root of an all-folders archive.
|
|
868
916
|
*/
|
|
869
|
-
export function
|
|
917
|
+
export function isSyncDirectoryDenied(path: string): boolean {
|
|
870
918
|
return hasDeniedComponent(pathComponents(path));
|
|
871
919
|
}
|
|
872
920
|
|
|
@@ -877,7 +925,7 @@ export function isCollectorDirectoryDenied(path: string): boolean {
|
|
|
877
925
|
* key", "tokens/prod.csv") denies the file unless it has a source-code or docs
|
|
878
926
|
* extension, in which case it is kept and scrubbed like any other source file.
|
|
879
927
|
*/
|
|
880
|
-
export function
|
|
928
|
+
export function isSyncPathDenied(path: string): boolean {
|
|
881
929
|
const parts = pathComponents(path);
|
|
882
930
|
if (hasDeniedComponent(parts)) return true;
|
|
883
931
|
const name = parts.at(-1)?.toLowerCase();
|
|
@@ -1115,7 +1163,7 @@ function nextAssignmentKeyword(text: string, from: number): number {
|
|
|
1115
1163
|
return ASSIGNMENT_LINE_GATE.exec(text)?.index ?? -1;
|
|
1116
1164
|
}
|
|
1117
1165
|
|
|
1118
|
-
export type
|
|
1166
|
+
export type RedactSyncTextOptions = {
|
|
1119
1167
|
/** Text that counts as context for context-dependent rules: the file path, or the JSON key a value sits under. */
|
|
1120
1168
|
context?: string;
|
|
1121
1169
|
/** Value rule mode; CONFIG when omitted (git diffs, the trace, JSON). See redactModeForPath. */
|
|
@@ -1129,7 +1177,7 @@ export type RedactCollectorTextOptions = {
|
|
|
1129
1177
|
* assignments, provider token shapes and personal identifiers. The output
|
|
1130
1178
|
* never contains a dangling backslash, so JSON-escaped text stays escapable.
|
|
1131
1179
|
*/
|
|
1132
|
-
export function
|
|
1180
|
+
export function redactSyncText(input: string, options: RedactSyncTextOptions = {}): { text: string; count: number } {
|
|
1133
1181
|
const tally = { count: 0 };
|
|
1134
1182
|
let text = input.includes("-----BEGIN ") ? applyRedaction(input, PRIVATE_KEY_BLOCK, tally) : input;
|
|
1135
1183
|
if (text.includes("://")) text = applyRedaction(text, URL_USERINFO, tally);
|
|
@@ -1194,7 +1242,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
|
|
|
1194
1242
|
tally.count += 1;
|
|
1195
1243
|
return REDACTED;
|
|
1196
1244
|
}
|
|
1197
|
-
const result =
|
|
1245
|
+
const result = redactSyncText(value, { context: key, awsContext });
|
|
1198
1246
|
tally.count += result.count;
|
|
1199
1247
|
return result.text;
|
|
1200
1248
|
}
|
|
@@ -1212,7 +1260,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
|
|
|
1212
1260
|
tally.members += value.pairs.length;
|
|
1213
1261
|
const context = awsContext || membersNameAws(value.pairs);
|
|
1214
1262
|
return new JsonPairs(value.pairs.map(([childKey, childValue]) => {
|
|
1215
|
-
const scrubbedKey =
|
|
1263
|
+
const scrubbedKey = redactSyncText(childKey, { awsContext: context });
|
|
1216
1264
|
tally.count += scrubbedKey.count;
|
|
1217
1265
|
return [scrubbedKey.text, redactJsonValue(childValue, childKey, tally, depth + 1, context, maxDepth)];
|
|
1218
1266
|
}));
|
|
@@ -1225,7 +1273,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
|
|
|
1225
1273
|
// No prototype, so a `__proto__` key stays an ordinary member.
|
|
1226
1274
|
const output: Record<string, unknown> = Object.create(null);
|
|
1227
1275
|
for (const [childKey, childValue] of entries) {
|
|
1228
|
-
const scrubbedKey =
|
|
1276
|
+
const scrubbedKey = redactSyncText(childKey, { awsContext: context });
|
|
1229
1277
|
tally.count += scrubbedKey.count;
|
|
1230
1278
|
output[scrubbedKey.text] = redactJsonValue(childValue, childKey, tally, depth + 1, context, maxDepth);
|
|
1231
1279
|
}
|
|
@@ -1240,7 +1288,7 @@ function redactJsonValue(value: unknown, key: string | undefined, tally: JsonTal
|
|
|
1240
1288
|
* never produce a broken escape, which scrubbing serialised JSON as text could.
|
|
1241
1289
|
* The backend's _redact_json_value.
|
|
1242
1290
|
*/
|
|
1243
|
-
export function
|
|
1291
|
+
export function redactSyncJson(value: unknown): { value: unknown; count: number } {
|
|
1244
1292
|
const tally: JsonTally = { count: 0, members: 0 };
|
|
1245
1293
|
return { value: redactJsonValue(value, undefined, tally), count: tally.count };
|
|
1246
1294
|
}
|
|
@@ -1358,7 +1406,7 @@ function stringifyJsonPairs(value: unknown, indent: string | undefined, current
|
|
|
1358
1406
|
* MAX_JSON_FILE_DEPTH, which the caller scrubs as text. The backend's
|
|
1359
1407
|
* sanitize_json_file.
|
|
1360
1408
|
*/
|
|
1361
|
-
export function
|
|
1409
|
+
export function redactSyncJsonText(text: string, path?: string): string | null {
|
|
1362
1410
|
const raw = redactAwsSecrets(text, path, { count: 0 });
|
|
1363
1411
|
let parsed: unknown;
|
|
1364
1412
|
try {
|
|
@@ -1391,17 +1439,17 @@ export function redactCollectorJsonText(text: string, path?: string): string | n
|
|
|
1391
1439
|
* back to text when they do not parse), everything else as text with the path
|
|
1392
1440
|
* as context and the value rule mode the extension selects.
|
|
1393
1441
|
*/
|
|
1394
|
-
export function
|
|
1395
|
-
return
|
|
1442
|
+
export function redactSyncContent(path: string, text: string): string {
|
|
1443
|
+
return redactSyncContentCounted(path, text).text;
|
|
1396
1444
|
}
|
|
1397
1445
|
|
|
1398
|
-
/**
|
|
1399
|
-
function
|
|
1446
|
+
/** redactSyncContent plus how many redactions it made (0 means the text is byte-identical). */
|
|
1447
|
+
function redactSyncContentCounted(path: string, text: string): { text: string; count: number } {
|
|
1400
1448
|
if (/\.json$/i.test(path)) {
|
|
1401
|
-
const json =
|
|
1449
|
+
const json = redactSyncJsonText(text, path);
|
|
1402
1450
|
if (json !== null) return { text: json, count: json === text ? 0 : 1 };
|
|
1403
1451
|
}
|
|
1404
|
-
return
|
|
1452
|
+
return redactSyncText(text, { context: path, mode: redactModeForPath(path) });
|
|
1405
1453
|
}
|
|
1406
1454
|
|
|
1407
1455
|
/**
|
|
@@ -1556,7 +1604,7 @@ function gitIgnoredPaths(root: string, paths: string[]): Promise<Set<string> | n
|
|
|
1556
1604
|
* one bad path never changes the answer for the others (and never sends them
|
|
1557
1605
|
* to the .gitignore-only fallback, which knows nothing of `.git/info/exclude`
|
|
1558
1606
|
* or `core.excludesFile`). A path git refuses on its own counts as ignored:
|
|
1559
|
-
* it is never
|
|
1607
|
+
* it is never uploaded. Null only when the workspace is not a git work tree
|
|
1560
1608
|
* (or git is missing), where the caller applies the .gitignore files itself.
|
|
1561
1609
|
* `onSpawn` counts each `git check-ignore` run.
|
|
1562
1610
|
*/
|
|
@@ -1630,10 +1678,10 @@ function nullableString(value: unknown): string | null {
|
|
|
1630
1678
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1631
1679
|
}
|
|
1632
1680
|
|
|
1633
|
-
function ledgerModel(value: unknown):
|
|
1681
|
+
function ledgerModel(value: unknown): SyncSessionModel | undefined {
|
|
1634
1682
|
if (!value || typeof value !== "object") return undefined;
|
|
1635
1683
|
const record = value as Record<string, unknown>;
|
|
1636
|
-
const model:
|
|
1684
|
+
const model: SyncSessionModel = {
|
|
1637
1685
|
provider_id: nullableString(record.provider_id),
|
|
1638
1686
|
model_id: nullableString(record.model_id),
|
|
1639
1687
|
variant: nullableString(record.variant),
|
|
@@ -1703,7 +1751,7 @@ function boundedTracePayload(
|
|
|
1703
1751
|
// dangling backslash that made the whole document unparseable. Header and
|
|
1704
1752
|
// events are scrubbed as the one document the backend scrubs, so a header
|
|
1705
1753
|
// value naming an AWS key is container context for the events there too.
|
|
1706
|
-
const { events: scrubbed, ...header } =
|
|
1754
|
+
const { events: scrubbed, ...header } = redactSyncJson({
|
|
1707
1755
|
schema_version: 1,
|
|
1708
1756
|
session_id: state.id,
|
|
1709
1757
|
workspace_id: state.workspaceId,
|
|
@@ -1726,7 +1774,7 @@ function boundedTracePayload(
|
|
|
1726
1774
|
// is its wrapper plus the events' serialised bytes and the commas between
|
|
1727
1775
|
// them, so the longest prefix that fits follows from a running total. Every
|
|
1728
1776
|
// probe used to be a full serialisation of the prefix, which made a flush
|
|
1729
|
-
// at the
|
|
1777
|
+
// at the MAX_SYNC_TRACE_EVENTS cap peak at many times the payload.
|
|
1730
1778
|
let payload = encode(scrubbed, false);
|
|
1731
1779
|
if (payload.byteLength <= maxBytes) return { content: payload.content, events: payload.events };
|
|
1732
1780
|
const wrapperBytes = (included: number) => Buffer.byteLength(JSON.stringify({
|
|
@@ -1786,7 +1834,7 @@ async function walkFallback(root: string, directory = root, rules: string[] = []
|
|
|
1786
1834
|
if (hasDeniedComponent(pathComponents(path))) listing.denied += 1;
|
|
1787
1835
|
else await walkFallback(root, fullPath, ignoreRules, listing);
|
|
1788
1836
|
} else if (entry.isFile()) {
|
|
1789
|
-
if (
|
|
1837
|
+
if (isSyncPathDenied(path)) listing.denied += 1;
|
|
1790
1838
|
else listing.paths.push(path);
|
|
1791
1839
|
}
|
|
1792
1840
|
}
|
|
@@ -1797,7 +1845,7 @@ function filterListing(candidates: string[]): WorkspaceListing {
|
|
|
1797
1845
|
const listing: WorkspaceListing = { paths: [], denied: 0 };
|
|
1798
1846
|
for (const path of candidates) {
|
|
1799
1847
|
if (!path) continue;
|
|
1800
|
-
if (
|
|
1848
|
+
if (isSyncPathDenied(path)) listing.denied += 1;
|
|
1801
1849
|
else if (listing.paths.length < MAX_FILES) listing.paths.push(path);
|
|
1802
1850
|
}
|
|
1803
1851
|
listing.paths.sort((left, right) => left.localeCompare(right));
|
|
@@ -1901,9 +1949,9 @@ function parseGitStatus(raw: string, prefix = ""): { entries: GitStatusEntry[];
|
|
|
1901
1949
|
const path = repoPath.slice(prefix.length);
|
|
1902
1950
|
if (!path) continue;
|
|
1903
1951
|
if (code !== "??" && code !== "!!") dirty = true;
|
|
1904
|
-
if (
|
|
1952
|
+
if (isSyncPathDenied(path)) continue;
|
|
1905
1953
|
if (entries.length >= MAX_GIT_STATUS_ENTRIES) continue;
|
|
1906
|
-
const uploadPath =
|
|
1954
|
+
const uploadPath = syncPathForUpload(path);
|
|
1907
1955
|
if (uploadPath.length > MAX_GIT_PATH_CHARS) continue;
|
|
1908
1956
|
entries.push({ code, path: uploadPath });
|
|
1909
1957
|
}
|
|
@@ -1921,8 +1969,8 @@ function parseGitRemotes(raw: string): GitRemote[] {
|
|
|
1921
1969
|
return [...remotes.entries()]
|
|
1922
1970
|
.slice(0, MAX_GIT_REMOTES)
|
|
1923
1971
|
.map(([name, url]) => ({
|
|
1924
|
-
name:
|
|
1925
|
-
url:
|
|
1972
|
+
name: clampSyncText(syncPathForUpload(name), MAX_GIT_REMOTE_NAME_CHARS),
|
|
1973
|
+
url: clampSyncText(syncPathForUpload(stripRemoteUserinfo(url)), MAX_GIT_REMOTE_URL_CHARS),
|
|
1926
1974
|
}));
|
|
1927
1975
|
}
|
|
1928
1976
|
|
|
@@ -1931,7 +1979,7 @@ function parseGitLog(raw: string): GitCommit[] {
|
|
|
1931
1979
|
for (const record of raw.split("\0")) {
|
|
1932
1980
|
const [sha, at, subject] = record.replace(/^\n/, "").split("\x1f");
|
|
1933
1981
|
if (!sha || !/^[0-9a-f]{7,64}$/.test(sha) || !at) continue;
|
|
1934
|
-
commits.push({ sha, at, subject:
|
|
1982
|
+
commits.push({ sha, at, subject: clampSyncText(syncPathForUpload(subject ?? ""), MAX_GIT_SUBJECT_CHARS) });
|
|
1935
1983
|
if (commits.length >= MAX_GIT_RECENT_COMMITS) break;
|
|
1936
1984
|
}
|
|
1937
1985
|
return commits;
|
|
@@ -1995,7 +2043,7 @@ export function diffHeaderPath(header: string): string | null {
|
|
|
1995
2043
|
* them, and caps the result. Sections whose header cannot be parsed are
|
|
1996
2044
|
* dropped: an unreadable path must never leak a denied file's contents.
|
|
1997
2045
|
*/
|
|
1998
|
-
export function
|
|
2046
|
+
export function filterSyncDiff(raw: string, inputTruncated = false): { diff: string | null; truncated: boolean } {
|
|
1999
2047
|
let output = "";
|
|
2000
2048
|
let used = 0;
|
|
2001
2049
|
let truncated = inputTruncated;
|
|
@@ -2003,15 +2051,15 @@ export function filterCollectorDiff(raw: string, inputTruncated = false): { diff
|
|
|
2003
2051
|
if (!section.startsWith("diff --git ")) continue;
|
|
2004
2052
|
const headerEnd = section.indexOf("\n");
|
|
2005
2053
|
const path = diffHeaderPath(headerEnd === -1 ? section : section.slice(0, headerEnd));
|
|
2006
|
-
if (!path ||
|
|
2054
|
+
if (!path || isSyncPathDenied(path)) continue;
|
|
2007
2055
|
if (/^(?:Binary files .* differ|GIT binary patch)/m.test(section)) continue;
|
|
2008
2056
|
// A diff is text even when the file is JSON: hunks are not parseable documents.
|
|
2009
|
-
const redacted =
|
|
2057
|
+
const redacted = redactSyncText(section, { context: path }).text;
|
|
2010
2058
|
const size = Buffer.byteLength(redacted);
|
|
2011
|
-
if (used + size >
|
|
2059
|
+
if (used + size > MAX_SYNC_DIFF_BYTES) {
|
|
2012
2060
|
truncated = true;
|
|
2013
2061
|
if (used === 0) {
|
|
2014
|
-
output = Buffer.from(redacted).subarray(0,
|
|
2062
|
+
output = Buffer.from(redacted).subarray(0, MAX_SYNC_DIFF_BYTES).toString("utf8").replace(/�+$/, "");
|
|
2015
2063
|
}
|
|
2016
2064
|
break;
|
|
2017
2065
|
}
|
|
@@ -2034,7 +2082,7 @@ async function gitDiff(root: string, hasHead: boolean): Promise<{ diff: string |
|
|
|
2034
2082
|
const options = { maxBytes: GIT_DIFF_READ_BYTES, timeoutMs: 20_000 };
|
|
2035
2083
|
if (hasHead) {
|
|
2036
2084
|
const result = await runGit(root, ["diff", "HEAD", ...common, ...scope], options);
|
|
2037
|
-
if (result.ok) return
|
|
2085
|
+
if (result.ok) return filterSyncDiff(result.stdout.toString("utf8"), result.truncated);
|
|
2038
2086
|
}
|
|
2039
2087
|
// An unborn branch has no HEAD to diff against: combine the index (against
|
|
2040
2088
|
// the empty tree) with the working tree changes on top of it.
|
|
@@ -2044,7 +2092,7 @@ async function gitDiff(root: string, hasHead: boolean): Promise<{ diff: string |
|
|
|
2044
2092
|
]);
|
|
2045
2093
|
if (!staged.ok && !unstaged.ok) return { diff: null, truncated: false };
|
|
2046
2094
|
const text = `${staged.ok ? staged.stdout.toString("utf8") : ""}${unstaged.ok ? unstaged.stdout.toString("utf8") : ""}`;
|
|
2047
|
-
return
|
|
2095
|
+
return filterSyncDiff(text, staged.truncated || unstaged.truncated);
|
|
2048
2096
|
}
|
|
2049
2097
|
|
|
2050
2098
|
/**
|
|
@@ -2054,7 +2102,7 @@ async function gitDiff(root: string, hasHead: boolean): Promise<{ diff: string |
|
|
|
2054
2102
|
* upstream, remotes without userinfo, recent subjects) and what is in flight
|
|
2055
2103
|
* (porcelain status and the combined text diff), all redacted.
|
|
2056
2104
|
*/
|
|
2057
|
-
export async function
|
|
2105
|
+
export async function readGitBlock(root: string): Promise<SyncGitBlock | null> {
|
|
2058
2106
|
const inside = await gitText(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
2059
2107
|
if (inside !== "true") return null;
|
|
2060
2108
|
// Where the workspace sits inside the repository ("" at the top level,
|
|
@@ -2087,9 +2135,9 @@ export async function collectGitBlock(root: string): Promise<CollectorGitBlock |
|
|
|
2087
2135
|
const diff = await gitDiff(root, Boolean(commit));
|
|
2088
2136
|
return {
|
|
2089
2137
|
commit,
|
|
2090
|
-
branch: branch ?
|
|
2138
|
+
branch: branch ? clampSyncText(syncPathForUpload(branch), MAX_GIT_REF_CHARS) : null,
|
|
2091
2139
|
dirty: parsedStatus.dirty,
|
|
2092
|
-
upstream: upstream ?
|
|
2140
|
+
upstream: upstream ? clampSyncText(syncPathForUpload(upstream), MAX_GIT_REF_CHARS) : null,
|
|
2093
2141
|
ahead,
|
|
2094
2142
|
behind,
|
|
2095
2143
|
remotes: remotes ? parseGitRemotes(remotes) : [],
|
|
@@ -2194,7 +2242,7 @@ class ReadPool {
|
|
|
2194
2242
|
|
|
2195
2243
|
/** Runs `use` with the file's bytes; the view must not escape `use`. */
|
|
2196
2244
|
async read<T>(absolute: string, expectedSize: number, use: (bytes: Buffer) => Promise<T> | T): Promise<T | null> {
|
|
2197
|
-
if (expectedSize >
|
|
2245
|
+
if (expectedSize > MAX_SYNC_FILE_BYTES) return null;
|
|
2198
2246
|
if (expectedSize < this.capacity) {
|
|
2199
2247
|
const buffer = await this.acquire();
|
|
2200
2248
|
try {
|
|
@@ -2210,10 +2258,10 @@ class ReadPool {
|
|
|
2210
2258
|
|
|
2211
2259
|
private readLarge<T>(absolute: string, use: (bytes: Buffer) => Promise<T> | T): Promise<T | null> {
|
|
2212
2260
|
const run = this.largeTail.then(async () => {
|
|
2213
|
-
this.large ??= Buffer.allocUnsafeSlow(
|
|
2261
|
+
this.large ??= Buffer.allocUnsafeSlow(MAX_SYNC_FILE_BYTES + 1);
|
|
2214
2262
|
const length = await readInto(absolute, this.large);
|
|
2215
|
-
// Past the cap by now: not a file the
|
|
2216
|
-
return length >
|
|
2263
|
+
// Past the cap by now: not a file the session uploader takes.
|
|
2264
|
+
return length > MAX_SYNC_FILE_BYTES ? null : await use(this.large.subarray(0, length));
|
|
2217
2265
|
});
|
|
2218
2266
|
this.largeTail = run.catch(() => undefined);
|
|
2219
2267
|
return run;
|
|
@@ -2310,7 +2358,7 @@ export async function mapBounded<T, R>(items: readonly T[], limit: number, opera
|
|
|
2310
2358
|
}
|
|
2311
2359
|
|
|
2312
2360
|
/** Work counters for tests and profiling; never uploaded. */
|
|
2313
|
-
export type
|
|
2361
|
+
export type SyncMetrics = {
|
|
2314
2362
|
fileStats: number;
|
|
2315
2363
|
fileReads: number;
|
|
2316
2364
|
fileRedactions: number;
|
|
@@ -2328,7 +2376,7 @@ export type CollectorMetrics = {
|
|
|
2328
2376
|
redactedTextHits: number;
|
|
2329
2377
|
};
|
|
2330
2378
|
|
|
2331
|
-
function freshMetrics():
|
|
2379
|
+
function freshMetrics(): SyncMetrics {
|
|
2332
2380
|
return {
|
|
2333
2381
|
fileStats: 0, fileReads: 0, fileRedactions: 0, fullScans: 0, dirtyScans: 0, reconciles: 0,
|
|
2334
2382
|
capturesSkipped: 0, capturesDeferred: 0, capturesHeld: 0, ignoreCheckSpawns: 0, watchEvents: 0, envelopesWritten: 0, redactedTextHits: 0,
|
|
@@ -2346,10 +2394,10 @@ function redactedCacheEntry(
|
|
|
2346
2394
|
size: number,
|
|
2347
2395
|
mtimeMs: number,
|
|
2348
2396
|
uploadPath: string,
|
|
2349
|
-
metrics:
|
|
2397
|
+
metrics: SyncMetrics,
|
|
2350
2398
|
texts?: { cache: RedactedTextCache; absolute: string },
|
|
2351
2399
|
): { entry: HashCacheEntry; text: string } {
|
|
2352
|
-
const redacted =
|
|
2400
|
+
const redacted = redactSyncContentCounted(path, buffer.toString("utf8"));
|
|
2353
2401
|
metrics.fileRedactions += 1;
|
|
2354
2402
|
const entry: HashCacheEntry = {
|
|
2355
2403
|
size,
|
|
@@ -2371,7 +2419,7 @@ function redactedCacheEntry(
|
|
|
2371
2419
|
/** The redacted upload path, sharing the listing's string when redaction left it unchanged. */
|
|
2372
2420
|
function uploadPathFor(path: string, cached: HashCacheEntry | undefined): string {
|
|
2373
2421
|
if (cached) return cached.uploadPath;
|
|
2374
|
-
const uploadPath =
|
|
2422
|
+
const uploadPath = syncPathForUpload(path);
|
|
2375
2423
|
return uploadPath === path ? path : uploadPath;
|
|
2376
2424
|
}
|
|
2377
2425
|
|
|
@@ -2409,13 +2457,13 @@ type ScanContext = {
|
|
|
2409
2457
|
rootPrefix: string;
|
|
2410
2458
|
cache: Map<string, HashCacheEntry>;
|
|
2411
2459
|
texts: RedactedTextCache;
|
|
2412
|
-
metrics:
|
|
2460
|
+
metrics: SyncMetrics;
|
|
2413
2461
|
yielder: LoopYielder;
|
|
2414
2462
|
pool: ReadPool;
|
|
2415
2463
|
ancestors: AncestorCache;
|
|
2416
2464
|
};
|
|
2417
2465
|
|
|
2418
|
-
function scanContext(root: string, cache: Map<string, HashCacheEntry>, texts: RedactedTextCache, metrics:
|
|
2466
|
+
function scanContext(root: string, cache: Map<string, HashCacheEntry>, texts: RedactedTextCache, metrics: SyncMetrics): ScanContext {
|
|
2419
2467
|
const base = resolve(root);
|
|
2420
2468
|
return {
|
|
2421
2469
|
root,
|
|
@@ -2448,7 +2496,7 @@ async function inspectFile(context: ScanContext, path: string): Promise<HashCach
|
|
|
2448
2496
|
return null;
|
|
2449
2497
|
}
|
|
2450
2498
|
metrics.fileStats += 1;
|
|
2451
|
-
if (!file.isFile() || file.isSymbolicLink() || file.size >
|
|
2499
|
+
if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_SYNC_FILE_BYTES) return null;
|
|
2452
2500
|
const cached = cache.get(path);
|
|
2453
2501
|
if (cached && cached.size === file.size && cached.mtimeMs === file.mtimeMs) return cached;
|
|
2454
2502
|
const uploadPath = uploadPathFor(path, cached);
|
|
@@ -2485,7 +2533,7 @@ async function readUploadContent(
|
|
|
2485
2533
|
path: string,
|
|
2486
2534
|
cache: Map<string, HashCacheEntry>,
|
|
2487
2535
|
texts: RedactedTextCache,
|
|
2488
|
-
metrics:
|
|
2536
|
+
metrics: SyncMetrics,
|
|
2489
2537
|
pool: ReadPool,
|
|
2490
2538
|
ancestors: AncestorCache,
|
|
2491
2539
|
): Promise<UploadContent | null> {
|
|
@@ -2498,7 +2546,7 @@ async function readUploadContent(
|
|
|
2498
2546
|
} catch {
|
|
2499
2547
|
return null;
|
|
2500
2548
|
}
|
|
2501
|
-
if (!file.isFile() || file.isSymbolicLink() || file.size >
|
|
2549
|
+
if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_SYNC_FILE_BYTES) return null;
|
|
2502
2550
|
const cached = cache.get(path);
|
|
2503
2551
|
try {
|
|
2504
2552
|
return await pool.read(absolute, file.size, (buffer): UploadContent | null => {
|
|
@@ -2559,7 +2607,7 @@ type SnapshotCandidate = {
|
|
|
2559
2607
|
};
|
|
2560
2608
|
|
|
2561
2609
|
/**
|
|
2562
|
-
* The workspace as a turn began, by path: null for a file the
|
|
2610
|
+
* The workspace as a turn began, by path: null for a file the session uploader only
|
|
2563
2611
|
* ever saw once the turn had written it (it was there, but what it held
|
|
2564
2612
|
* before the turn is unknown).
|
|
2565
2613
|
*/
|
|
@@ -2665,7 +2713,7 @@ async function scanWorkspaceFull(
|
|
|
2665
2713
|
texts: RedactedTextCache,
|
|
2666
2714
|
previous: Manifest | null,
|
|
2667
2715
|
includeAll: boolean,
|
|
2668
|
-
metrics:
|
|
2716
|
+
metrics: SyncMetrics,
|
|
2669
2717
|
onListing?: (paths: readonly string[]) => void,
|
|
2670
2718
|
): Promise<ScanResult> {
|
|
2671
2719
|
metrics.fullScans += 1;
|
|
@@ -2712,7 +2760,7 @@ async function scanDirtyPaths(
|
|
|
2712
2760
|
dirty: readonly string[],
|
|
2713
2761
|
ignored: ReadonlySet<string>,
|
|
2714
2762
|
listing: { denied: number; truncated: boolean },
|
|
2715
|
-
metrics:
|
|
2763
|
+
metrics: SyncMetrics,
|
|
2716
2764
|
): Promise<ScanResult> {
|
|
2717
2765
|
metrics.dirtyScans += 1;
|
|
2718
2766
|
const context = scanContext(root, cache, texts, metrics);
|
|
@@ -2765,13 +2813,13 @@ async function scanDirtyPaths(
|
|
|
2765
2813
|
* Paths whose content a capped snapshot keeps first: what the session touched
|
|
2766
2814
|
* plus what git reports as modified, keyed the way files[] names them.
|
|
2767
2815
|
*/
|
|
2768
|
-
function snapshotPriorityPaths(touched: Iterable<string>, git:
|
|
2816
|
+
function snapshotPriorityPaths(touched: Iterable<string>, git: SyncGitBlock | null): Set<string> {
|
|
2769
2817
|
const paths = new Set<string>();
|
|
2770
|
-
for (const path of touched) paths.add(
|
|
2818
|
+
for (const path of touched) paths.add(syncPathForUpload(path));
|
|
2771
2819
|
for (const entry of git?.status ?? []) paths.add(entry.path);
|
|
2772
2820
|
for (const line of git?.diff?.split("\n") ?? []) {
|
|
2773
2821
|
const path = diffHeaderPath(line);
|
|
2774
|
-
if (path) paths.add(
|
|
2822
|
+
if (path) paths.add(syncPathForUpload(path));
|
|
2775
2823
|
}
|
|
2776
2824
|
return paths;
|
|
2777
2825
|
}
|
|
@@ -2850,7 +2898,7 @@ const JSON_STRING_PIECE_CHARS = 1 << 20;
|
|
|
2850
2898
|
/**
|
|
2851
2899
|
* Writes `value` as JSON, byte for byte what JSON.stringify(value) gives,
|
|
2852
2900
|
* without building the whole text: a plain object's long string values (a
|
|
2853
|
-
* file's content, up to
|
|
2901
|
+
* file's content, up to MAX_SYNC_FILE_BYTES; the trace document, up to
|
|
2854
2902
|
* MAX_TRACE_BYTES) are escaped in pieces, so no escaped copy of them exists
|
|
2855
2903
|
* at once. Anything else goes through JSON.stringify.
|
|
2856
2904
|
*/
|
|
@@ -2961,14 +3009,14 @@ type EnvelopeBody = {
|
|
|
2961
3009
|
/** Envelope fields written before files[]: workspace, environment, touched paths, scope. */
|
|
2962
3010
|
extras: Record<string, unknown>;
|
|
2963
3011
|
/** Streams files[] one entry at a time through `emit`. */
|
|
2964
|
-
writeFiles: (emit: (file:
|
|
3012
|
+
writeFiles: (emit: (file: SyncFile) => Promise<void>) => Promise<void>;
|
|
2965
3013
|
manifest: () => Iterable<HashCacheEntry>;
|
|
2966
3014
|
/** Computed once files[] is written, so cap omissions are known. */
|
|
2967
3015
|
privacy: () => Record<string, unknown>;
|
|
2968
3016
|
trace?: unknown[];
|
|
2969
3017
|
};
|
|
2970
3018
|
|
|
2971
|
-
function
|
|
3019
|
+
function syncEnvironment(appVersion: string | undefined, engineVersion: string | undefined, gitVersion: string | null): SyncEnvironment {
|
|
2972
3020
|
let locale: string | null = null;
|
|
2973
3021
|
let timezone: string | null = null;
|
|
2974
3022
|
try {
|
|
@@ -2980,7 +3028,7 @@ function collectorEnvironment(appVersion: string | undefined, engineVersion: str
|
|
|
2980
3028
|
}
|
|
2981
3029
|
const shellPath = process.platform === "win32" ? process.env.COMSPEC : process.env.SHELL;
|
|
2982
3030
|
const field = (value: string | null | undefined): string | null =>
|
|
2983
|
-
value?.trim() ?
|
|
3031
|
+
value?.trim() ? clampSyncText(value.trim(), MAX_ENVIRONMENT_FIELD_CHARS) : null;
|
|
2984
3032
|
return {
|
|
2985
3033
|
os: process.platform,
|
|
2986
3034
|
os_version: field(osRelease()) ?? "",
|
|
@@ -3078,7 +3126,7 @@ function parseSpoolMeta(value: unknown): SpoolMeta | null {
|
|
|
3078
3126
|
id: record.id,
|
|
3079
3127
|
session_id: record.session_id,
|
|
3080
3128
|
snapshot_type: record.snapshot_type as SnapshotType,
|
|
3081
|
-
trigger: record.trigger as
|
|
3129
|
+
trigger: record.trigger as SyncTrigger,
|
|
3082
3130
|
sequence: record.sequence,
|
|
3083
3131
|
bytes: record.bytes,
|
|
3084
3132
|
created_at: record.created_at,
|
|
@@ -3087,13 +3135,13 @@ function parseSpoolMeta(value: unknown): SpoolMeta | null {
|
|
|
3087
3135
|
};
|
|
3088
3136
|
}
|
|
3089
3137
|
|
|
3090
|
-
export class
|
|
3091
|
-
private readonly
|
|
3138
|
+
export class WorkspaceSync {
|
|
3139
|
+
private readonly syncUrl: string | null;
|
|
3092
3140
|
private token: string;
|
|
3093
3141
|
private readonly fetcher: typeof externalFetch;
|
|
3094
|
-
private readonly uploader?:
|
|
3095
|
-
private readonly refreshAccessToken?:
|
|
3096
|
-
private readonly log: NonNullable<
|
|
3142
|
+
private readonly uploader?: SyncOptions["upload"];
|
|
3143
|
+
private readonly refreshAccessToken?: SyncOptions["refreshAccessToken"];
|
|
3144
|
+
private readonly log: NonNullable<SyncOptions["log"]>;
|
|
3097
3145
|
private readonly ledgerPath: string | null;
|
|
3098
3146
|
private readonly spoolDir: string | null;
|
|
3099
3147
|
private readonly ledgerReady: Promise<void>;
|
|
@@ -3107,11 +3155,11 @@ export class WorkspaceCollector {
|
|
|
3107
3155
|
private readonly uploadRetryDelayMs: number;
|
|
3108
3156
|
private readonly retryBaseMs: number;
|
|
3109
3157
|
private readonly retryMaxMs: number;
|
|
3110
|
-
private readonly uploadBudget:
|
|
3158
|
+
private readonly uploadBudget: SyncUploadBudget;
|
|
3111
3159
|
private readonly spoolMaxEntries: number;
|
|
3112
3160
|
private readonly spoolMaxBytes: number;
|
|
3113
3161
|
private readonly snapshotMaxBytes: number;
|
|
3114
|
-
private environmentCache: Promise<
|
|
3162
|
+
private environmentCache: Promise<SyncEnvironment> | null = null;
|
|
3115
3163
|
private spoolCounter = 0;
|
|
3116
3164
|
private spoolTail: Promise<void> = Promise.resolve();
|
|
3117
3165
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -3146,16 +3194,17 @@ export class WorkspaceCollector {
|
|
|
3146
3194
|
private readonly watch: boolean;
|
|
3147
3195
|
private readonly envelopeMetadata: Record<string, string>;
|
|
3148
3196
|
/** Work counters for tests and profiling. */
|
|
3149
|
-
readonly metrics:
|
|
3197
|
+
readonly metrics: SyncMetrics = freshMetrics();
|
|
3150
3198
|
|
|
3151
|
-
constructor(options:
|
|
3152
|
-
this.
|
|
3199
|
+
constructor(options: SyncOptions = {}) {
|
|
3200
|
+
this.syncUrl = resolveSyncUrl(options.gatewayUrl ?? process.env.OMNIRUSH_GATEWAY_URL);
|
|
3153
3201
|
this.token = (options.accessToken ?? process.env.OMNIRUSH_ACCESS_TOKEN ?? "").trim();
|
|
3154
3202
|
this.fetcher = options.fetch ?? externalFetch;
|
|
3155
3203
|
this.uploader = options.upload;
|
|
3156
3204
|
this.refreshAccessToken = options.refreshAccessToken;
|
|
3157
3205
|
this.log = options.log ?? (() => undefined);
|
|
3158
3206
|
const stateDir = options.stateDir ? resolve(options.stateDir) : null;
|
|
3207
|
+
if (stateDir) migrateLegacyStateNames(stateDir);
|
|
3159
3208
|
this.ledgerPath = stateDir ? join(stateDir, SESSION_LEDGER_FILE) : null;
|
|
3160
3209
|
this.spoolDir = stateDir ? join(stateDir, SPOOL_DIRECTORY) : null;
|
|
3161
3210
|
this.ledgerReady = this.loadLedger();
|
|
@@ -3166,18 +3215,18 @@ export class WorkspaceCollector {
|
|
|
3166
3215
|
this.uploadRetryDelayMs = options.uploadRetryDelayMs ?? UPLOAD_RETRY_DELAY_MS;
|
|
3167
3216
|
this.retryBaseMs = options.retryBaseMs ?? RETRY_BASE_MS;
|
|
3168
3217
|
this.retryMaxMs = options.retryMaxMs ?? RETRY_MAX_MS;
|
|
3169
|
-
this.uploadBudget = options.uploadBudget ??
|
|
3218
|
+
this.uploadBudget = options.uploadBudget ?? SYNC_UPLOAD_BUDGET;
|
|
3170
3219
|
this.spoolMaxEntries = options.spoolMaxEntries ?? MAX_SPOOL_ENTRIES;
|
|
3171
3220
|
this.spoolMaxBytes = options.spoolMaxBytes ?? MAX_SPOOL_BYTES;
|
|
3172
3221
|
this.snapshotMaxBytes = options.snapshotMaxBytes ?? MAX_SNAPSHOT_BYTES;
|
|
3173
|
-
this.minChangeIntervalMs = options.minChangeIntervalMs ??
|
|
3174
|
-
this.maxWatchedFiles = options.maxWatchedFiles ??
|
|
3222
|
+
this.minChangeIntervalMs = options.minChangeIntervalMs ?? MIN_SYNC_CHANGE_INTERVAL_MS;
|
|
3223
|
+
this.maxWatchedFiles = options.maxWatchedFiles ?? MAX_SYNC_WATCHED_FILES;
|
|
3175
3224
|
this.texts = new RedactedTextCache(options.redactedTextCacheBytes ?? REDACTED_TEXT_CACHE_BYTES);
|
|
3176
3225
|
this.onSessionClosed = options.onSessionClosed;
|
|
3177
3226
|
this.onPathTouched = options.onPathTouched;
|
|
3178
3227
|
this.watch = options.watch !== false;
|
|
3179
3228
|
this.envelopeMetadata = { ...(options.envelopeMetadata ?? {}) };
|
|
3180
|
-
this.tempDir = stateDir ? join(stateDir, TEMP_DIRECTORY) : join(tmpdir(), `omnirush-
|
|
3229
|
+
this.tempDir = stateDir ? join(stateDir, TEMP_DIRECTORY) : join(tmpdir(), `omnirush-upload-${process.pid}`);
|
|
3181
3230
|
this.bases = new TurnBaseStore(stateDir ? join(stateDir, BASE_DIRECTORY) : join(this.tempDir, BASE_DIRECTORY));
|
|
3182
3231
|
void this.cleanTempDir(60 * 60_000).catch(() => undefined);
|
|
3183
3232
|
if (this.spoolDir) {
|
|
@@ -3318,10 +3367,10 @@ export class WorkspaceCollector {
|
|
|
3318
3367
|
}
|
|
3319
3368
|
|
|
3320
3369
|
get enabled(): boolean {
|
|
3321
|
-
return Boolean(this.uploader || (this.
|
|
3370
|
+
return Boolean(this.uploader || (this.syncUrl && this.token));
|
|
3322
3371
|
}
|
|
3323
3372
|
|
|
3324
|
-
private environment(): Promise<
|
|
3373
|
+
private environment(): Promise<SyncEnvironment> {
|
|
3325
3374
|
this.environmentCache ??= (async () => {
|
|
3326
3375
|
let gitVersion: string | null = null;
|
|
3327
3376
|
try {
|
|
@@ -3330,7 +3379,7 @@ export class WorkspaceCollector {
|
|
|
3330
3379
|
} catch {
|
|
3331
3380
|
gitVersion = null;
|
|
3332
3381
|
}
|
|
3333
|
-
return
|
|
3382
|
+
return syncEnvironment(this.appVersion, this.engineVersion, gitVersion);
|
|
3334
3383
|
})();
|
|
3335
3384
|
return this.environmentCache;
|
|
3336
3385
|
}
|
|
@@ -3464,7 +3513,7 @@ export class WorkspaceCollector {
|
|
|
3464
3513
|
state.watchMode = "polling";
|
|
3465
3514
|
state.dirtyOverflow = true;
|
|
3466
3515
|
this.stopReconcile(state);
|
|
3467
|
-
this.log("info", "OmniRush
|
|
3516
|
+
this.log("info", "OmniRush session upload: polls this workspace on snapshots instead of watching it", {
|
|
3468
3517
|
sessionId: state.id,
|
|
3469
3518
|
files,
|
|
3470
3519
|
limit: this.maxWatchedFiles,
|
|
@@ -3516,7 +3565,7 @@ export class WorkspaceCollector {
|
|
|
3516
3565
|
const path = workspaceRelativePath(state.root, prefix ? `${prefix}/${relative}` : relative);
|
|
3517
3566
|
if (!path) return;
|
|
3518
3567
|
this.reportTouched(state, path);
|
|
3519
|
-
if (
|
|
3568
|
+
if (isSyncPathDenied(path)) return;
|
|
3520
3569
|
if (path === ".gitignore" || path.endsWith("/.gitignore")) {
|
|
3521
3570
|
// New ignore rules can hide or reveal any number of files: forget what
|
|
3522
3571
|
// git answered so far and rescan the tree with the rules as they stand.
|
|
@@ -3615,7 +3664,7 @@ export class WorkspaceCollector {
|
|
|
3615
3664
|
await mapBounded(listing.paths, SCAN_CONCURRENCY, async (path) => {
|
|
3616
3665
|
const file = await lstat(resolve(state.root, path)).catch(() => null);
|
|
3617
3666
|
if (file) this.metrics.fileStats += 1;
|
|
3618
|
-
const eligible = file && file.isFile() && !file.isSymbolicLink() && file.size <=
|
|
3667
|
+
const eligible = file && file.isFile() && !file.isSymbolicLink() && file.size <= MAX_SYNC_FILE_BYTES ? file : null;
|
|
3619
3668
|
const cached = cache.get(path);
|
|
3620
3669
|
for (const session of sessions) {
|
|
3621
3670
|
const known = session.manifest?.get(path);
|
|
@@ -3697,7 +3746,7 @@ export class WorkspaceCollector {
|
|
|
3697
3746
|
* asks git about every path whatever the cache holds, and refreshes the
|
|
3698
3747
|
* cache with the answers: a snapshot decides what leaves the machine on
|
|
3699
3748
|
* that answer, since `.git/info/exclude` or `core.excludesFile` can change
|
|
3700
|
-
* with no event the
|
|
3749
|
+
* with no event the session uploader sees.
|
|
3701
3750
|
*/
|
|
3702
3751
|
private async ignoredPaths(state: SessionState, paths: string[], fresh = false): Promise<Set<string>> {
|
|
3703
3752
|
const ignored = new Set<string>();
|
|
@@ -3796,24 +3845,24 @@ export class WorkspaceCollector {
|
|
|
3796
3845
|
this.appendTrace(state, type, data);
|
|
3797
3846
|
}
|
|
3798
3847
|
|
|
3799
|
-
/** Appends one trace event, keeping only the newest
|
|
3848
|
+
/** Appends one trace event, keeping only the newest MAX_SYNC_TRACE_EVENTS. */
|
|
3800
3849
|
private appendTrace(state: SessionState, type: string, data?: unknown): void {
|
|
3801
3850
|
state.trace.push({ at: new Date().toISOString(), type, ...(data === undefined ? {} : { data }) });
|
|
3802
|
-
if (state.trace.length >
|
|
3851
|
+
if (state.trace.length > MAX_SYNC_TRACE_EVENTS) state.trace.splice(0, state.trace.length - MAX_SYNC_TRACE_EVENTS);
|
|
3803
3852
|
}
|
|
3804
3853
|
|
|
3805
3854
|
private recordTouchedPath(state: SessionState, candidate: string): void {
|
|
3806
3855
|
const path = workspaceRelativePath(state.root, candidate.replaceAll("\\", "/"));
|
|
3807
3856
|
if (!path) return;
|
|
3808
3857
|
this.reportTouched(state, path);
|
|
3809
|
-
if (
|
|
3858
|
+
if (isSyncPathDenied(path) || state.touchedPaths.has(path)) return;
|
|
3810
3859
|
state.touchedPaths.add(path);
|
|
3811
3860
|
this.markDirty(state, path);
|
|
3812
3861
|
this.queueChangedPath(state, path);
|
|
3813
3862
|
this.scheduleChange(state, "fs_change");
|
|
3814
3863
|
}
|
|
3815
3864
|
|
|
3816
|
-
/** Whether the
|
|
3865
|
+
/** Whether the session uploader is currently tracking this session. */
|
|
3817
3866
|
hasSession(sessionId: string): boolean {
|
|
3818
3867
|
const state = this.sessions.get(sessionId);
|
|
3819
3868
|
return Boolean(state && !state.finished);
|
|
@@ -3824,11 +3873,11 @@ export class WorkspaceCollector {
|
|
|
3824
3873
|
* message) as a "session.model" event and remembers it for the envelope's
|
|
3825
3874
|
* "session" block. Identical for omnirush.ai and every external provider.
|
|
3826
3875
|
*/
|
|
3827
|
-
recordSessionModel(sessionId: string, model:
|
|
3876
|
+
recordSessionModel(sessionId: string, model: SyncSessionModel): void {
|
|
3828
3877
|
const state = this.sessions.get(sessionId);
|
|
3829
3878
|
if (!state || state.finished) return;
|
|
3830
|
-
const clamp = (value: string | null) => (value ?
|
|
3831
|
-
const cleaned:
|
|
3879
|
+
const clamp = (value: string | null) => (value ? clampSyncText(value, MAX_ENVIRONMENT_FIELD_CHARS) : null);
|
|
3880
|
+
const cleaned: SyncSessionModel = {
|
|
3832
3881
|
provider_id: clamp(model.provider_id),
|
|
3833
3882
|
model_id: clamp(model.model_id),
|
|
3834
3883
|
variant: clamp(model.variant),
|
|
@@ -3860,7 +3909,7 @@ export class WorkspaceCollector {
|
|
|
3860
3909
|
* once the root turn settled: one "session.child" event carrying the child's
|
|
3861
3910
|
* new messages, plus the child's checkpoint for the next capture.
|
|
3862
3911
|
*/
|
|
3863
|
-
recordChildSession(sessionId: string, child:
|
|
3912
|
+
recordChildSession(sessionId: string, child: SyncChildSession): void {
|
|
3864
3913
|
const state = this.sessions.get(sessionId);
|
|
3865
3914
|
if (!state || state.finished) return;
|
|
3866
3915
|
if (!/^[A-Za-z0-9._:-]{1,256}$/.test(child.childSessionId)) return;
|
|
@@ -3873,9 +3922,9 @@ export class WorkspaceCollector {
|
|
|
3873
3922
|
this.appendTrace(state, "session.child", {
|
|
3874
3923
|
child_session_id: child.childSessionId,
|
|
3875
3924
|
parent_session_id: child.parentSessionId,
|
|
3876
|
-
...(Number.isSafeInteger(child.depth) && child.depth! >= 1 && child.depth! <=
|
|
3877
|
-
title: child.title ?
|
|
3878
|
-
agent: child.agent ?
|
|
3925
|
+
...(Number.isSafeInteger(child.depth) && child.depth! >= 1 && child.depth! <= MAX_SYNC_CHILD_SESSION_DEPTH ? { depth: child.depth } : {}),
|
|
3926
|
+
title: child.title ? clampSyncText(child.title, MAX_GIT_SUBJECT_CHARS) : null,
|
|
3927
|
+
agent: child.agent ? clampSyncText(child.agent, MAX_ENVIRONMENT_FIELD_CHARS) : null,
|
|
3879
3928
|
messages: withSubagentFallbacks(child.messages, state.subagentFallbacks.get(child.childSessionId)),
|
|
3880
3929
|
});
|
|
3881
3930
|
void this.persistOnceReady(state);
|
|
@@ -3886,18 +3935,18 @@ export class WorkspaceCollector {
|
|
|
3886
3935
|
* browser-internal URLs are never traced; page text is redacted and capped.
|
|
3887
3936
|
* Returns whether the visit was recorded.
|
|
3888
3937
|
*/
|
|
3889
|
-
recordWebVisit(sessionId: string, visit:
|
|
3938
|
+
recordWebVisit(sessionId: string, visit: SyncWebVisit): boolean {
|
|
3890
3939
|
const state = this.sessions.get(sessionId);
|
|
3891
3940
|
if (!state || state.finished) return false;
|
|
3892
|
-
if (typeof visit.url !== "string" || !
|
|
3941
|
+
if (typeof visit.url !== "string" || !isSyncableWebUrl(visit.url)) return false;
|
|
3893
3942
|
const url = new URL(visit.url);
|
|
3894
3943
|
url.username = "";
|
|
3895
3944
|
url.password = "";
|
|
3896
|
-
const title =
|
|
3897
|
-
const text =
|
|
3945
|
+
const title = syncTextForTrace(visit.title ?? null, MAX_WEB_VISIT_TITLE_CHARS * 4);
|
|
3946
|
+
const text = syncTextForTrace(visit.text ?? null, MAX_SYNC_WEB_VISIT_TEXT_BYTES);
|
|
3898
3947
|
this.appendTrace(state, "web.visit", {
|
|
3899
|
-
url:
|
|
3900
|
-
title: title.text === null ? null :
|
|
3948
|
+
url: clampSyncText(redactSyncText(url.toString()).text, MAX_WEB_VISIT_URL_CHARS),
|
|
3949
|
+
title: title.text === null ? null : clampSyncText(title.text, MAX_WEB_VISIT_TITLE_CHARS),
|
|
3901
3950
|
text: text.text,
|
|
3902
3951
|
text_truncated: text.truncated,
|
|
3903
3952
|
});
|
|
@@ -3905,13 +3954,13 @@ export class WorkspaceCollector {
|
|
|
3905
3954
|
}
|
|
3906
3955
|
|
|
3907
3956
|
/** Records a file attached to a prompt: identity, size, and redacted, capped text when extractable. */
|
|
3908
|
-
recordAttachment(sessionId: string, attachment:
|
|
3957
|
+
recordAttachment(sessionId: string, attachment: SyncAttachment): void {
|
|
3909
3958
|
const state = this.sessions.get(sessionId);
|
|
3910
3959
|
if (!state || state.finished) return;
|
|
3911
|
-
const text =
|
|
3960
|
+
const text = syncTextForTrace(attachment.text, MAX_SYNC_ATTACHMENT_TEXT_BYTES);
|
|
3912
3961
|
this.appendTrace(state, "attachment", {
|
|
3913
|
-
name:
|
|
3914
|
-
mime:
|
|
3962
|
+
name: clampSyncText(redactSyncText(basename(attachment.name || "attachment")).text, MAX_ATTACHMENT_NAME_CHARS),
|
|
3963
|
+
mime: clampSyncText(attachment.mime || "application/octet-stream", MAX_ATTACHMENT_MIME_CHARS),
|
|
3915
3964
|
bytes: Math.max(0, Math.floor(attachment.bytes)),
|
|
3916
3965
|
sha256: attachment.sha256,
|
|
3917
3966
|
text: text.text,
|
|
@@ -3931,7 +3980,7 @@ export class WorkspaceCollector {
|
|
|
3931
3980
|
}
|
|
3932
3981
|
}
|
|
3933
3982
|
|
|
3934
|
-
private sessionBlock(state: SessionState):
|
|
3983
|
+
private sessionBlock(state: SessionState): SyncSessionBlock {
|
|
3935
3984
|
return {
|
|
3936
3985
|
provider_id: state.model?.provider_id ?? null,
|
|
3937
3986
|
model_id: state.model?.model_id ?? null,
|
|
@@ -3963,7 +4012,7 @@ export class WorkspaceCollector {
|
|
|
3963
4012
|
if (emitted >= MAX_ARTIFACT_EVENTS_PER_TURN || stat.size > MAX_ARTIFACT_HASH_BYTES) continue;
|
|
3964
4013
|
try {
|
|
3965
4014
|
const sha256 = await sha256File(resolve(state.root, path));
|
|
3966
|
-
this.appendTrace(state, "artifact", { path:
|
|
4015
|
+
this.appendTrace(state, "artifact", { path: syncPathForUpload(path), sha256, bytes: stat.size });
|
|
3967
4016
|
state.touchedPaths.add(path);
|
|
3968
4017
|
emitted += 1;
|
|
3969
4018
|
} catch {
|
|
@@ -4063,14 +4112,14 @@ export class WorkspaceCollector {
|
|
|
4063
4112
|
private async turnDiffInput(state: SessionState, change: TurnChange, pool: ReadPool, ancestors: AncestorCache, texts: boolean): Promise<TurnDiffInput | null> {
|
|
4064
4113
|
const { path, before, after, unseen } = change;
|
|
4065
4114
|
const input = {
|
|
4066
|
-
path: (after ?? before)?.uploadPath ??
|
|
4115
|
+
path: (after ?? before)?.uploadPath ?? syncPathForUpload(path),
|
|
4067
4116
|
before_sha256: before?.sha256 ?? null,
|
|
4068
4117
|
after_sha256: after?.sha256 ?? null,
|
|
4069
4118
|
before: null,
|
|
4070
4119
|
after: null,
|
|
4071
4120
|
};
|
|
4072
4121
|
if (after) {
|
|
4073
|
-
// Changed by the turn from a form the
|
|
4122
|
+
// Changed by the turn from a form the session uploader never saw: nothing to diff against.
|
|
4074
4123
|
if (unseen) return { ...input, status: "no_base" };
|
|
4075
4124
|
if (before && !(await this.bases.has(before.sha256))) return { ...input, status: "no_base" };
|
|
4076
4125
|
if (!texts) return { ...input, status: before ? "modified" : "added" };
|
|
@@ -4091,7 +4140,7 @@ export class WorkspaceCollector {
|
|
|
4091
4140
|
return base === null ? { ...input, status: "no_base" } : { ...input, status: "deleted", before: base };
|
|
4092
4141
|
}
|
|
4093
4142
|
if (!file.isFile() || file.isSymbolicLink()) return null;
|
|
4094
|
-
if (file.size <=
|
|
4143
|
+
if (file.size <= MAX_SYNC_FILE_BYTES && !state.cache.get(path)?.binary && !(await startsBinary(absolute))) return null;
|
|
4095
4144
|
return { ...input, after_sha256: null, status: "skipped" };
|
|
4096
4145
|
}
|
|
4097
4146
|
|
|
@@ -4339,7 +4388,7 @@ export class WorkspaceCollector {
|
|
|
4339
4388
|
private queueChangedPath(state: SessionState, filename: string): void {
|
|
4340
4389
|
if (state.finished) return;
|
|
4341
4390
|
const path = workspaceRelativePath(state.root, filename);
|
|
4342
|
-
if (!path ||
|
|
4391
|
+
if (!path || isSyncPathDenied(path)) return;
|
|
4343
4392
|
// Several events for one path before its capture starts collapse into one read.
|
|
4344
4393
|
if (state.pendingJournal.has(path)) return;
|
|
4345
4394
|
state.pendingJournal.add(path);
|
|
@@ -4378,7 +4427,7 @@ export class WorkspaceCollector {
|
|
|
4378
4427
|
const file = await lstat(absolute);
|
|
4379
4428
|
const written = writtenThisTurn(state.turnStartedAt, state.cache.get(path), file);
|
|
4380
4429
|
if (file.isFile() && !file.isSymbolicLink() && file.size > MAX_CHANGE_JOURNAL_BYTES && written) state.turnSkipped.set(path, file.mtimeMs);
|
|
4381
|
-
if (!file.isFile() || file.isSymbolicLink() || file.size >
|
|
4430
|
+
if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_SYNC_FILE_BYTES) {
|
|
4382
4431
|
entry = { path, at: new Date().toISOString(), status: "skipped" };
|
|
4383
4432
|
} else if (file.size > MAX_CHANGE_JOURNAL_BYTES) {
|
|
4384
4433
|
// Larger than the whole journal: it could only evict every other entry
|
|
@@ -4388,7 +4437,7 @@ export class WorkspaceCollector {
|
|
|
4388
4437
|
} else {
|
|
4389
4438
|
const buffer = await readFile(absolute);
|
|
4390
4439
|
this.metrics.fileReads += 1;
|
|
4391
|
-
if (buffer.length >
|
|
4440
|
+
if (buffer.length > MAX_SYNC_FILE_BYTES || isBinary(buffer)) {
|
|
4392
4441
|
if (written) state.turnSkipped.set(path, file.mtimeMs);
|
|
4393
4442
|
entry = { path, at: new Date().toISOString(), status: "skipped" };
|
|
4394
4443
|
} else {
|
|
@@ -4426,7 +4475,7 @@ export class WorkspaceCollector {
|
|
|
4426
4475
|
|
|
4427
4476
|
private enqueue(state: SessionState, operation: () => Promise<void>): void {
|
|
4428
4477
|
state.tail = state.tail.then(operation).catch((error: unknown) => {
|
|
4429
|
-
this.log("warn", "OmniRush
|
|
4478
|
+
this.log("warn", "OmniRush session upload: operation failed", {
|
|
4430
4479
|
sessionId: state.id,
|
|
4431
4480
|
error: error instanceof Error ? error.message : "unknown",
|
|
4432
4481
|
});
|
|
@@ -4434,7 +4483,7 @@ export class WorkspaceCollector {
|
|
|
4434
4483
|
}
|
|
4435
4484
|
|
|
4436
4485
|
private touchedPathsForUpload(state: SessionState): string[] {
|
|
4437
|
-
return [...state.touchedPaths].map(
|
|
4486
|
+
return [...state.touchedPaths].map(syncPathForUpload);
|
|
4438
4487
|
}
|
|
4439
4488
|
|
|
4440
4489
|
/**
|
|
@@ -4448,7 +4497,7 @@ export class WorkspaceCollector {
|
|
|
4448
4497
|
* and covers the whole tree otherwise. Resolves with whether a snapshot went
|
|
4449
4498
|
* out.
|
|
4450
4499
|
*/
|
|
4451
|
-
private async uploadWorkspace(state: SessionState, type: Exclude<SnapshotType, "trace">, trigger:
|
|
4500
|
+
private async uploadWorkspace(state: SessionState, type: Exclude<SnapshotType, "trace">, trigger: SyncTrigger): Promise<boolean> {
|
|
4452
4501
|
const cache = state.cache;
|
|
4453
4502
|
// Paths reported from here on belong to the next capture.
|
|
4454
4503
|
const dirty = state.dirty;
|
|
@@ -4462,7 +4511,7 @@ export class WorkspaceCollector {
|
|
|
4462
4511
|
// listing vouches for (the paths a targeted scan inspects, and every
|
|
4463
4512
|
// journal entry), in one batch: nothing git ignores now leaves the
|
|
4464
4513
|
// machine, whatever it answered when the event arrived.
|
|
4465
|
-
const reported = targeted && previous !== null ? [...dirty].filter((path) => !
|
|
4514
|
+
const reported = targeted && previous !== null ? [...dirty].filter((path) => !isSyncPathDenied(path)) : [];
|
|
4466
4515
|
const journaled = type === "change" || type === "end" ? [...state.changeJournal.values()] : [];
|
|
4467
4516
|
const checked = [...new Set([...reported, ...journaled.map((entry) => entry.path)])];
|
|
4468
4517
|
const ignored = checked.length > 0 ? await this.ignoredPaths(state, checked, true) : new Set<string>();
|
|
@@ -4482,20 +4531,20 @@ export class WorkspaceCollector {
|
|
|
4482
4531
|
if (previous !== null) for (const path of scan.changed ?? []) this.reportTouched(state, path);
|
|
4483
4532
|
// A change capture that found nothing moved stops at one `git rev-parse`
|
|
4484
4533
|
// (a commit changes history without touching a file); the full git block
|
|
4485
|
-
// with its status, log and diff is
|
|
4534
|
+
// with its status, log and diff is read only for a snapshot that goes out.
|
|
4486
4535
|
if (type === "change" && scan.changed !== null && scan.changed.size === 0 && scan.removed === 0 && !this.journalHasChanges(state)
|
|
4487
4536
|
&& (await gitHead(state.root)) === state.lastHead) {
|
|
4488
4537
|
accepted = true;
|
|
4489
4538
|
return false;
|
|
4490
4539
|
}
|
|
4491
4540
|
// Its status and diff name the files a capped snapshot must keep.
|
|
4492
|
-
const git = await
|
|
4541
|
+
const git = await readGitBlock(state.root);
|
|
4493
4542
|
const candidates = candidatesFor(scan, snapshotPriorityPaths(state.touchedPaths, git));
|
|
4494
4543
|
const environment = await this.environment();
|
|
4495
4544
|
const rootName = workspaceRootName(state.root);
|
|
4496
4545
|
const touchedPaths = this.touchedPathsForUpload(state);
|
|
4497
4546
|
const metadata = JSON.stringify({
|
|
4498
|
-
schema_version:
|
|
4547
|
+
schema_version: SYNC_SCHEMA_VERSION,
|
|
4499
4548
|
workspace_id: state.workspaceId,
|
|
4500
4549
|
session_id: state.id,
|
|
4501
4550
|
session_segment: state.segment,
|
|
@@ -4507,12 +4556,12 @@ export class WorkspaceCollector {
|
|
|
4507
4556
|
root_name: rootName,
|
|
4508
4557
|
git: { commit: git?.commit ?? null, branch: git?.branch ?? null, dirty: git ? String(git.dirty) : "false" },
|
|
4509
4558
|
});
|
|
4510
|
-
const leading:
|
|
4559
|
+
const leading: SyncFile[] = [{ path: "__omnirush__/workspace.json", content: metadata, sha256: sha256Hex(metadata) }];
|
|
4511
4560
|
if (journal.length > 0) {
|
|
4512
4561
|
const changes = JSON.stringify({
|
|
4513
4562
|
schema_version: 1,
|
|
4514
4563
|
session_id: state.id,
|
|
4515
|
-
entries: journal.map((entry) => ({ ...entry, path:
|
|
4564
|
+
entries: journal.map((entry) => ({ ...entry, path: syncPathForUpload(entry.path) })),
|
|
4516
4565
|
});
|
|
4517
4566
|
leading.push({ path: "__omnirush__/changes.json", content: changes, sha256: sha256Hex(changes) });
|
|
4518
4567
|
}
|
|
@@ -4587,7 +4636,7 @@ export class WorkspaceCollector {
|
|
|
4587
4636
|
omitted_bytes: omittedBytes,
|
|
4588
4637
|
budget_bytes: budget,
|
|
4589
4638
|
});
|
|
4590
|
-
this.log("warn", "OmniRush
|
|
4639
|
+
this.log("warn", "OmniRush session upload: snapshot trimmed to the snapshot cap", {
|
|
4591
4640
|
sessionId: state.id,
|
|
4592
4641
|
snapshotType: type,
|
|
4593
4642
|
trigger,
|
|
@@ -4637,7 +4686,7 @@ export class WorkspaceCollector {
|
|
|
4637
4686
|
|
|
4638
4687
|
/**
|
|
4639
4688
|
* One POST of an envelope, ended by `deadline` (the attempt's size-scaled
|
|
4640
|
-
* deadline,
|
|
4689
|
+
* deadline, sync-upload-budget.ts: fetch reports no upload progress, so
|
|
4641
4690
|
* sending the body and the gateway's answer share one deadline) or sooner
|
|
4642
4691
|
* by `cancel`.
|
|
4643
4692
|
*/
|
|
@@ -4645,7 +4694,7 @@ export class WorkspaceCollector {
|
|
|
4645
4694
|
const signal = cancel ? AbortSignal.any([deadline, cancel]) : deadline;
|
|
4646
4695
|
if (signal.aborted) return Promise.reject(signal.reason);
|
|
4647
4696
|
if (this.uploader) return untilAborted(this.uploader(sessionId, compressed, signal), signal);
|
|
4648
|
-
return this.fetcher(this.
|
|
4697
|
+
return this.fetcher(this.syncUrl!, {
|
|
4649
4698
|
method: "POST",
|
|
4650
4699
|
headers: {
|
|
4651
4700
|
Authorization: `Bearer ${this.token}`,
|
|
@@ -4679,8 +4728,8 @@ export class WorkspaceCollector {
|
|
|
4679
4728
|
* the spool at once and the spool drain retries it on its own backoff.
|
|
4680
4729
|
*/
|
|
4681
4730
|
private async transmit(sessionId: string, compressed: Uint8Array, attempts = UPLOAD_ATTEMPTS, cancel?: AbortSignal): Promise<TransmitOutcome> {
|
|
4682
|
-
const timeoutMs =
|
|
4683
|
-
let lastReason = "
|
|
4731
|
+
const timeoutMs = syncUploadTimeoutMs(compressed.byteLength, this.uploadBudget);
|
|
4732
|
+
let lastReason = "session upload unavailable";
|
|
4684
4733
|
let refreshAttempted = false;
|
|
4685
4734
|
let attempt = 0;
|
|
4686
4735
|
while (attempt < attempts) {
|
|
@@ -4689,7 +4738,7 @@ export class WorkspaceCollector {
|
|
|
4689
4738
|
try {
|
|
4690
4739
|
const response = await this.send(sessionId, compressed, deadline, cancel);
|
|
4691
4740
|
if (response.ok) return { ok: true };
|
|
4692
|
-
lastReason = `
|
|
4741
|
+
lastReason = `session upload failed with status ${response.status}`;
|
|
4693
4742
|
if (UNAUTHORIZED_STATUSES.has(response.status)) {
|
|
4694
4743
|
// The sign-in gate is final: nothing is queued for an account that
|
|
4695
4744
|
// is gone. Any other rejection is a device token the gateway broker
|
|
@@ -4701,7 +4750,7 @@ export class WorkspaceCollector {
|
|
|
4701
4750
|
if (refreshAttempted) return { ok: false, retryable: true, reason: lastReason };
|
|
4702
4751
|
refreshAttempted = true;
|
|
4703
4752
|
const refresh = await this.refreshToken();
|
|
4704
|
-
this.log("warn", "OmniRush
|
|
4753
|
+
this.log("warn", "OmniRush session upload: upload rejected as unauthorized", {
|
|
4705
4754
|
sessionId,
|
|
4706
4755
|
status: response.status,
|
|
4707
4756
|
refreshed: refresh.refreshed,
|
|
@@ -4714,7 +4763,7 @@ export class WorkspaceCollector {
|
|
|
4714
4763
|
await response.body?.cancel().catch(() => undefined);
|
|
4715
4764
|
if (!RETRYABLE_STATUSES.has(response.status)) return { ok: false, retryable: false, reason: lastReason };
|
|
4716
4765
|
} catch (error) {
|
|
4717
|
-
lastReason = error instanceof Error ? error.message : "
|
|
4766
|
+
lastReason = error instanceof Error ? error.message : "session upload unavailable";
|
|
4718
4767
|
if (cancel?.aborted) return { ok: false, retryable: true, reason: lastReason };
|
|
4719
4768
|
// Out of time: this attempt's deadline, or the gateway broker's own
|
|
4720
4769
|
// (the same size-scaled budget, reported across the worker boundary by name).
|
|
@@ -4761,8 +4810,8 @@ export class WorkspaceCollector {
|
|
|
4761
4810
|
* retryable failure moves the file into the spool instead. Resolves with
|
|
4762
4811
|
* whether the envelope was accepted (delivered or spooled).
|
|
4763
4812
|
*/
|
|
4764
|
-
private async uploadEnvelope(state: SessionState, snapshotType: SnapshotType, trigger:
|
|
4765
|
-
if (!this.
|
|
4813
|
+
private async uploadEnvelope(state: SessionState, snapshotType: SnapshotType, trigger: SyncTrigger, body: EnvelopeBody): Promise<boolean> {
|
|
4814
|
+
if (!this.syncUrl && !this.uploader) return false;
|
|
4766
4815
|
const sequence = state.sequence + 1;
|
|
4767
4816
|
const path = await this.envelopeTempPath();
|
|
4768
4817
|
const writer = new EnvelopeWriter(path);
|
|
@@ -4770,7 +4819,7 @@ export class WorkspaceCollector {
|
|
|
4770
4819
|
let compressedBytes: number;
|
|
4771
4820
|
try {
|
|
4772
4821
|
const head = JSON.stringify({
|
|
4773
|
-
schema_version:
|
|
4822
|
+
schema_version: SYNC_SCHEMA_VERSION,
|
|
4774
4823
|
session_id: state.id,
|
|
4775
4824
|
session_segment: state.segment,
|
|
4776
4825
|
session_resumed: state.resumed,
|
|
@@ -4819,7 +4868,7 @@ export class WorkspaceCollector {
|
|
|
4819
4868
|
// retried. The scan budget keeps this from happening; reaching it means
|
|
4820
4869
|
// the wrapper outgrew its margin, which deserves a loud line here rather
|
|
4821
4870
|
// than a lost upload and a rejection upstream.
|
|
4822
|
-
this.log("warn", "OmniRush
|
|
4871
|
+
this.log("warn", "OmniRush session upload: payload exceeded the snapshot cap", {
|
|
4823
4872
|
sessionId: state.id,
|
|
4824
4873
|
snapshotType,
|
|
4825
4874
|
trigger,
|
|
@@ -4829,7 +4878,7 @@ export class WorkspaceCollector {
|
|
|
4829
4878
|
return false;
|
|
4830
4879
|
}
|
|
4831
4880
|
if (compressedBytes > MAX_COMPRESSED_BYTES) {
|
|
4832
|
-
this.log("warn", "OmniRush
|
|
4881
|
+
this.log("warn", "OmniRush session upload: payload exceeded compressed limit", { sessionId: state.id, snapshotType, trigger });
|
|
4833
4882
|
return false;
|
|
4834
4883
|
}
|
|
4835
4884
|
const compressed = await readFile(path);
|
|
@@ -4843,7 +4892,7 @@ export class WorkspaceCollector {
|
|
|
4843
4892
|
const spooled = await this.spoolEnvelopeFile({ id: "", session_id: state.id, snapshot_type: snapshotType, trigger, sequence, bytes: compressedBytes, created_at: new Date().toISOString(), attempts: 1 }, path, account);
|
|
4844
4893
|
if (!spooled) {
|
|
4845
4894
|
// Signed out during the upload: the envelope is deleted, never queued for the next account.
|
|
4846
|
-
this.log("info", "OmniRush
|
|
4895
|
+
this.log("info", "OmniRush session upload: artifact discarded at sign-out", { sessionId: state.id, snapshotType, trigger, sequence });
|
|
4847
4896
|
return false;
|
|
4848
4897
|
}
|
|
4849
4898
|
state.sentBytes += bytes;
|
|
@@ -4851,7 +4900,7 @@ export class WorkspaceCollector {
|
|
|
4851
4900
|
state.failureCount += 1;
|
|
4852
4901
|
state.lastFailureAt = new Date().toISOString();
|
|
4853
4902
|
await this.persistSession(state).catch(() => undefined);
|
|
4854
|
-
this.log("warn", "OmniRush
|
|
4903
|
+
this.log("warn", "OmniRush session upload: artifact spooled for retry", {
|
|
4855
4904
|
sessionId: state.id,
|
|
4856
4905
|
snapshotType,
|
|
4857
4906
|
trigger,
|
|
@@ -4872,7 +4921,7 @@ export class WorkspaceCollector {
|
|
|
4872
4921
|
error: error instanceof Error ? error.message : "unknown",
|
|
4873
4922
|
});
|
|
4874
4923
|
});
|
|
4875
|
-
this.log("info", "OmniRush
|
|
4924
|
+
this.log("info", "OmniRush session upload: artifact uploaded", {
|
|
4876
4925
|
sessionId: state.id,
|
|
4877
4926
|
snapshotType,
|
|
4878
4927
|
trigger,
|
|
@@ -4965,7 +5014,7 @@ export class WorkspaceCollector {
|
|
|
4965
5014
|
await this.removeSpoolEntry(entry.id);
|
|
4966
5015
|
total -= entry.bytes;
|
|
4967
5016
|
count -= 1;
|
|
4968
|
-
this.log("warn", "OmniRush
|
|
5017
|
+
this.log("warn", "OmniRush session upload: spool dropped its oldest entry", {
|
|
4969
5018
|
sessionId: entry.session_id,
|
|
4970
5019
|
snapshotType: entry.snapshot_type,
|
|
4971
5020
|
sequence: entry.sequence,
|
|
@@ -5065,7 +5114,7 @@ export class WorkspaceCollector {
|
|
|
5065
5114
|
this.retryFailures = 0;
|
|
5066
5115
|
delivered += 1;
|
|
5067
5116
|
pending -= 1;
|
|
5068
|
-
this.log("info", "OmniRush
|
|
5117
|
+
this.log("info", "OmniRush session upload: artifact delivered from spool", {
|
|
5069
5118
|
sessionId: entry.session_id,
|
|
5070
5119
|
snapshotType: entry.snapshot_type,
|
|
5071
5120
|
trigger: entry.trigger,
|
|
@@ -5079,7 +5128,7 @@ export class WorkspaceCollector {
|
|
|
5079
5128
|
if (!outcome.retryable || entry.attempts + 1 >= MAX_SPOOL_ATTEMPTS) {
|
|
5080
5129
|
await this.spoolLocked(() => this.removeSpoolEntry(entry.id));
|
|
5081
5130
|
pending -= 1;
|
|
5082
|
-
this.log("warn", "OmniRush
|
|
5131
|
+
this.log("warn", "OmniRush session upload: artifact dropped from spool", {
|
|
5083
5132
|
sessionId: entry.session_id,
|
|
5084
5133
|
snapshotType: entry.snapshot_type,
|
|
5085
5134
|
sequence: entry.sequence,
|