omnirush 0.4.1 → 0.4.2

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.
@@ -3,9 +3,15 @@
3
3
  // sessions via the omnirush pi extension).
4
4
  //
5
5
  // Builds the standard collect envelope (schema_version 1, snapshot_type
6
- // "start") for a project directory and POSTs it zstd-compressed to
7
- // {origin}/collect with the device access token. Requires the bundled
8
- // bun (zstd) or Node >= 22.15.
6
+ // "start") for a project directory and POSTs each zstd-compressed part
7
+ // to {origin}/collect with the device access token. Requires the
8
+ // bundled bun (zstd) or Node >= 22.15.
9
+ //
10
+ // Memory (issue #6): files are read and packed one part at a time and
11
+ // each part is discarded right after its upload — nothing workspace-
12
+ // sized is ever held in full. The pre-flight summary walks sizes only
13
+ // (lstat), never content. Deterministic order: the walker yields paths
14
+ // sorted, each path's entries (manifest + slices) grouped.
9
15
  //
10
16
  // Usage: omnirush collect [--dir PATH] [--yes] [--dry-run]
11
17
 
@@ -13,10 +19,10 @@ import { randomBytes } from "node:crypto";
13
19
  import path from "node:path";
14
20
  import readline from "node:readline";
15
21
 
16
- import { omniDir, resolveOrigin, gatewayUrlForOrigin } from "./extensions/omnirush/auth";
22
+ import { resolveOrigin, gatewayUrlForOrigin } from "./extensions/omnirush/auth";
17
23
  import {
18
- buildEnvelopeParts,
19
- collectFiles,
24
+ iterateCollectFiles,
25
+ iterateEnvelopeParts,
20
26
  MAX_PART_COMPRESSED_BYTES,
21
27
  MAX_PART_COMPRESSED_HARD,
22
28
  MAX_SESSION_BYTES,
@@ -50,49 +56,36 @@ async function confirm(question: string): Promise<boolean> {
50
56
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
51
57
  }
52
58
 
59
+ /** Cheap workspace summary: path count + on-disk bytes (no content). */
60
+ async function summarize(dir: string): Promise<{ files: number; bytes: number }> {
61
+ const { lstat } = await import("node:fs/promises");
62
+ const { listWorkspaceFiles } = await import("./extensions/omnirush/collector-lib");
63
+ const paths = await listWorkspaceFiles(dir);
64
+ let bytes = 0;
65
+ for (const relativePath of paths) {
66
+ try {
67
+ bytes += (await lstat(path.join(dir, relativePath))).size;
68
+ } catch {
69
+ /* raced away — the streaming pass re-checks */
70
+ }
71
+ }
72
+ return { files: paths.length, bytes };
73
+ }
74
+
53
75
  async function main() {
54
76
  const { dir, yes, dryRun } = parseArgs(process.argv.slice(2));
55
77
  const sessionId = `manual-${randomBytes(6).toString("hex")}`;
56
78
  const workspaceId = path.basename(dir) || "workspace";
57
79
 
58
- const files = await collectFiles(dir, MAX_SESSION_BYTES, workspaceId, {
59
- id: sessionId,
60
- segment: 1,
61
- resumed: false,
62
- });
63
- const bytes = files.reduce((total, file) => total + Buffer.byteLength(file.content), 0);
64
- if (files.length <= 1) {
80
+ const summary = await summarize(dir);
81
+ if (summary.files === 0) {
65
82
  console.error("omnirush collect: nothing to upload (empty or fully filtered workspace)");
66
83
  process.exit(1);
67
84
  }
68
-
69
- const state = { id: sessionId, segment: 1, resumed: false };
70
- const parts = await buildEnvelopeParts(
71
- state,
72
- "start",
73
- files,
74
- MAX_PART_COMPRESSED_BYTES,
75
- (message, attributes) => console.error(`omnirush collect: ${message} (${JSON.stringify(attributes ?? {})})`),
76
- );
77
- const plainBytes = parts.reduce((total, part) => total + part.payload.length, 0);
78
- const compressedBytes = parts.reduce((total, part) => total + part.compressed.length, 0);
79
- console.log(
80
- `collect: ${files.length - 1} files (${bytes} bytes, denylist applied) as session ${sessionId}`,
81
- );
82
85
  console.log(
83
- `envelope: ${parts.length} part(s), ${plainBytes} bytes plain, ${compressedBytes} bytes zstd ` +
84
- `(target <= ${MAX_PART_COMPRESSED_BYTES}, rail ${MAX_PART_COMPRESSED_HARD})`,
86
+ `collect: ${summary.files} files (${summary.bytes} bytes on disk, denylist applied) as session ${sessionId}`,
85
87
  );
86
88
 
87
- if (dryRun) {
88
- console.log("dry run — no upload performed");
89
- return;
90
- }
91
- if (!yes && !(await confirm(`Upload ${files.length - 1} files from ${dir} to Omnirush? [y/N] `))) {
92
- console.log("aborted");
93
- process.exit(1);
94
- }
95
-
96
89
  const origin = resolveOrigin(process.env);
97
90
  const collectUrl = gatewayUrlForOrigin(origin).replace(/\/v1$/, "/collect");
98
91
  const accessToken = (
@@ -104,7 +97,34 @@ async function main() {
104
97
  console.error("omnirush collect: no access token (run `omnirush login`)");
105
98
  process.exit(1);
106
99
  }
107
- for (const part of parts) {
100
+
101
+ const state = { id: sessionId, segment: 1, resumed: false };
102
+ // Pass 2 streams content: file -> batch -> part -> upload, with only
103
+ // one part ever resident. `part` is dropped at each loop iteration.
104
+ const parts = iterateEnvelopeParts(
105
+ state,
106
+ "start",
107
+ iterateCollectFiles(dir, MAX_SESSION_BYTES, workspaceId, {
108
+ id: sessionId,
109
+ segment: 1,
110
+ resumed: false,
111
+ }),
112
+ MAX_PART_COMPRESSED_BYTES,
113
+ (message, attributes) => console.error(`omnirush collect: ${message} (${JSON.stringify(attributes ?? {})})`),
114
+ );
115
+
116
+ let partCount = 0;
117
+ let plainBytes = 0;
118
+ let compressedBytes = 0;
119
+ let uploaded = false;
120
+ for await (const part of parts) {
121
+ partCount += 1;
122
+ plainBytes += part.payload.length;
123
+ compressedBytes += part.compressed.length;
124
+ if (dryRun) {
125
+ console.log(`part ${partCount}: ${part.compressed.length} bytes zstd (discarded — dry run)`);
126
+ continue;
127
+ }
108
128
  const response = await fetch(collectUrl, {
109
129
  method: "POST",
110
130
  headers: {
@@ -123,6 +143,7 @@ async function main() {
123
143
  console.error(`upload failed: HTTP ${response.status}`);
124
144
  process.exit(1);
125
145
  }
146
+ uploaded = true;
126
147
  const extra = [...response.headers]
127
148
  .filter(([name]) => name.toLowerCase().startsWith("x-omnirush-"))
128
149
  .map(([name, value]) => `${name}: ${value}`);
@@ -132,6 +153,23 @@ async function main() {
132
153
  `${extra.length ? "\n " + extra.join("\n ") : ""}`,
133
154
  );
134
155
  }
156
+
157
+ if (partCount === 0) {
158
+ console.error("omnirush collect: nothing to upload (empty or fully filtered workspace)");
159
+ process.exit(1);
160
+ }
161
+ console.log(
162
+ `envelope: ${partCount} part(s), ${plainBytes} bytes plain, ${compressedBytes} bytes zstd ` +
163
+ `(target <= ${MAX_PART_COMPRESSED_BYTES}, rail ${MAX_PART_COMPRESSED_HARD})`,
164
+ );
165
+ if (dryRun) {
166
+ console.log("dry run — no upload performed");
167
+ return;
168
+ }
169
+ if (!uploaded) {
170
+ console.error("omnirush collect: nothing uploaded");
171
+ process.exit(1);
172
+ }
135
173
  }
136
174
 
137
175
  main().catch((error) => {
@@ -29,6 +29,12 @@ import fs from "node:fs";
29
29
  import os from "node:os";
30
30
  import path from "node:path";
31
31
 
32
+ import {
33
+ isRetryableStatus,
34
+ retryAttempts,
35
+ withRetries,
36
+ } from "./retry.js";
37
+
32
38
  export const DEFAULT_ORIGIN = "https://omnirush.ai/omnirush";
33
39
  export const DEFAULT_POLL_INTERVAL_SEC = 5;
34
40
  export const MAX_POLL_INTERVAL_SEC = 30;
@@ -171,19 +177,36 @@ function detailOf(payload, fallback) {
171
177
  }
172
178
 
173
179
  async function postJson(url, body, fetchImpl, timeoutMs = 20_000) {
174
- const response = await fetchImpl(url, {
175
- method: "POST",
176
- headers: { "Content-Type": "application/json" },
177
- body: JSON.stringify(body),
178
- signal: AbortSignal.timeout(timeoutMs),
179
- });
180
- let payload = null;
181
- try {
182
- payload = await response.json();
183
- } catch {
184
- /* non-JSON body — fall through with payload null */
185
- }
186
- return { response, payload };
180
+ // Shared retry layer: 429/5xx/network failures ride out backend
181
+ // deploys. 4xx are protocol answers (428 pending, 401 rejected) —
182
+ // never retried here; callers interpret them.
183
+ const outcome = await withRetries(
184
+ async () => {
185
+ const response = await fetchImpl(url, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify(body),
189
+ signal: AbortSignal.timeout(timeoutMs),
190
+ });
191
+ let payload = null;
192
+ try {
193
+ payload = await response.json();
194
+ } catch {
195
+ /* non-JSON body — fall through with payload null */
196
+ }
197
+ return {
198
+ response,
199
+ payload,
200
+ retryAfterSec: Number(response?.headers?.get?.("retry-after")) || undefined,
201
+ };
202
+ },
203
+ {
204
+ attempts: retryAttempts(),
205
+ isRetryable: ({ response }) => isRetryableStatus(response?.status),
206
+ },
207
+ );
208
+ if (outcome.error) throw outcome.error;
209
+ return outcome.result;
187
210
  }
188
211
 
189
212
  /** Step 1 of the device flow: create a pending authorization. */
@@ -305,10 +328,26 @@ export async function refreshTokens(origin, { refreshToken, fetchImpl } = {}) {
305
328
 
306
329
  export async function deviceMe(origin, { accessToken, fetchImpl } = {}) {
307
330
  const doFetch = fetchImpl ?? globalThis.fetch;
308
- const response = await doFetch(`${origin}/device/me`, {
309
- headers: { Authorization: `Bearer ${accessToken}` },
310
- signal: AbortSignal.timeout(15_000),
311
- });
331
+ // 401 is a protocol answer (stale access token — the caller refreshes
332
+ // and retries once); 429/5xx/network retry with backoff.
333
+ const outcome = await withRetries(
334
+ async () => {
335
+ const response = await doFetch(`${origin}/device/me`, {
336
+ headers: { Authorization: `Bearer ${accessToken}` },
337
+ signal: AbortSignal.timeout(15_000),
338
+ });
339
+ return {
340
+ response,
341
+ retryAfterSec: Number(response?.headers?.get?.("retry-after")) || undefined,
342
+ };
343
+ },
344
+ {
345
+ attempts: retryAttempts(),
346
+ isRetryable: ({ response }) => isRetryableStatus(response?.status),
347
+ },
348
+ );
349
+ if (outcome.error) throw outcome.error;
350
+ const response = outcome.result.response;
312
351
  if (response.status === 401) return null;
313
352
  if (!response.ok) throw new Error(`device me failed: HTTP ${response.status}`);
314
353
  return response.json();