omnirush 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/collect-once.ts +72 -141
- package/assets/extensions/omnirush/agents-lib.ts +23 -7
- package/assets/extensions/omnirush/agents.ts +13 -9
- package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
- package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
- package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
- package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
- package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
- package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
- package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
- package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
- package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
- package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
- package/assets/extensions/omnirush/collector.ts +559 -171
- package/assets/extensions/omnirush/commands.ts +1 -1
- package/assets/extensions/omnirush/pi-engine.ts +666 -0
- package/assets/extensions/omnirush/plan.ts +6 -7
- package/assets/extensions/omnirush/sota.ts +11 -1
- package/assets/models.json +1 -1
- package/package.json +5 -2
- package/scripts/build-capture-vendor.js +55 -0
- package/src/bin.js +91 -15
- package/src/lib.js +73 -6
- package/assets/extensions/omnirush/collector-lib.ts +0 -2433
- package/assets/extensions/omnirush/trace-format.ts +0 -311
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The archive upload client (sections 7 and 13.5): JSON calls to the
|
|
3
|
+
* omnirush.ai archive routes, and part PUTs straight to S3 through presigned
|
|
4
|
+
* URLs, each part read from the sealed file when it is sent. Presigned URLs
|
|
5
|
+
* and bearer tokens are credentials and are never logged.
|
|
6
|
+
*/
|
|
7
|
+
import { open, type FileHandle } from "node:fs/promises";
|
|
8
|
+
import { z } from "../vendor/zod.js";
|
|
9
|
+
|
|
10
|
+
import { parseArchivePolicy, type ArchivePolicy } from "./policy.js";
|
|
11
|
+
import { SEAL_ALG, SEAL_CONTENT, sealKid } from "./seal.js";
|
|
12
|
+
|
|
13
|
+
export type ArchiveFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
|
14
|
+
/**
|
|
15
|
+
* An authenticated request to the omnirush.ai API, relative to its root
|
|
16
|
+
* (`archives/key`, `archives`, `archives/<id>/parts`, ...). The owner of the
|
|
17
|
+
* device session (the gateway broker) attaches the bearer and handles its own
|
|
18
|
+
* rotation, as it does for the collector's `upload` hook.
|
|
19
|
+
*/
|
|
20
|
+
export type ArchiveApiRequest = (path: string, init: ArchiveApiRequestInit) => Promise<Response>;
|
|
21
|
+
/** `refresh: false` returns a 401 as it is, without refreshing the bearer (the all-folders policy probe); absent means true. */
|
|
22
|
+
export type ArchiveApiRequestInit = { method: "GET" | "POST"; body?: string; signal?: AbortSignal; refresh?: false };
|
|
23
|
+
export type ArchiveLog = (level: "info" | "warn", message: string, attributes?: Record<string, unknown>) => void;
|
|
24
|
+
|
|
25
|
+
export type RetryPolicy = {
|
|
26
|
+
/** First backoff; doubles per attempt (1 s). */
|
|
27
|
+
baseMs: number;
|
|
28
|
+
/** Backoff cap (5 min). */
|
|
29
|
+
maxMs: number;
|
|
30
|
+
/** Attempts per request before it is left to the next drain (8). */
|
|
31
|
+
attempts: number;
|
|
32
|
+
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
33
|
+
random: () => number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
37
|
+
return new Promise((resolvePromise) => {
|
|
38
|
+
if (signal?.aborted) {
|
|
39
|
+
resolvePromise();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const timer = setTimeout(done, ms);
|
|
43
|
+
function done() {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
signal?.removeEventListener("abort", done);
|
|
46
|
+
resolvePromise();
|
|
47
|
+
}
|
|
48
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_RETRY_POLICY: RetryPolicy = { baseMs: 1_000, maxMs: 5 * 60_000, attempts: 8, sleep, random: Math.random };
|
|
53
|
+
|
|
54
|
+
/** Exponential backoff with plus or minus 20% jitter. */
|
|
55
|
+
export function backoffMs(policy: RetryPolicy, attempt: number): number {
|
|
56
|
+
const exact = Math.min(policy.maxMs, policy.baseMs * 2 ** Math.max(0, attempt - 1));
|
|
57
|
+
return Math.round(exact * (0.8 + 0.4 * policy.random()));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
61
|
+
/** The all-folders policy probe: one attempt this long at most, no backoff and no bearer refresh. */
|
|
62
|
+
export const POLICY_PROBE_TIMEOUT_MS = 5_000;
|
|
63
|
+
/** A part PUT may take this long at least, and longer for big parts on slow links (32 KB/s floor). */
|
|
64
|
+
const PART_MIN_TIMEOUT_MS = 10 * 60_000;
|
|
65
|
+
const PART_MIN_BYTES_PER_MS = 32;
|
|
66
|
+
/** A part is read from the sealed file in slices this big, checking for an abort between them. */
|
|
67
|
+
const PART_READ_SLICE_BYTES = 1024 * 1024;
|
|
68
|
+
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
69
|
+
const PART_RETRYABLE_STATUSES = new Set([400, 408, 429, 500, 502, 503, 504]);
|
|
70
|
+
const MAX_PARTS_PER_REQUEST = 100;
|
|
71
|
+
const MAX_UPLOAD_ROUNDS = 6;
|
|
72
|
+
/** 422 on create for a marker whose policy is off (`folder`, `touched`). */
|
|
73
|
+
export const ARCHIVE_MARKER_NOT_ALLOWED = "archive_marker_not_allowed";
|
|
74
|
+
/** Create conflicts that end the session's chain (section 7.9). */
|
|
75
|
+
const CHAIN_ENDING_CODES = new Set(["archive_id_conflict", "archive_sequence_conflict", "archive_parent_mismatch", "archive_deleted"]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The omnirush.ai API root derived from the gateway URL exactly like the
|
|
79
|
+
* collect URL: trailing `/` and `/v1` stripped; HTTPS unless loopback.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveArchiveApiRoot(rawGatewayUrl: string | undefined): string | null {
|
|
82
|
+
const value = rawGatewayUrl?.trim();
|
|
83
|
+
if (!value) return null;
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(value);
|
|
86
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname))) return null;
|
|
87
|
+
url.pathname = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
88
|
+
url.search = "";
|
|
89
|
+
url.hash = "";
|
|
90
|
+
return url.toString().replace(/\/+$/, "");
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ArchiveCreateRequest = {
|
|
97
|
+
archive_id: string;
|
|
98
|
+
session_id: string;
|
|
99
|
+
kind: "base" | "delta";
|
|
100
|
+
sequence: number;
|
|
101
|
+
turn: number;
|
|
102
|
+
parent_archive_id: string | null;
|
|
103
|
+
size: number;
|
|
104
|
+
sha256: string;
|
|
105
|
+
kid: string;
|
|
106
|
+
content: typeof SEAL_CONTENT;
|
|
107
|
+
marker: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type ArchiveUploadState = {
|
|
111
|
+
upload_id: string;
|
|
112
|
+
object_key: string;
|
|
113
|
+
part_size: number;
|
|
114
|
+
part_count: number;
|
|
115
|
+
/** part number -> ETag exactly as S3 returned it (quotes included). */
|
|
116
|
+
etags: Record<string, string>;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** What the uploader needs from a queued job; it mutates `upload` and `recreates` and persists through the callback. */
|
|
120
|
+
export type ArchiveUploadJob = {
|
|
121
|
+
request: ArchiveCreateRequest;
|
|
122
|
+
sealedPath: string;
|
|
123
|
+
upload: ArchiveUploadState | null;
|
|
124
|
+
recreates: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type ArchiveKey = { kid: string; publicKey: Buffer; alg: string };
|
|
128
|
+
|
|
129
|
+
export type KeyResult =
|
|
130
|
+
/** The policy comes with the key, in the same response. */
|
|
131
|
+
| { status: "ok"; key: ArchiveKey; policy: ArchivePolicy }
|
|
132
|
+
| { status: "disabled"; code: string }
|
|
133
|
+
| { status: "unavailable"; reason: string };
|
|
134
|
+
|
|
135
|
+
export type UploadOutcome =
|
|
136
|
+
| { status: "uploaded" }
|
|
137
|
+
/** 428 / 503 archive_disabled: archiving is off for the user, without error. */
|
|
138
|
+
| { status: "disabled"; code: string }
|
|
139
|
+
/** 401 after a refresh, or 403: stop draining and keep the queue. */
|
|
140
|
+
| { status: "blocked"; reason: string }
|
|
141
|
+
/** A chain-ending conflict, 413 or 422 (archive_marker_not_allowed, else archive_request_invalid): drop the session's jobs and stop archiving it. */
|
|
142
|
+
| { status: "stop_session"; code: string }
|
|
143
|
+
/** 409 archive_kid_unknown: the archive was sealed to a key the server no longer has. */
|
|
144
|
+
| { status: "rekey"; code: string }
|
|
145
|
+
/** Transient failure after the retries: leave the job to a later drain. */
|
|
146
|
+
| { status: "retry_later"; reason: string }
|
|
147
|
+
| { status: "aborted" };
|
|
148
|
+
|
|
149
|
+
const keySchema = z.object({ kid: z.string().regex(/^[0-9a-f]{16}$/), public_key: z.string(), alg: z.string() });
|
|
150
|
+
const partUrlSchema = z.object({ part_number: z.number().int().min(1), url: z.string().min(1) });
|
|
151
|
+
const createSchema = z.object({
|
|
152
|
+
archive_id: z.string(),
|
|
153
|
+
status: z.string(),
|
|
154
|
+
upload_id: z.string().nullish(),
|
|
155
|
+
object_key: z.string().nullish(),
|
|
156
|
+
part_size: z.number().int().positive().nullish(),
|
|
157
|
+
part_count: z.number().int().positive().nullish(),
|
|
158
|
+
parts: z.array(partUrlSchema).nullish(),
|
|
159
|
+
});
|
|
160
|
+
const partsSchema = z.object({
|
|
161
|
+
parts: z.array(partUrlSchema),
|
|
162
|
+
uploaded: z.array(z.object({ part_number: z.number().int().min(1), etag: z.string().min(1), size: z.number().int().nonnegative() })).nullish(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
type ApiResult =
|
|
166
|
+
| { kind: "ok"; status: number; body: unknown }
|
|
167
|
+
| { kind: "error"; status: number; code: string | null }
|
|
168
|
+
| { kind: "unavailable"; reason: string }
|
|
169
|
+
| { kind: "aborted" };
|
|
170
|
+
|
|
171
|
+
async function errorCode(response: Response): Promise<string | null> {
|
|
172
|
+
try {
|
|
173
|
+
const body: unknown = await response.json();
|
|
174
|
+
if (typeof body !== "object" || body === null) return null;
|
|
175
|
+
if ("detail" in body && typeof body.detail === "string") return body.detail;
|
|
176
|
+
if ("error" in body && typeof body.error === "string") return body.error;
|
|
177
|
+
} catch {
|
|
178
|
+
// Not JSON: the status alone decides.
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function timeoutSignal(ms: number, signal?: AbortSignal): AbortSignal {
|
|
184
|
+
const timeout = AbortSignal.timeout(ms);
|
|
185
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type ArchiveUploaderOptions = {
|
|
189
|
+
gatewayUrl?: string;
|
|
190
|
+
accessToken?: string;
|
|
191
|
+
refreshAccessToken?: () => Promise<string | null>;
|
|
192
|
+
/** External egress (S3 PUTs, and API calls without `request`). */
|
|
193
|
+
fetch: ArchiveFetch;
|
|
194
|
+
request?: ArchiveApiRequest;
|
|
195
|
+
retry?: Partial<RetryPolicy>;
|
|
196
|
+
log?: ArchiveLog;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
type PartsResult = { urls: Map<number, string>; uploaded: Array<{ part_number: number; etag: string; size: number }> };
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One part of the sealed file, read into a single buffer (null once `signal`
|
|
203
|
+
* aborted: nothing more is read). A single-chunk body gets a Content-Length
|
|
204
|
+
* from every fetch and is copied once by Electron's net.fetch, which collects
|
|
205
|
+
* a streamed body with one Buffer.concat per chunk before sending it; a 64 MiB
|
|
206
|
+
* part streamed in 64 KiB Blob chunks costs seconds of main-process copying.
|
|
207
|
+
*/
|
|
208
|
+
async function readPart(file: FileHandle, start: number, length: number, signal?: AbortSignal): Promise<Uint8Array<ArrayBuffer> | null> {
|
|
209
|
+
const bytes = new Uint8Array(new ArrayBuffer(length));
|
|
210
|
+
let offset = 0;
|
|
211
|
+
while (offset < length) {
|
|
212
|
+
if (signal?.aborted) return null;
|
|
213
|
+
const { bytesRead } = await file.read(bytes, offset, Math.min(PART_READ_SLICE_BYTES, length - offset), start + offset);
|
|
214
|
+
if (bytesRead === 0) throw new Error("archive file ended early");
|
|
215
|
+
offset += bytesRead;
|
|
216
|
+
}
|
|
217
|
+
return signal?.aborted ? null : bytes;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export class ArchiveUploader {
|
|
221
|
+
private token: string | null;
|
|
222
|
+
private readonly apiRoot: string | null;
|
|
223
|
+
private readonly retry: RetryPolicy;
|
|
224
|
+
private readonly log: ArchiveLog;
|
|
225
|
+
|
|
226
|
+
constructor(private readonly options: ArchiveUploaderOptions) {
|
|
227
|
+
this.token = options.accessToken?.trim() || null;
|
|
228
|
+
this.apiRoot = resolveArchiveApiRoot(options.gatewayUrl);
|
|
229
|
+
this.retry = { ...DEFAULT_RETRY_POLICY, ...options.retry };
|
|
230
|
+
this.log = options.log ?? (() => undefined);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Whether the API can be reached at all: a request hook, or a gateway URL plus a bearer. */
|
|
234
|
+
get configured(): boolean {
|
|
235
|
+
return Boolean(this.options.request) || (this.apiRoot !== null && this.token !== null);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
setAccessToken(token: string | null): void {
|
|
239
|
+
this.token = token?.trim() || null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private send(method: "GET" | "POST", path: string, body: unknown, signal?: AbortSignal, probe = false): Promise<Response> {
|
|
243
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
244
|
+
const timeout = timeoutSignal(probe ? POLICY_PROBE_TIMEOUT_MS : REQUEST_TIMEOUT_MS, signal);
|
|
245
|
+
if (this.options.request) return this.options.request(path, { method, ...(payload === undefined ? {} : { body: payload }), signal: timeout, ...(probe ? { refresh: false as const } : {}) });
|
|
246
|
+
if (!this.apiRoot || !this.token) return Promise.reject(new Error("archive API not configured"));
|
|
247
|
+
return this.options.fetch(`${this.apiRoot}/${path}`, {
|
|
248
|
+
method,
|
|
249
|
+
headers: {
|
|
250
|
+
Authorization: `Bearer ${this.token}`,
|
|
251
|
+
Accept: "application/json",
|
|
252
|
+
...(payload === undefined ? {} : { "Content-Type": "application/json" }),
|
|
253
|
+
},
|
|
254
|
+
...(payload === undefined ? {} : { body: payload }),
|
|
255
|
+
signal: timeout,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async refresh(): Promise<boolean> {
|
|
260
|
+
if (!this.options.refreshAccessToken) return false;
|
|
261
|
+
try {
|
|
262
|
+
const token = (await this.options.refreshAccessToken())?.trim() ?? "";
|
|
263
|
+
if (!token) return false;
|
|
264
|
+
this.token = token;
|
|
265
|
+
return true;
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** One API call: 401 refreshes the bearer once; network errors and retryable statuses back off. */
|
|
272
|
+
private async call(method: "GET" | "POST", path: string, body: unknown, signal?: AbortSignal): Promise<ApiResult> {
|
|
273
|
+
let refreshed = false;
|
|
274
|
+
let attempt = 0;
|
|
275
|
+
for (;;) {
|
|
276
|
+
if (signal?.aborted) return { kind: "aborted" };
|
|
277
|
+
let response: Response;
|
|
278
|
+
let failure: string;
|
|
279
|
+
try {
|
|
280
|
+
response = await this.send(method, path, body, signal);
|
|
281
|
+
if (response.ok) {
|
|
282
|
+
const parsed: unknown = await response.json().catch(() => null);
|
|
283
|
+
return { kind: "ok", status: response.status, body: parsed };
|
|
284
|
+
}
|
|
285
|
+
const code = await errorCode(response);
|
|
286
|
+
if (response.status === 401 && !refreshed) {
|
|
287
|
+
refreshed = true;
|
|
288
|
+
if (await this.refresh()) continue;
|
|
289
|
+
}
|
|
290
|
+
if (!RETRYABLE_STATUSES.has(response.status) || (response.status === 503 && code === "archive_disabled")) {
|
|
291
|
+
return { kind: "error", status: response.status, code };
|
|
292
|
+
}
|
|
293
|
+
failure = `status ${response.status}`;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (signal?.aborted) return { kind: "aborted" };
|
|
296
|
+
failure = error instanceof Error ? error.name : "network error";
|
|
297
|
+
}
|
|
298
|
+
attempt += 1;
|
|
299
|
+
if (attempt >= this.retry.attempts) return { kind: "unavailable", reason: failure };
|
|
300
|
+
await this.retry.sleep(backoffMs(this.retry, attempt), signal);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The outcome for failures every route shares (7.1, 7.9); null when the caller decides. */
|
|
305
|
+
private common(result: ApiResult): UploadOutcome | null {
|
|
306
|
+
if (result.kind === "aborted") return { status: "aborted" };
|
|
307
|
+
if (result.kind === "unavailable") return { status: "retry_later", reason: result.reason };
|
|
308
|
+
if (result.kind === "ok") return null;
|
|
309
|
+
if (result.status === 401) return { status: "blocked", reason: "unauthorized" };
|
|
310
|
+
if (result.status === 403) return { status: "blocked", reason: result.code ?? "forbidden" };
|
|
311
|
+
if (result.status === 428) return { status: "disabled", code: result.code ?? "archive_consent_required" };
|
|
312
|
+
if (result.status === 503) return { status: "disabled", code: result.code ?? "archive_disabled" };
|
|
313
|
+
if (result.status === 413) return { status: "stop_session", code: result.code ?? "archive_too_large" };
|
|
314
|
+
// The server does not take this marker (a folder policy that is off): the chain goes, with no retry.
|
|
315
|
+
if (result.status === 422) return { status: "stop_session", code: result.code === ARCHIVE_MARKER_NOT_ALLOWED ? result.code : "archive_request_invalid" };
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** One GET with a single attempt: no bearer refresh on a 401, no backoff; a retryable failure is `unavailable`. */
|
|
320
|
+
private async getOnce(path: string, signal?: AbortSignal): Promise<ApiResult> {
|
|
321
|
+
if (signal?.aborted) return { kind: "aborted" };
|
|
322
|
+
try {
|
|
323
|
+
const response = await this.send("GET", path, undefined, signal, true);
|
|
324
|
+
if (response.ok) return { kind: "ok", status: response.status, body: await response.json().catch(() => null) };
|
|
325
|
+
const code = await errorCode(response);
|
|
326
|
+
if (!RETRYABLE_STATUSES.has(response.status) || (response.status === 503 && code === "archive_disabled")) return { kind: "error", status: response.status, code };
|
|
327
|
+
return { kind: "unavailable", reason: `status ${response.status}` };
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (signal?.aborted) return { kind: "aborted" };
|
|
330
|
+
return { kind: "unavailable", reason: error instanceof Error ? error.name : "network error" };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** GET /archives/key (7.2). 428, 503 and a server without the route all mean archiving is off. */
|
|
335
|
+
async fetchKey(signal?: AbortSignal): Promise<KeyResult> {
|
|
336
|
+
if (!this.configured) return { status: "disabled", code: "not_configured" };
|
|
337
|
+
return this.keyResult(await this.call("GET", "archives/key", undefined, signal));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* GET /archives/key as the all-folders policy probe (4.4): one attempt
|
|
342
|
+
* within POLICY_PROBE_TIMEOUT_MS, no backoff, and a 401 is not answered
|
|
343
|
+
* with a bearer refresh. The caller counts anything but `ok` with
|
|
344
|
+
* `policy.allFolders` as the policy off.
|
|
345
|
+
*/
|
|
346
|
+
async probeKey(signal?: AbortSignal): Promise<KeyResult> {
|
|
347
|
+
if (!this.configured) return { status: "disabled", code: "not_configured" };
|
|
348
|
+
return this.keyResult(await this.getOnce("archives/key", signal));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private keyResult(result: ApiResult): KeyResult {
|
|
352
|
+
if (result.kind === "ok") {
|
|
353
|
+
const parsed = keySchema.safeParse(result.body);
|
|
354
|
+
if (!parsed.success) return { status: "unavailable", reason: "invalid key response" };
|
|
355
|
+
const publicKey = Buffer.from(parsed.data.public_key, "base64");
|
|
356
|
+
if (publicKey.length !== 32 || publicKey.toString("base64") !== parsed.data.public_key || sealKid(publicKey) !== parsed.data.kid || parsed.data.alg !== SEAL_ALG) {
|
|
357
|
+
return { status: "unavailable", reason: "invalid key response" };
|
|
358
|
+
}
|
|
359
|
+
return { status: "ok", key: { kid: parsed.data.kid, publicKey, alg: parsed.data.alg }, policy: parseArchivePolicy(result.body) };
|
|
360
|
+
}
|
|
361
|
+
if (result.kind === "error" && result.status === 404) return { status: "disabled", code: "archive_routes_missing" };
|
|
362
|
+
const outcome = this.common(result);
|
|
363
|
+
if (outcome?.status === "disabled") return { status: "disabled", code: outcome.code };
|
|
364
|
+
if (result.kind === "error") return { status: "unavailable", reason: `status ${result.status}` };
|
|
365
|
+
return { status: "unavailable", reason: result.kind === "unavailable" ? result.reason : result.kind };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Best-effort POST /archives/{id}/abort (7.7); one attempt, result ignored. */
|
|
369
|
+
async abort(archiveId: string, signal?: AbortSignal): Promise<void> {
|
|
370
|
+
try {
|
|
371
|
+
const response = await this.send("POST", `archives/${archiveId}/abort`, {}, signal);
|
|
372
|
+
await response.body?.cancel().catch(() => undefined);
|
|
373
|
+
} catch {
|
|
374
|
+
// The server's stale-upload maintenance aborts it within a day.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async create(job: ArchiveUploadJob, signal?: AbortSignal): Promise<UploadOutcome | { uploaded: true } | { upload: ArchiveUploadState; urls: Map<number, string> }> {
|
|
379
|
+
const result = await this.call("POST", "archives", job.request, signal);
|
|
380
|
+
if (result.kind === "ok") {
|
|
381
|
+
const parsed = createSchema.safeParse(result.body);
|
|
382
|
+
if (!parsed.success || parsed.data.archive_id !== job.request.archive_id) return { status: "retry_later", reason: "invalid create response" };
|
|
383
|
+
if (parsed.data.status === "uploaded") return { uploaded: true };
|
|
384
|
+
const { upload_id: uploadId, object_key: objectKey, part_size: partSize, part_count: partCount } = parsed.data;
|
|
385
|
+
if (!uploadId || !objectKey || !partSize || !partCount || partCount !== Math.ceil(job.request.size / partSize)) {
|
|
386
|
+
return { status: "retry_later", reason: "invalid create response" };
|
|
387
|
+
}
|
|
388
|
+
const urls = new Map((parsed.data.parts ?? []).map((part) => [part.part_number, part.url]));
|
|
389
|
+
return { upload: { upload_id: uploadId, object_key: objectKey, part_size: partSize, part_count: partCount, etags: {} }, urls };
|
|
390
|
+
}
|
|
391
|
+
const outcome = this.common(result);
|
|
392
|
+
if (outcome) return outcome;
|
|
393
|
+
if (result.kind !== "error") return { status: "retry_later", reason: "create failed" };
|
|
394
|
+
if (result.status === 409 && result.code === "archive_kid_unknown") return { status: "rekey", code: result.code };
|
|
395
|
+
if (result.status === 409 && result.code && CHAIN_ENDING_CODES.has(result.code)) return { status: "stop_session", code: result.code };
|
|
396
|
+
return { status: "retry_later", reason: `create status ${result.status}` };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private async listParts(job: ArchiveUploadJob, partNumbers: number[], signal?: AbortSignal): Promise<UploadOutcome | "recreate" | PartsResult> {
|
|
400
|
+
const result = await this.call("POST", `archives/${job.request.archive_id}/parts`, { part_numbers: partNumbers }, signal);
|
|
401
|
+
if (result.kind === "ok") {
|
|
402
|
+
const parsed = partsSchema.safeParse(result.body);
|
|
403
|
+
if (!parsed.success) return { status: "retry_later", reason: "invalid parts response" };
|
|
404
|
+
return { urls: new Map(parsed.data.parts.map((part) => [part.part_number, part.url])), uploaded: parsed.data.uploaded ?? [] };
|
|
405
|
+
}
|
|
406
|
+
const outcome = this.common(result);
|
|
407
|
+
if (outcome) return outcome;
|
|
408
|
+
if (result.kind === "error" && (result.status === 404 || (result.status === 409 && result.code === "archive_not_uploading"))) return "recreate";
|
|
409
|
+
return { status: "retry_later", reason: result.kind === "error" ? `parts status ${result.status}` : "parts failed" };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private async complete(job: ArchiveUploadJob, upload: ArchiveUploadState, signal?: AbortSignal): Promise<UploadOutcome | "recreate" | "relist" | { reupload: string }> {
|
|
413
|
+
const parts = Array.from({ length: upload.part_count }, (_, index) => ({ part_number: index + 1, etag: upload.etags[String(index + 1)] ?? "" }));
|
|
414
|
+
const result = await this.call("POST", `archives/${job.request.archive_id}/complete`, { parts }, signal);
|
|
415
|
+
if (result.kind === "ok") return { status: "uploaded" };
|
|
416
|
+
const outcome = this.common(result);
|
|
417
|
+
if (outcome) return outcome;
|
|
418
|
+
if (result.kind === "error") {
|
|
419
|
+
if (result.status === 404 || (result.status === 409 && result.code === "archive_not_uploading")) return "recreate";
|
|
420
|
+
if (result.status === 409 && result.code === "archive_parts_invalid") return "relist";
|
|
421
|
+
if (result.status === 409 && (result.code === "archive_size_mismatch" || result.code === "archive_seal_invalid")) return { reupload: result.code };
|
|
422
|
+
return { status: "retry_later", reason: `complete status ${result.status}` };
|
|
423
|
+
}
|
|
424
|
+
return { status: "retry_later", reason: "complete failed" };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** PUT one part to its presigned URL; an abort of `signal` cancels the request at once. */
|
|
428
|
+
private async putPart(url: string, body: Uint8Array<ArrayBuffer>, signal?: AbortSignal): Promise<{ etag: string } | "forbidden" | "gone" | { failed: string }> {
|
|
429
|
+
let attempt = 0;
|
|
430
|
+
for (;;) {
|
|
431
|
+
if (signal?.aborted) return { failed: "aborted" };
|
|
432
|
+
let failure: string;
|
|
433
|
+
try {
|
|
434
|
+
const timeout = Math.max(PART_MIN_TIMEOUT_MS, Math.ceil(body.byteLength / PART_MIN_BYTES_PER_MS));
|
|
435
|
+
const response = await this.options.fetch(url, { method: "PUT", body, signal: timeoutSignal(timeout, signal) });
|
|
436
|
+
await response.body?.cancel().catch(() => undefined);
|
|
437
|
+
if (response.ok) {
|
|
438
|
+
const etag = response.headers.get("etag");
|
|
439
|
+
if (etag) return { etag };
|
|
440
|
+
failure = "missing etag";
|
|
441
|
+
} else if (response.status === 403) {
|
|
442
|
+
return "forbidden";
|
|
443
|
+
} else if (response.status === 404) {
|
|
444
|
+
return "gone";
|
|
445
|
+
} else if (!PART_RETRYABLE_STATUSES.has(response.status)) {
|
|
446
|
+
return { failed: `part status ${response.status}` };
|
|
447
|
+
} else {
|
|
448
|
+
failure = `part status ${response.status}`;
|
|
449
|
+
}
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (signal?.aborted) return { failed: "aborted" };
|
|
452
|
+
failure = error instanceof Error ? error.name : "network error";
|
|
453
|
+
}
|
|
454
|
+
attempt += 1;
|
|
455
|
+
if (attempt >= this.retry.attempts) return { failed: failure };
|
|
456
|
+
await this.retry.sleep(backoffMs(this.retry, attempt), signal);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** PUTs every part S3 does not hold yet; "recreate" when the multipart upload is gone. */
|
|
461
|
+
private async putMissingParts(
|
|
462
|
+
job: ArchiveUploadJob,
|
|
463
|
+
upload: ArchiveUploadState,
|
|
464
|
+
file: FileHandle,
|
|
465
|
+
initialUrls: Map<number, string>,
|
|
466
|
+
persist: () => Promise<void>,
|
|
467
|
+
signal?: AbortSignal,
|
|
468
|
+
): Promise<UploadOutcome | "recreate" | "done"> {
|
|
469
|
+
const urls = new Map(initialUrls);
|
|
470
|
+
const size = job.request.size;
|
|
471
|
+
const partBytes = (part: number) => Math.min(upload.part_size, size - (part - 1) * upload.part_size);
|
|
472
|
+
const missing = () => {
|
|
473
|
+
const parts: number[] = [];
|
|
474
|
+
for (let part = 1; part <= upload.part_count; part += 1) if (!upload.etags[String(part)]) parts.push(part);
|
|
475
|
+
return parts;
|
|
476
|
+
};
|
|
477
|
+
let forbiddenInRow = 0;
|
|
478
|
+
for (;;) {
|
|
479
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
480
|
+
const todo = missing();
|
|
481
|
+
const part = todo[0];
|
|
482
|
+
if (part === undefined) return "done";
|
|
483
|
+
const url = urls.get(part);
|
|
484
|
+
if (!url) {
|
|
485
|
+
const listed = await this.listParts(job, todo.slice(0, MAX_PARTS_PER_REQUEST), signal);
|
|
486
|
+
if (listed === "recreate" || "status" in listed) return listed;
|
|
487
|
+
let learned = false;
|
|
488
|
+
for (const held of listed.uploaded) {
|
|
489
|
+
if (held.part_number <= upload.part_count && held.size === partBytes(held.part_number) && upload.etags[String(held.part_number)] !== held.etag) {
|
|
490
|
+
upload.etags[String(held.part_number)] = held.etag;
|
|
491
|
+
learned = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (learned) await persist();
|
|
495
|
+
for (const [number, partUrl] of listed.urls) urls.set(number, partUrl);
|
|
496
|
+
if (!urls.has(part) && !upload.etags[String(part)]) return { status: "retry_later", reason: "no part url" };
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
let body: Uint8Array<ArrayBuffer> | null;
|
|
500
|
+
try {
|
|
501
|
+
body = await readPart(file, (part - 1) * upload.part_size, partBytes(part), signal);
|
|
502
|
+
} catch (error) {
|
|
503
|
+
// The next drain checks the file again (a truncated one stops the session).
|
|
504
|
+
return { status: "retry_later", reason: `archive file read failed: ${error instanceof Error ? error.message : "unknown"}` };
|
|
505
|
+
}
|
|
506
|
+
if (!body) return { status: "aborted" };
|
|
507
|
+
const result = await this.putPart(url, body, signal);
|
|
508
|
+
if (result === "forbidden") {
|
|
509
|
+
// An expired URL, or expired signing credentials: fetch fresh URLs and retry.
|
|
510
|
+
forbiddenInRow += 1;
|
|
511
|
+
if (forbiddenInRow > 2) return { status: "retry_later", reason: "part forbidden" };
|
|
512
|
+
urls.clear();
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (result === "gone") {
|
|
516
|
+
// NoSuchUpload: /parts answers archive_not_uploading, then the upload is re-created.
|
|
517
|
+
urls.clear();
|
|
518
|
+
const listed = await this.listParts(job, [part], signal);
|
|
519
|
+
if (listed === "recreate" || "status" in listed) return listed;
|
|
520
|
+
for (const [number, partUrl] of listed.urls) urls.set(number, partUrl);
|
|
521
|
+
forbiddenInRow += 1;
|
|
522
|
+
if (forbiddenInRow > 2) return { status: "retry_later", reason: "part not found" };
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if ("failed" in result) {
|
|
526
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
527
|
+
this.log("warn", "OmniRush archive part upload failed", { archiveId: job.request.archive_id, part, reason: result.failed });
|
|
528
|
+
return { status: "retry_later", reason: result.failed };
|
|
529
|
+
}
|
|
530
|
+
forbiddenInRow = 0;
|
|
531
|
+
upload.etags[String(part)] = result.etag;
|
|
532
|
+
urls.delete(part);
|
|
533
|
+
await persist();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Uploads one sealed archive: create (or resume), PUT the parts S3 lacks,
|
|
539
|
+
* complete. Never throws; the job's upload state is persisted after every
|
|
540
|
+
* part so a restart resumes where it stopped.
|
|
541
|
+
*/
|
|
542
|
+
async upload(job: ArchiveUploadJob, persist: () => Promise<void>, signal?: AbortSignal): Promise<UploadOutcome> {
|
|
543
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
544
|
+
let file: FileHandle;
|
|
545
|
+
try {
|
|
546
|
+
file = await open(job.sealedPath, "r");
|
|
547
|
+
} catch {
|
|
548
|
+
return { status: "stop_session", code: "archive_file_missing" };
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
if ((await file.stat()).size !== job.request.size) return { status: "stop_session", code: "archive_file_corrupt" };
|
|
552
|
+
return await this.uploadFrom(file, job, persist, signal);
|
|
553
|
+
} finally {
|
|
554
|
+
await file.close().catch(() => undefined);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private async uploadFrom(file: FileHandle, job: ArchiveUploadJob, persist: () => Promise<void>, signal?: AbortSignal): Promise<UploadOutcome> {
|
|
559
|
+
for (let round = 0; round < MAX_UPLOAD_ROUNDS; round += 1) {
|
|
560
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
561
|
+
let urls = new Map<number, string>();
|
|
562
|
+
if (!job.upload) {
|
|
563
|
+
const created = await this.create(job, signal);
|
|
564
|
+
if ("status" in created) return created;
|
|
565
|
+
if ("uploaded" in created) return { status: "uploaded" };
|
|
566
|
+
job.upload = created.upload;
|
|
567
|
+
urls = created.urls;
|
|
568
|
+
await persist();
|
|
569
|
+
}
|
|
570
|
+
const upload = job.upload;
|
|
571
|
+
const put = await this.putMissingParts(job, upload, file, urls, persist, signal);
|
|
572
|
+
if (put === "recreate") {
|
|
573
|
+
job.upload = null;
|
|
574
|
+
await persist();
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (put !== "done") return put;
|
|
578
|
+
const completed = await this.complete(job, upload, signal);
|
|
579
|
+
if (completed === "recreate") {
|
|
580
|
+
job.upload = null;
|
|
581
|
+
await persist();
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (completed === "relist") {
|
|
585
|
+
// Trust S3's own part list: /parts reports what it holds and the rest is sent again.
|
|
586
|
+
upload.etags = {};
|
|
587
|
+
await persist();
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if ("reupload" in completed) {
|
|
591
|
+
if (job.recreates >= 1) return { status: "stop_session", code: completed.reupload };
|
|
592
|
+
job.recreates += 1;
|
|
593
|
+
job.upload = null;
|
|
594
|
+
await persist();
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
return completed;
|
|
598
|
+
}
|
|
599
|
+
return { status: "retry_later", reason: "upload rounds exhausted" };
|
|
600
|
+
}
|
|
601
|
+
}
|