mergestorm 0.3.8 → 0.3.13
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/README.md +59 -14
- package/dist/api.d.ts +27 -38
- package/dist/api.js +81 -4
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/commands/credits.js +33 -12
- package/dist/commands/jobs.js +1 -1
- package/dist/commands/login.js +29 -8
- package/dist/commands/registry.d.ts +1 -1
- package/dist/commands/registry.js +26 -19
- package/dist/commands/review-client.d.ts +93 -0
- package/dist/commands/review-client.js +289 -0
- package/dist/commands/review.d.ts +30 -6
- package/dist/commands/review.js +385 -146
- package/dist/commands/stack.d.ts +34 -2
- package/dist/commands/stack.js +256 -62
- package/dist/commands/status.d.ts +16 -2
- package/dist/commands/status.js +221 -21
- package/dist/errors.d.ts +18 -2
- package/dist/errors.js +22 -1
- package/dist/git-stack.d.ts +2 -0
- package/dist/git-stack.js +4 -0
- package/dist/git.d.ts +12 -1
- package/dist/git.js +52 -7
- package/dist/shell.d.ts +1 -1
- package/dist/shell.js +7 -4
- package/dist/stack-dto.d.ts +185 -0
- package/dist/stack-dto.js +7 -0
- package/dist/submit-pr-base.d.ts +31 -0
- package/dist/submit-pr-base.js +69 -0
- package/dist/ui/banner.js +12 -0
- package/dist/ui/box.d.ts +13 -9
- package/dist/ui/box.js +28 -44
- package/dist/ui/findings.d.ts +2 -0
- package/dist/ui/findings.js +2 -1
- package/dist/ui/job-envelope.d.ts +68 -0
- package/dist/ui/job-envelope.js +35 -0
- package/dist/ui/prompt.d.ts +91 -0
- package/dist/ui/prompt.js +241 -62
- package/dist/ui/usage.d.ts +25 -1
- package/dist/ui/usage.js +50 -1
- package/dist/ui/width.d.ts +26 -0
- package/dist/ui/width.js +262 -0
- package/package.json +6 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
import type { ReviewJobRow } from "../ui/job-envelope.js";
|
|
3
|
+
export declare const REVIEW_POLL_INTERVAL_MS = 2000;
|
|
4
|
+
export declare const REVIEW_POLL_DEFAULT_TIMEOUT_MS = 480000;
|
|
5
|
+
export declare const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
|
|
6
|
+
export declare const REVIEW_POLL_BACKOFF_MS = 250;
|
|
7
|
+
export declare const REVIEW_POLL_MAX_INTERVAL_MS = 5000;
|
|
8
|
+
export declare const REVIEW_POLL_STRETCH_AFTER_MS = 60000;
|
|
9
|
+
export declare const REVIEW_POLL_STRETCH_SPAN_MS = 60000;
|
|
10
|
+
/** Same thread slug contract as POST /api/v1/reviews. */
|
|
11
|
+
export declare const REVIEW_THREAD_SLUG_RE: RegExp;
|
|
12
|
+
export declare const MAX_CONTEXT_TEXT_CHARS = 60000;
|
|
13
|
+
export declare const MAX_CONTEXT_FILES = 10;
|
|
14
|
+
export declare const MAX_CONTEXT_FILE_CHARS = 16000;
|
|
15
|
+
export declare const MAX_CONTEXT_FILES_TOTAL = 60000;
|
|
16
|
+
export declare const MAX_IDEMPOTENCY_KEY_CHARS = 128;
|
|
17
|
+
export type ReviewInput = {
|
|
18
|
+
thread: string;
|
|
19
|
+
branch: string;
|
|
20
|
+
repo?: string;
|
|
21
|
+
baseLabel: string;
|
|
22
|
+
headLabel: string;
|
|
23
|
+
diff: string;
|
|
24
|
+
files: {
|
|
25
|
+
path: string;
|
|
26
|
+
content: string;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
export declare function collectReviewInput(base: string, head: string, cwd?: string, sandboxRoot?: string): Promise<ReviewInput | null>;
|
|
30
|
+
export type SubmitReviewOptions = {
|
|
31
|
+
routerMode?: string;
|
|
32
|
+
specialists?: string[];
|
|
33
|
+
context?: string;
|
|
34
|
+
contextFiles?: {
|
|
35
|
+
path: string;
|
|
36
|
+
content: string;
|
|
37
|
+
}[];
|
|
38
|
+
idempotencyKey?: string;
|
|
39
|
+
webhookUrl?: string;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
};
|
|
42
|
+
export type ReviewContextPayload = {
|
|
43
|
+
context?: string;
|
|
44
|
+
contextFiles?: {
|
|
45
|
+
path: string;
|
|
46
|
+
content: string;
|
|
47
|
+
}[];
|
|
48
|
+
};
|
|
49
|
+
export type LoadReviewContextOptions = {
|
|
50
|
+
context?: string;
|
|
51
|
+
contextFiles: string[];
|
|
52
|
+
cwd?: string;
|
|
53
|
+
readStdin?: () => Promise<string>;
|
|
54
|
+
/** Reject contextFiles that resolve outside this directory (agent-driven callers). */
|
|
55
|
+
sandboxCwd?: string;
|
|
56
|
+
};
|
|
57
|
+
/** Load --context / --context-file and enforce the API caps client-side. */
|
|
58
|
+
export declare function loadReviewContext(opts: LoadReviewContextOptions): Promise<ReviewContextPayload>;
|
|
59
|
+
export declare function submitReview(cfg: Config, input: ReviewInput, opts?: SubmitReviewOptions): Promise<{
|
|
60
|
+
status: number;
|
|
61
|
+
row: ReviewJobRow;
|
|
62
|
+
retryAfterSeconds?: number;
|
|
63
|
+
}>;
|
|
64
|
+
/** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
|
|
65
|
+
export declare function transientRetryWaitMs(input: {
|
|
66
|
+
retryAfterSeconds?: number;
|
|
67
|
+
failureCount: number;
|
|
68
|
+
random?: () => number;
|
|
69
|
+
}): number;
|
|
70
|
+
/** Keep the default 2s cadence for a minute, then ramp toward 5s. */
|
|
71
|
+
export declare function stretchedPollIntervalMs(elapsedMs: number, intervalMs: number): number;
|
|
72
|
+
export declare function isTransientReviewPollStatus(status: number): boolean;
|
|
73
|
+
export declare function isTransientReviewPollError(err: unknown): boolean;
|
|
74
|
+
export declare class ReviewPollTimeoutError extends Error {
|
|
75
|
+
readonly jobId: string;
|
|
76
|
+
readonly lastRow: ReviewJobRow;
|
|
77
|
+
constructor(jobId: string, lastRow: ReviewJobRow);
|
|
78
|
+
}
|
|
79
|
+
export type PollReviewOptions = {
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
intervalMs?: number;
|
|
82
|
+
signal?: AbortSignal;
|
|
83
|
+
onTick?: (kind: "progress" | "retry") => void;
|
|
84
|
+
/** Rethrow per-request api_timeout instead of folding it into a poll timeout (interactive detach). */
|
|
85
|
+
propagateTimeout?: boolean;
|
|
86
|
+
/** Test seam; production uses `setTimeout`. */
|
|
87
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
88
|
+
/** Test seam; production uses `Date.now`. */
|
|
89
|
+
now?: () => number;
|
|
90
|
+
/** Test seam for backoff jitter; production uses `Math.random`. */
|
|
91
|
+
random?: () => number;
|
|
92
|
+
};
|
|
93
|
+
export declare function pollReview(cfg: Config, jobId: string, opts?: PollReviewOptions): Promise<ReviewJobRow>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { apiFetch } from "../api.js";
|
|
4
|
+
import { CommandError, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage } from "../errors.js";
|
|
5
|
+
import { changedNamesInSandbox, collectChangedFiles, git, parseGithubOriginRepo, repoRoot, } from "../git.js";
|
|
6
|
+
export const REVIEW_POLL_INTERVAL_MS = 2_000;
|
|
7
|
+
export const REVIEW_POLL_DEFAULT_TIMEOUT_MS = 480_000;
|
|
8
|
+
export const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
|
|
9
|
+
export const REVIEW_POLL_BACKOFF_MS = 250;
|
|
10
|
+
export const REVIEW_POLL_MAX_INTERVAL_MS = 5_000;
|
|
11
|
+
export const REVIEW_POLL_STRETCH_AFTER_MS = 60_000;
|
|
12
|
+
export const REVIEW_POLL_STRETCH_SPAN_MS = 60_000;
|
|
13
|
+
/** Same thread slug contract as POST /api/v1/reviews. */
|
|
14
|
+
export const REVIEW_THREAD_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,119}$/;
|
|
15
|
+
export const MAX_CONTEXT_TEXT_CHARS = 60_000;
|
|
16
|
+
export const MAX_CONTEXT_FILES = 10;
|
|
17
|
+
export const MAX_CONTEXT_FILE_CHARS = 16_000;
|
|
18
|
+
export const MAX_CONTEXT_FILES_TOTAL = 60_000;
|
|
19
|
+
export const MAX_IDEMPOTENCY_KEY_CHARS = 128;
|
|
20
|
+
export async function collectReviewInput(base, head, cwd = process.cwd(), sandboxRoot) {
|
|
21
|
+
let diff;
|
|
22
|
+
try {
|
|
23
|
+
const names = await changedNamesInSandbox(base, head, cwd, sandboxRoot);
|
|
24
|
+
if (names.length === 0) {
|
|
25
|
+
diff = "";
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const root = repoRoot(cwd);
|
|
29
|
+
const specs = names.map((name) => `:(literal)${relative(cwd, join(root, name))}`);
|
|
30
|
+
diff = git(["diff", `${base}...${head}`, "--", ...specs], cwd);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
35
|
+
throw new CommandError(`Could not diff ${base}...${head}: ${detail}. Try \`mergestorm review <base>\` with your trunk branch.`, 2, "usage");
|
|
36
|
+
}
|
|
37
|
+
if (!diff.trim())
|
|
38
|
+
return null;
|
|
39
|
+
const files = await collectChangedFiles(base, head, cwd, sandboxRoot);
|
|
40
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd).trim() || "local";
|
|
41
|
+
const thread = `local/${branch}`.replace(/[^a-zA-Z0-9._/-]/g, "-").slice(0, 120);
|
|
42
|
+
const originRepo = parseGithubOriginRepo(cwd);
|
|
43
|
+
return {
|
|
44
|
+
thread,
|
|
45
|
+
branch,
|
|
46
|
+
...(originRepo ? { repo: `${originRepo.owner}/${originRepo.repo}` } : {}),
|
|
47
|
+
baseLabel: base,
|
|
48
|
+
headLabel: head,
|
|
49
|
+
diff,
|
|
50
|
+
files,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function defaultReadStdin() {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
for await (const chunk of process.stdin) {
|
|
56
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
57
|
+
}
|
|
58
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
59
|
+
}
|
|
60
|
+
function contextUsage(message) {
|
|
61
|
+
return new CommandError(message, REVIEW_EXIT.usage, "usage");
|
|
62
|
+
}
|
|
63
|
+
/** Load --context / --context-file and enforce the API caps client-side. */
|
|
64
|
+
export async function loadReviewContext(opts) {
|
|
65
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
66
|
+
let context;
|
|
67
|
+
if (opts.context !== undefined) {
|
|
68
|
+
const raw = opts.context === "-"
|
|
69
|
+
? await (opts.readStdin ?? defaultReadStdin)()
|
|
70
|
+
: opts.context;
|
|
71
|
+
context = raw.trim();
|
|
72
|
+
if (!context) {
|
|
73
|
+
throw contextUsage("--context is empty");
|
|
74
|
+
}
|
|
75
|
+
if (context.length > MAX_CONTEXT_TEXT_CHARS) {
|
|
76
|
+
throw contextUsage(`--context is ${context.length} chars; max is ${MAX_CONTEXT_TEXT_CHARS}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (opts.contextFiles.length > MAX_CONTEXT_FILES) {
|
|
80
|
+
throw contextUsage(`--context-file may be repeated at most ${MAX_CONTEXT_FILES} times`);
|
|
81
|
+
}
|
|
82
|
+
const contextFiles = [];
|
|
83
|
+
let total = 0;
|
|
84
|
+
for (const rawPath of opts.contextFiles) {
|
|
85
|
+
const abs = resolve(cwd, rawPath);
|
|
86
|
+
if (opts.sandboxCwd) {
|
|
87
|
+
let sandboxReal;
|
|
88
|
+
let targetReal;
|
|
89
|
+
try {
|
|
90
|
+
[sandboxReal, targetReal] = await Promise.all([
|
|
91
|
+
realpath(resolve(opts.sandboxCwd)),
|
|
92
|
+
realpath(abs),
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw contextUsage(`--context-file ${rawPath} must be inside the working directory`);
|
|
97
|
+
}
|
|
98
|
+
const relToSandbox = relative(sandboxReal, targetReal);
|
|
99
|
+
if (relToSandbox === ".." || relToSandbox.startsWith(`..${sep}`)) {
|
|
100
|
+
throw contextUsage(`--context-file ${rawPath} must be inside the working directory`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let content;
|
|
104
|
+
try {
|
|
105
|
+
content = await readFile(abs, "utf8");
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
109
|
+
throw contextUsage(`could not read --context-file ${rawPath}: ${detail}`);
|
|
110
|
+
}
|
|
111
|
+
if (content.length > MAX_CONTEXT_FILE_CHARS) {
|
|
112
|
+
throw contextUsage(`--context-file ${rawPath} is ${content.length} chars; max is ${MAX_CONTEXT_FILE_CHARS} per file`);
|
|
113
|
+
}
|
|
114
|
+
total += content.length;
|
|
115
|
+
if (total > MAX_CONTEXT_FILES_TOTAL) {
|
|
116
|
+
throw contextUsage(`--context-file total is ${total} chars; max is ${MAX_CONTEXT_FILES_TOTAL}`);
|
|
117
|
+
}
|
|
118
|
+
contextFiles.push({ path: relative(cwd, abs), content });
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
...(context ? { context } : {}),
|
|
122
|
+
...(contextFiles.length > 0 ? { contextFiles } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export async function submitReview(cfg, input, opts = {}) {
|
|
126
|
+
const { status, body, retryAfterSeconds } = await apiFetch(cfg, "/api/v1/reviews", {
|
|
127
|
+
method: "POST",
|
|
128
|
+
json: {
|
|
129
|
+
thread: input.thread,
|
|
130
|
+
branch: input.branch,
|
|
131
|
+
...(input.repo ? { repo: input.repo } : {}),
|
|
132
|
+
base_label: input.baseLabel,
|
|
133
|
+
head_label: input.headLabel,
|
|
134
|
+
diff: input.diff,
|
|
135
|
+
files: input.files,
|
|
136
|
+
...(opts.routerMode ? { router_mode: opts.routerMode } : {}),
|
|
137
|
+
...(opts.specialists?.length ? { specialists: opts.specialists } : {}),
|
|
138
|
+
...(opts.context ? { context: opts.context } : {}),
|
|
139
|
+
...(opts.contextFiles?.length ? { context_files: opts.contextFiles } : {}),
|
|
140
|
+
...(opts.idempotencyKey ? { idempotency_key: opts.idempotencyKey } : {}),
|
|
141
|
+
...(opts.webhookUrl ? { webhook_url: opts.webhookUrl } : {}),
|
|
142
|
+
},
|
|
143
|
+
signal: opts.signal,
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
status,
|
|
147
|
+
row: body && typeof body === "object" ? body : {},
|
|
148
|
+
...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
|
|
152
|
+
export function transientRetryWaitMs(input) {
|
|
153
|
+
const exp = REVIEW_POLL_BACKOFF_MS * 2 ** Math.max(0, input.failureCount - 1);
|
|
154
|
+
const jitter = 0.5 + (input.random ?? Math.random)() * 0.5;
|
|
155
|
+
const backoff = Math.min(REVIEW_POLL_MAX_INTERVAL_MS, Math.round(exp * jitter));
|
|
156
|
+
const fromHeader = input.retryAfterSeconds != null && Number.isFinite(input.retryAfterSeconds)
|
|
157
|
+
? Math.round(input.retryAfterSeconds * 1000)
|
|
158
|
+
: 0;
|
|
159
|
+
return Math.max(fromHeader, backoff);
|
|
160
|
+
}
|
|
161
|
+
/** Keep the default 2s cadence for a minute, then ramp toward 5s. */
|
|
162
|
+
export function stretchedPollIntervalMs(elapsedMs, intervalMs) {
|
|
163
|
+
if (intervalMs !== REVIEW_POLL_INTERVAL_MS)
|
|
164
|
+
return intervalMs;
|
|
165
|
+
if (elapsedMs < REVIEW_POLL_STRETCH_AFTER_MS)
|
|
166
|
+
return intervalMs;
|
|
167
|
+
const t = Math.min(1, (elapsedMs - REVIEW_POLL_STRETCH_AFTER_MS) / REVIEW_POLL_STRETCH_SPAN_MS);
|
|
168
|
+
return Math.round(intervalMs + t * (REVIEW_POLL_MAX_INTERVAL_MS - intervalMs));
|
|
169
|
+
}
|
|
170
|
+
function sleep(ms, signal) {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
if (signal?.aborted) {
|
|
173
|
+
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
177
|
+
const timer = setTimeout(() => {
|
|
178
|
+
cleanup();
|
|
179
|
+
resolve();
|
|
180
|
+
}, ms);
|
|
181
|
+
const onAbort = () => {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
cleanup();
|
|
184
|
+
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
185
|
+
};
|
|
186
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
export function isTransientReviewPollStatus(status) {
|
|
190
|
+
return status === 408 || status === 429 || status >= 500;
|
|
191
|
+
}
|
|
192
|
+
export function isTransientReviewPollError(err) {
|
|
193
|
+
if (!(err instanceof Error))
|
|
194
|
+
return false;
|
|
195
|
+
if (err.name === "AbortError")
|
|
196
|
+
return false;
|
|
197
|
+
if (err instanceof CommandError)
|
|
198
|
+
return err.code === "api_timeout";
|
|
199
|
+
if (err instanceof TypeError && /fetch|network|connection/i.test(err.message))
|
|
200
|
+
return true;
|
|
201
|
+
return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(err.message);
|
|
202
|
+
}
|
|
203
|
+
export class ReviewPollTimeoutError extends Error {
|
|
204
|
+
jobId;
|
|
205
|
+
lastRow;
|
|
206
|
+
constructor(jobId, lastRow) {
|
|
207
|
+
super(`Timed out waiting for review ${jobId}`);
|
|
208
|
+
this.name = "ReviewPollTimeoutError";
|
|
209
|
+
this.jobId = jobId;
|
|
210
|
+
this.lastRow = lastRow;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export async function pollReview(cfg, jobId, opts = {}) {
|
|
214
|
+
const timeoutMs = opts.timeoutMs ?? REVIEW_POLL_DEFAULT_TIMEOUT_MS;
|
|
215
|
+
const intervalMs = opts.intervalMs ?? REVIEW_POLL_INTERVAL_MS;
|
|
216
|
+
const now = opts.now ?? Date.now;
|
|
217
|
+
const wait = opts.sleep ?? sleep;
|
|
218
|
+
const startedAt = now();
|
|
219
|
+
const deadline = startedAt + timeoutMs;
|
|
220
|
+
let transientFailures = 0;
|
|
221
|
+
let lastRow = { job_id: jobId, status: "queued" };
|
|
222
|
+
const sleepBeforeRetry = async (retryAfterSeconds) => {
|
|
223
|
+
const waitMs = Math.min(transientRetryWaitMs({
|
|
224
|
+
retryAfterSeconds,
|
|
225
|
+
failureCount: transientFailures,
|
|
226
|
+
random: opts.random,
|
|
227
|
+
}), Math.max(0, deadline - now()));
|
|
228
|
+
if (waitMs > 0)
|
|
229
|
+
await wait(waitMs, opts.signal);
|
|
230
|
+
};
|
|
231
|
+
while (now() < deadline) {
|
|
232
|
+
let poll;
|
|
233
|
+
try {
|
|
234
|
+
poll = await apiFetch(cfg, `/api/v1/reviews/${jobId}`, {
|
|
235
|
+
signal: opts.signal,
|
|
236
|
+
timeoutMs: Math.max(1, Math.min(30_000, deadline - now())),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
if (isTransientReviewPollError(err) &&
|
|
241
|
+
transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
|
|
242
|
+
transientFailures += 1;
|
|
243
|
+
opts.onTick?.("retry");
|
|
244
|
+
await sleepBeforeRetry();
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (isCommandErrorCode(err, "api_timeout")) {
|
|
248
|
+
if (opts.propagateTimeout)
|
|
249
|
+
throw err;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (isTransientReviewPollError(err)) {
|
|
253
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
254
|
+
throw new CommandError(`Poll failed: ${detail}`, REVIEW_EXIT.failed, "review_failed");
|
|
255
|
+
}
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
if (poll.status !== 200) {
|
|
259
|
+
if (isTransientReviewPollStatus(poll.status) &&
|
|
260
|
+
transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
|
|
261
|
+
transientFailures += 1;
|
|
262
|
+
opts.onTick?.("retry");
|
|
263
|
+
await sleepBeforeRetry(poll.retryAfterSeconds);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (poll.status === 429) {
|
|
267
|
+
const message = rateLimitedMessage(poll.retryAfterSeconds);
|
|
268
|
+
throw new CommandError(message, REVIEW_EXIT.rate_limited, "rate_limited", {
|
|
269
|
+
retryAfterSeconds: poll.retryAfterSeconds,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
throw new CommandError(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, REVIEW_EXIT.failed, "review_failed");
|
|
273
|
+
}
|
|
274
|
+
transientFailures = 0;
|
|
275
|
+
lastRow =
|
|
276
|
+
poll.body && typeof poll.body === "object"
|
|
277
|
+
? { ...poll.body, job_id: jobId }
|
|
278
|
+
: { job_id: jobId, status: "failed", error: "Invalid review response" };
|
|
279
|
+
if (lastRow.status === "queued" || lastRow.status === "in_progress") {
|
|
280
|
+
opts.onTick?.("progress");
|
|
281
|
+
const waitMs = Math.min(stretchedPollIntervalMs(now() - startedAt, intervalMs), Math.max(0, deadline - now()));
|
|
282
|
+
if (waitMs > 0)
|
|
283
|
+
await wait(waitMs, opts.signal);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
return lastRow;
|
|
287
|
+
}
|
|
288
|
+
throw new ReviewPollTimeoutError(jobId, lastRow);
|
|
289
|
+
}
|
|
@@ -1,20 +1,44 @@
|
|
|
1
|
+
import { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus } from "./review-client.js";
|
|
2
|
+
export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
|
|
3
|
+
/** Same public specialist allowlist as the API contract and migration 117. */
|
|
4
|
+
export declare const VORTEX_ROUTER_MODES: readonly ["off", "standard", "max", "manual"];
|
|
5
|
+
/** Standard/Max fleet caps; Manual is capped at Max. */
|
|
6
|
+
export declare const VORTEX_ROUTER_CAPS: {
|
|
7
|
+
readonly standard: 3;
|
|
8
|
+
readonly max: 5;
|
|
9
|
+
};
|
|
10
|
+
export declare const VORTEX_SPECIALIST_IDS: readonly ["security", "performance", "architecture", "tests", "data", "api", "frontend"];
|
|
11
|
+
type VortexRouterMode = (typeof VORTEX_ROUTER_MODES)[number];
|
|
12
|
+
type VortexSpecialistId = (typeof VORTEX_SPECIALIST_IDS)[number];
|
|
1
13
|
/**
|
|
2
14
|
* Diff base when the user omits args[0]. Prefers origin/HEAD / main / master
|
|
3
15
|
* via discoverTrunk (same as stack create) instead of hardcoding "main".
|
|
4
16
|
*/
|
|
17
|
+
export type ParsedReviewArgs = {
|
|
18
|
+
base?: string;
|
|
19
|
+
head?: string;
|
|
20
|
+
router?: VortexRouterMode;
|
|
21
|
+
specialists: VortexSpecialistId[];
|
|
22
|
+
format: "pretty" | "json";
|
|
23
|
+
wait: boolean;
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
context?: string;
|
|
26
|
+
contextFiles: string[];
|
|
27
|
+
idempotencyKey?: string;
|
|
28
|
+
webhookUrl?: string;
|
|
29
|
+
thread?: string;
|
|
30
|
+
};
|
|
31
|
+
/** Parse the human and machine-safe `review` flags. */
|
|
32
|
+
export declare function parseReviewArgs(args: string[]): ParsedReviewArgs;
|
|
5
33
|
export declare function resolveReviewBase(explicit: string | undefined, cwd?: string): string;
|
|
6
34
|
export type ReviewOptions = {
|
|
7
35
|
signal?: AbortSignal;
|
|
8
36
|
/** When true, show Ctrl+C detach hint during poll. */
|
|
9
37
|
interactive?: boolean;
|
|
38
|
+
/** Test seam; production uses the two-second poll interval. */
|
|
39
|
+
pollIntervalMs?: number;
|
|
10
40
|
};
|
|
11
|
-
/** Max consecutive transient poll failures before surfacing an error. */
|
|
12
|
-
export declare const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
|
|
13
41
|
export declare function reviewStatusHint(jobId: string): string;
|
|
14
42
|
/** Append recovery hint once a job id exists (idempotent). */
|
|
15
43
|
export declare function withReviewJobRecovery(message: string, jobId: string): string;
|
|
16
|
-
/** HTTP statuses worth retrying during review poll (not 401/402/404). */
|
|
17
|
-
export declare function isTransientReviewPollStatus(status: number): boolean;
|
|
18
|
-
/** Network / request-timeout errors worth retrying during review poll. */
|
|
19
|
-
export declare function isTransientReviewPollError(err: unknown): boolean;
|
|
20
44
|
export declare function cmdReview(args: string[], opts?: ReviewOptions): Promise<void>;
|