@mycadet/cli 0.12.10 → 0.13.0-dev.202610171617.9ea363e
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 +13 -0
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
- package/node_modules/@mycadet/service-contracts/src/channels.ts +0 -1
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
- package/package.json +8 -3
- package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
- package/src/__tests__/backup-ops-matching.test.ts +15 -13
- package/src/__tests__/backup-ops.test.ts +105 -0
- package/src/__tests__/backup.test.ts +114 -673
- package/src/__tests__/cloud-login-poll.test.ts +69 -0
- package/src/__tests__/cloud-releases.test.ts +82 -0
- package/src/__tests__/commands-mock.ts +101 -0
- package/src/__tests__/debug.test.ts +17 -91
- package/src/__tests__/devices.test.ts +64 -2
- package/src/__tests__/ensure-binary.test.ts +52 -0
- package/src/__tests__/gateway-token.test.ts +267 -0
- package/src/__tests__/hatch-next-steps.test.ts +38 -0
- package/src/__tests__/host-teleport-docker.test.ts +371 -0
- package/src/__tests__/host-teleport.test.ts +405 -0
- package/src/__tests__/multi-local.test.ts +3 -3
- package/src/__tests__/nginx-ingress.test.ts +587 -36
- package/src/__tests__/orphan-detection.test.ts +1 -1
- package/src/__tests__/paired-lifecycle.test.ts +2 -2
- package/src/__tests__/ps-cloud-status.test.ts +8 -7
- package/src/__tests__/root-backup-docker.test.ts +294 -0
- package/src/__tests__/root-backup.test.ts +527 -0
- package/src/__tests__/root-restore-command.test.ts +147 -0
- package/src/__tests__/root-signing-key.test.ts +50 -0
- package/src/__tests__/setup.test.ts +17 -7
- package/src/__tests__/shell-mode.test.ts +83 -0
- package/src/__tests__/sleep.test.ts +11 -0
- package/src/__tests__/teleport.test.ts +68 -2996
- package/src/__tests__/tunnel-startup-summary.test.ts +110 -0
- package/src/__tests__/tunnel-stop.test.ts +226 -0
- package/src/__tests__/tunnel.test.ts +22 -4
- package/src/__tests__/upgrade-local.test.ts +21 -11
- package/src/__tests__/upgrade-replay-env.test.ts +2 -2
- package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
- package/src/__tests__/wake.test.ts +4 -1
- package/src/__tests__/windows-lifecycle.test.ts +25 -0
- package/src/commands/backup.ts +44 -399
- package/src/commands/client.ts +3 -3
- package/src/commands/completions.ts +0 -2
- package/src/commands/debug.ts +75 -131
- package/src/commands/devices.ts +117 -47
- package/src/commands/gateway/token.ts +77 -23
- package/src/commands/hatch.ts +10 -52
- package/src/commands/pair.ts +9 -3
- package/src/commands/ps.ts +4 -2
- package/src/commands/restore.ts +77 -444
- package/src/commands/retire.ts +2 -3
- package/src/commands/sleep.ts +15 -2
- package/src/commands/teleport.ts +47 -1559
- package/src/commands/tunnel.ts +91 -11
- package/src/commands/upgrade.ts +84 -157
- package/src/commands/wake.ts +1 -1
- package/src/index.ts +0 -4
- package/src/lib/__tests__/http-client-version.test.ts +29 -0
- package/src/lib/__tests__/runtime-url.test.ts +3 -75
- package/src/lib/__tests__/web-dist.test.ts +27 -1
- package/src/lib/assistant-client.ts +1 -1
- package/src/lib/assistant-config.ts +43 -23
- package/src/lib/backup-ops.ts +37 -251
- package/src/lib/cloud-client.ts +10 -13
- package/src/lib/cloud-login-poll.ts +75 -0
- package/src/lib/cloud-login.ts +5 -2
- package/src/lib/cloud-releases.ts +54 -10
- package/src/lib/cloud-values.ts +1 -1
- package/src/lib/confirm-action.ts +2 -2
- package/src/lib/docker.ts +65 -34
- package/src/lib/ensure-binary.ts +65 -6
- package/src/lib/github-repository.ts +2 -0
- package/src/lib/guardian-token.ts +1 -23
- package/src/lib/hatch-local.ts +18 -52
- package/src/lib/hatch-next-steps.ts +15 -2
- package/src/lib/host-teleport.ts +506 -0
- package/src/lib/http-client.ts +25 -0
- package/src/lib/ingress-config.ts +31 -0
- package/src/lib/live-voice/tunnel.test.ts +1 -1
- package/src/lib/live-voice/tunnel.ts +3 -3
- package/src/lib/local.ts +10 -8
- package/src/lib/nginx-ingress.ts +572 -95
- package/src/lib/process.ts +13 -0
- package/src/lib/root-backup.ts +917 -0
- package/src/lib/root-signing-key.ts +31 -0
- package/src/lib/runtime-url.ts +0 -29
- package/src/lib/teleport-backup.ts +3 -16
- package/src/lib/tunnel.ts +87 -34
- package/src/lib/tunnels/cadet.ts +12 -1
- package/src/lib/tunnels/cloudflare.ts +36 -1
- package/src/lib/tunnels/ngrok.ts +16 -2
- package/src/lib/tunnels/process-lifecycle.ts +26 -0
- package/src/lib/tunnels/tailscale.ts +79 -1
- package/src/lib/upgrade-lifecycle.ts +63 -141
- package/src/lib/web-dist.ts +79 -1
- package/src/lib/xdg-log.ts +11 -0
- package/src/__tests__/bundle-staging.test.ts +0 -268
- package/src/__tests__/unpair.test.ts +0 -160
- package/src/commands/rollback.ts +0 -479
- package/src/commands/unpair.ts +0 -122
- package/src/lib/__tests__/job-polling.test.ts +0 -278
- package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
- package/src/lib/bundle-staging.ts +0 -220
- package/src/lib/job-polling.ts +0 -195
- package/src/lib/local-runtime-client.ts +0 -374
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "crypto";
|
|
2
|
-
import {
|
|
3
|
-
copyFileSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
statSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
} from "fs";
|
|
9
|
-
import { join } from "path";
|
|
10
|
-
|
|
11
|
-
import type { AssistantEntry } from "./assistant-config.js";
|
|
12
|
-
import { dockerResourceNames } from "./docker.js";
|
|
13
|
-
import { loopbackSafeFetch } from "./loopback-fetch.js";
|
|
14
|
-
import { exec } from "./step-runner.js";
|
|
15
|
-
|
|
16
|
-
/** Workspace-relative directory the daemon will open for staged restores. */
|
|
17
|
-
export const RESTORE_STAGING_DIRNAME = ".restore-staging";
|
|
18
|
-
|
|
19
|
-
export type RestoreStagingTarget =
|
|
20
|
-
| { kind: "local"; instanceDir: string }
|
|
21
|
-
| { kind: "docker"; assistantId: string };
|
|
22
|
-
|
|
23
|
-
export interface StagedBundle {
|
|
24
|
-
/** Path the daemon should receive in `{ path }`. */
|
|
25
|
-
relativePath: string;
|
|
26
|
-
cleanup: () => Promise<void>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const IMPORT_POLL_INTERVAL_MS = 2_000;
|
|
30
|
-
const IMPORT_POLL_TIMEOUT_MS = 60 * 60 * 1000;
|
|
31
|
-
|
|
32
|
-
export function stagingTargetFromEntry(
|
|
33
|
-
entry: AssistantEntry,
|
|
34
|
-
): RestoreStagingTarget | null {
|
|
35
|
-
const cloud =
|
|
36
|
-
entry.cloud || (entry.project ? "gcp" : entry.sshUser ? "custom" : "local");
|
|
37
|
-
if (cloud === "docker") {
|
|
38
|
-
return { kind: "docker", assistantId: entry.assistantId };
|
|
39
|
-
}
|
|
40
|
-
if (cloud === "local" && entry.resources?.instanceDir) {
|
|
41
|
-
return { kind: "local", instanceDir: entry.resources.instanceDir };
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function localWorkspaceDir(instanceDir: string): string {
|
|
47
|
-
return join(instanceDir, "workspace");
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function formatBundleSizeMb(bytes: number): string {
|
|
51
|
-
return (bytes / (1024 * 1024)).toFixed(2);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Copy a host `.vbundle` into the assistant workspace staging directory
|
|
56
|
-
* so the daemon can stream it without a large HTTP body.
|
|
57
|
-
*/
|
|
58
|
-
export async function stageBundleForRestore(
|
|
59
|
-
target: RestoreStagingTarget,
|
|
60
|
-
hostBundlePath: string,
|
|
61
|
-
): Promise<StagedBundle> {
|
|
62
|
-
const filename = `${randomUUID()}.vbundle`;
|
|
63
|
-
const relativePath = `${RESTORE_STAGING_DIRNAME}/${filename}`;
|
|
64
|
-
|
|
65
|
-
if (target.kind === "local") {
|
|
66
|
-
const stagingDir = join(
|
|
67
|
-
localWorkspaceDir(target.instanceDir),
|
|
68
|
-
RESTORE_STAGING_DIRNAME,
|
|
69
|
-
);
|
|
70
|
-
mkdirSync(stagingDir, { recursive: true });
|
|
71
|
-
const dest = join(stagingDir, filename);
|
|
72
|
-
copyFileSync(hostBundlePath, dest);
|
|
73
|
-
return {
|
|
74
|
-
relativePath,
|
|
75
|
-
cleanup: async () => {
|
|
76
|
-
try {
|
|
77
|
-
unlinkSync(dest);
|
|
78
|
-
} catch {
|
|
79
|
-
// Best-effort: a successful import may have swapped the workspace.
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const container = dockerResourceNames(target.assistantId).assistantContainer;
|
|
86
|
-
const dest = `/workspace/${relativePath}`;
|
|
87
|
-
await exec("docker", [
|
|
88
|
-
"exec",
|
|
89
|
-
container,
|
|
90
|
-
"mkdir",
|
|
91
|
-
"-p",
|
|
92
|
-
`/workspace/${RESTORE_STAGING_DIRNAME}`,
|
|
93
|
-
]);
|
|
94
|
-
await exec("docker", ["cp", hostBundlePath, `${container}:${dest}`]);
|
|
95
|
-
return {
|
|
96
|
-
relativePath,
|
|
97
|
-
cleanup: async () => {
|
|
98
|
-
try {
|
|
99
|
-
await exec("docker", ["exec", container, "rm", "-f", dest]);
|
|
100
|
-
} catch {
|
|
101
|
-
// Best-effort: a successful import may have swapped the workspace.
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export async function preflightStagedBundle(
|
|
108
|
-
runtimeUrl: string,
|
|
109
|
-
accessToken: string,
|
|
110
|
-
relativePath: string,
|
|
111
|
-
): Promise<Response> {
|
|
112
|
-
return loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import-preflight`, {
|
|
113
|
-
method: "POST",
|
|
114
|
-
headers: {
|
|
115
|
-
Authorization: `Bearer ${accessToken}`,
|
|
116
|
-
"Content-Type": "application/json",
|
|
117
|
-
},
|
|
118
|
-
body: JSON.stringify({ path: relativePath }),
|
|
119
|
-
signal: AbortSignal.timeout(120_000),
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Import a staged bundle. The gateway JSON import path returns 202 and
|
|
125
|
-
* requires status polling; a direct daemon response of 200 is accepted too.
|
|
126
|
-
*/
|
|
127
|
-
export async function importStagedBundle(
|
|
128
|
-
runtimeUrl: string,
|
|
129
|
-
accessToken: string,
|
|
130
|
-
relativePath: string,
|
|
131
|
-
): Promise<Response> {
|
|
132
|
-
const postImport = () =>
|
|
133
|
-
loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import`, {
|
|
134
|
-
method: "POST",
|
|
135
|
-
headers: {
|
|
136
|
-
Authorization: `Bearer ${accessToken}`,
|
|
137
|
-
"Content-Type": "application/json",
|
|
138
|
-
},
|
|
139
|
-
body: JSON.stringify({ path: relativePath }),
|
|
140
|
-
signal: AbortSignal.timeout(120_000),
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const response = await postImport();
|
|
144
|
-
|
|
145
|
-
if (response.status === 202) {
|
|
146
|
-
const accepted = (await response.json()) as { job_id?: string };
|
|
147
|
-
if (!accepted.job_id) {
|
|
148
|
-
return new Response(
|
|
149
|
-
JSON.stringify({ error: "Import did not return a job_id" }),
|
|
150
|
-
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
return pollImportJob(runtimeUrl, accessToken, accepted.job_id);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return response;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function pollImportJob(
|
|
160
|
-
runtimeUrl: string,
|
|
161
|
-
accessToken: string,
|
|
162
|
-
jobId: string,
|
|
163
|
-
): Promise<Response> {
|
|
164
|
-
const deadline = Date.now() + IMPORT_POLL_TIMEOUT_MS;
|
|
165
|
-
while (Date.now() < deadline) {
|
|
166
|
-
const statusResponse = await loopbackSafeFetch(
|
|
167
|
-
`${runtimeUrl}/v1/migrations/import/${jobId}/status`,
|
|
168
|
-
{
|
|
169
|
-
headers: {
|
|
170
|
-
Authorization: `Bearer ${accessToken}`,
|
|
171
|
-
},
|
|
172
|
-
signal: AbortSignal.timeout(30_000),
|
|
173
|
-
},
|
|
174
|
-
);
|
|
175
|
-
if (statusResponse.status === 404) {
|
|
176
|
-
return new Response(
|
|
177
|
-
JSON.stringify({ error: `Unknown import job: ${jobId}` }),
|
|
178
|
-
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
if (!statusResponse.ok) {
|
|
182
|
-
return statusResponse;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const job = (await statusResponse.json()) as {
|
|
186
|
-
status?: string;
|
|
187
|
-
error?: string;
|
|
188
|
-
result?: unknown;
|
|
189
|
-
};
|
|
190
|
-
if (job.status === "complete") {
|
|
191
|
-
return new Response(JSON.stringify(job.result ?? { success: true }), {
|
|
192
|
-
status: 200,
|
|
193
|
-
headers: { "Content-Type": "application/json" },
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
if (job.status === "failed") {
|
|
197
|
-
return new Response(
|
|
198
|
-
JSON.stringify({
|
|
199
|
-
success: false,
|
|
200
|
-
message: job.error ?? "Import failed",
|
|
201
|
-
}),
|
|
202
|
-
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_INTERVAL_MS));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
return new Response(
|
|
210
|
-
JSON.stringify({ error: "Import timed out after 60 minutes" }),
|
|
211
|
-
{ status: 504, headers: { "Content-Type": "application/json" } },
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function bundleFileSizeBytes(path: string): number {
|
|
216
|
-
if (!existsSync(path)) {
|
|
217
|
-
return 0;
|
|
218
|
-
}
|
|
219
|
-
return statSync(path).size;
|
|
220
|
-
}
|
package/src/lib/job-polling.ts
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import type { UnifiedJobStatus } from "./cloud-client.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Terminal status returned by {@link pollJobUntilDone}. Callers decide
|
|
5
|
-
* whether to treat `failed` as a fatal error or retry logic concern.
|
|
6
|
-
*/
|
|
7
|
-
export type TerminalJobStatus = Extract<
|
|
8
|
-
UnifiedJobStatus,
|
|
9
|
-
{ status: "complete" | "failed" }
|
|
10
|
-
>;
|
|
11
|
-
|
|
12
|
-
export interface PollJobUntilDoneOptions {
|
|
13
|
-
/** Async producer that returns the latest job status. */
|
|
14
|
-
poll: () => Promise<UnifiedJobStatus>;
|
|
15
|
-
/** Sleep between successive polls. Defaults to 2_000 ms. */
|
|
16
|
-
intervalMs?: number;
|
|
17
|
-
/** Maximum wall-clock time to wait. Defaults to 60 minutes. */
|
|
18
|
-
timeoutMs?: number;
|
|
19
|
-
/** Human-readable label used in the timeout error message (e.g. "export job"). */
|
|
20
|
-
label: string;
|
|
21
|
-
/**
|
|
22
|
-
* Maximum consecutive transient (retryable) poll errors tolerated before
|
|
23
|
-
* the last error is propagated. Transient errors (5xx / network) between
|
|
24
|
-
* successful polls reset the counter. Defaults to 5.
|
|
25
|
-
*/
|
|
26
|
-
maxTransientErrors?: number;
|
|
27
|
-
/**
|
|
28
|
-
* Optional async hook invoked when `poll()` throws an error containing a
|
|
29
|
-
* `401` HTTP status. The callback is expected to refresh whatever
|
|
30
|
-
* credential the poll closure reads (e.g. re-lease a guardian token), then
|
|
31
|
-
* return. The polling loop will retry the poll after the callback resolves
|
|
32
|
-
* instead of propagating the 401.
|
|
33
|
-
*
|
|
34
|
-
* Used by long-running migrations where the cached access token may expire
|
|
35
|
-
* mid-poll. Without this hook, 4xx errors (except 429) are permanent and
|
|
36
|
-
* would abandon a migration that's still running on the server.
|
|
37
|
-
*/
|
|
38
|
-
refreshOn401?: () => Promise<void>;
|
|
39
|
-
/**
|
|
40
|
-
* Maximum consecutive 401 refreshes tolerated before the last 401 is
|
|
41
|
-
* propagated. Tracked separately from {@link maxTransientErrors} because
|
|
42
|
-
* a persistent 401 after a refresh usually means the underlying credential
|
|
43
|
-
* is revoked, not a transient network issue. Defaults to 3.
|
|
44
|
-
*/
|
|
45
|
-
maxAuthRefreshes?: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const DEFAULT_INTERVAL_MS = 2_000;
|
|
49
|
-
// Matches the server-side runtime migration window: the GCS upload PUT and
|
|
50
|
-
// the import-URL fetch in assistant/src/runtime/routes/migration-routes.ts
|
|
51
|
-
// use AbortSignal.timeout(60 * 60 * 1000), so a shorter CLI poll cap would
|
|
52
|
-
// abort a job that's still legitimately in progress on the server.
|
|
53
|
-
const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
54
|
-
const DEFAULT_MAX_TRANSIENT_ERRORS = 5;
|
|
55
|
-
const DEFAULT_MAX_AUTH_REFRESHES = 3;
|
|
56
|
-
|
|
57
|
-
function is401Error(err: unknown): boolean {
|
|
58
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
-
return /\b401\b/.test(msg);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function sleep(ms: number): Promise<void> {
|
|
63
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Heuristic classification used by {@link pollJobUntilDone} to decide whether
|
|
68
|
-
* to retry a failed poll.
|
|
69
|
-
*
|
|
70
|
-
* - 5xx responses and unclassifiable network-style errors (fetch failed,
|
|
71
|
-
* ECONNRESET, etc.) are treated as transient.
|
|
72
|
-
* - 4xx responses are treated as permanent, except 429 (rate limited) which is
|
|
73
|
-
* transient.
|
|
74
|
-
* - "not found" errors are permanent — they indicate the job id is wrong and
|
|
75
|
-
* retrying won't help.
|
|
76
|
-
*
|
|
77
|
-
* The poll helpers (`platformPollJobStatus`, `localRuntimePollJobStatus`)
|
|
78
|
-
* raise errors whose message contains the HTTP status (e.g. `"Local job
|
|
79
|
-
* status check failed: 503 Service Unavailable"`), so we parse that out when
|
|
80
|
-
* available and default to "retry" when unsure.
|
|
81
|
-
*/
|
|
82
|
-
function isTransientPollError(err: unknown): boolean {
|
|
83
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
84
|
-
|
|
85
|
-
if (msg.includes("not found")) return false;
|
|
86
|
-
|
|
87
|
-
const match = msg.match(/(?:status check failed|failed)[^\d]*(\d{3})/i);
|
|
88
|
-
if (match) {
|
|
89
|
-
const code = parseInt(match[1], 10);
|
|
90
|
-
if (code === 429) return true;
|
|
91
|
-
if (code >= 400 && code < 500) return false;
|
|
92
|
-
if (code >= 500) return true;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Unclassifiable (e.g. "fetch failed", ECONNRESET) — treat as transient so
|
|
96
|
-
// a single network hiccup doesn't abort a long-running migration.
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Poll `options.poll` until it returns a terminal status (`complete` or
|
|
102
|
-
* `failed`), or until `timeoutMs` elapses.
|
|
103
|
-
*
|
|
104
|
-
* On terminal status, returns the status object — including the `failed`
|
|
105
|
-
* case. The caller decides how to treat a failed terminal status (e.g.
|
|
106
|
-
* print the `error` field and exit). Timeouts throw.
|
|
107
|
-
*
|
|
108
|
-
* Transient errors raised by `poll()` (5xx, network hiccups, rate-limits) are
|
|
109
|
-
* retried up to `maxTransientErrors` times before the last error propagates,
|
|
110
|
-
* matching the pre-rewrite migration-export polling loop's behavior so a
|
|
111
|
-
* single flaky poll doesn't abort a migration that may still be running.
|
|
112
|
-
*/
|
|
113
|
-
export async function pollJobUntilDone(
|
|
114
|
-
options: PollJobUntilDoneOptions,
|
|
115
|
-
): Promise<TerminalJobStatus> {
|
|
116
|
-
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
117
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
118
|
-
const maxTransientErrors =
|
|
119
|
-
options.maxTransientErrors ?? DEFAULT_MAX_TRANSIENT_ERRORS;
|
|
120
|
-
const maxAuthRefreshes =
|
|
121
|
-
options.maxAuthRefreshes ?? DEFAULT_MAX_AUTH_REFRESHES;
|
|
122
|
-
const deadline = Date.now() + timeoutMs;
|
|
123
|
-
|
|
124
|
-
let consecutiveTransientErrors = 0;
|
|
125
|
-
let consecutiveAuthRefreshes = 0;
|
|
126
|
-
|
|
127
|
-
// First poll happens immediately so fast-path completions don't wait
|
|
128
|
-
// one interval before returning.
|
|
129
|
-
while (true) {
|
|
130
|
-
let status: UnifiedJobStatus;
|
|
131
|
-
try {
|
|
132
|
-
status = await options.poll();
|
|
133
|
-
consecutiveTransientErrors = 0;
|
|
134
|
-
consecutiveAuthRefreshes = 0;
|
|
135
|
-
} catch (err) {
|
|
136
|
-
// 401 Unauthorized takes precedence over the generic transient
|
|
137
|
-
// classifier: when a refresh callback is registered, a long-running
|
|
138
|
-
// poll loop can re-lease its credential and keep going instead of
|
|
139
|
-
// abandoning a migration that's still running on the server.
|
|
140
|
-
if (options.refreshOn401 && is401Error(err)) {
|
|
141
|
-
consecutiveAuthRefreshes += 1;
|
|
142
|
-
if (consecutiveAuthRefreshes > maxAuthRefreshes) {
|
|
143
|
-
throw err;
|
|
144
|
-
}
|
|
145
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
-
console.warn(
|
|
147
|
-
`${options.label} polling got 401, refreshing auth and retrying... (${msg})`,
|
|
148
|
-
);
|
|
149
|
-
await options.refreshOn401();
|
|
150
|
-
if (Date.now() >= deadline) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`Timed out waiting for ${options.label} after ${Math.round(
|
|
153
|
-
timeoutMs / 1000,
|
|
154
|
-
)}s`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
await sleep(intervalMs);
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (!isTransientPollError(err)) {
|
|
162
|
-
throw err;
|
|
163
|
-
}
|
|
164
|
-
consecutiveTransientErrors += 1;
|
|
165
|
-
if (consecutiveTransientErrors > maxTransientErrors) {
|
|
166
|
-
throw err;
|
|
167
|
-
}
|
|
168
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
169
|
-
console.warn(`${options.label} polling failed: ${msg}. Retrying...`);
|
|
170
|
-
if (Date.now() >= deadline) {
|
|
171
|
-
throw new Error(
|
|
172
|
-
`Timed out waiting for ${options.label} after ${Math.round(
|
|
173
|
-
timeoutMs / 1000,
|
|
174
|
-
)}s`,
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
await sleep(intervalMs);
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (status.status === "complete" || status.status === "failed") {
|
|
182
|
-
return status;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (Date.now() >= deadline) {
|
|
186
|
-
throw new Error(
|
|
187
|
-
`Timed out waiting for ${options.label} after ${Math.round(
|
|
188
|
-
timeoutMs / 1000,
|
|
189
|
-
)}s`,
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
await sleep(intervalMs);
|
|
194
|
-
}
|
|
195
|
-
}
|