omnispace 0.5.1 → 0.5.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.
- package/dist/api.js +31 -7
- package/dist/pull.js +20 -31
- package/dist/retry.js +38 -0
- package/dist/sync.js +27 -13
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -1,11 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { isRetryableStatus, RetryableError, warnRetry, withRetry } from "./retry.js";
|
|
2
|
+
/**
|
|
3
|
+
* POST to the Omni server. `token` is the CLI bearer token from ~/.omni/credentials.json, if any.
|
|
4
|
+
* With `retryDelaysMs`, a 5xx, a 429 or a dropped connection is tried again; only pass it for calls
|
|
5
|
+
* that are safe to repeat. A dropped connection that is not retried comes back as status 0.
|
|
6
|
+
*/
|
|
7
|
+
export async function callApi(apiUrl, token, path, body, opts = {}) {
|
|
3
8
|
const headers = { "content-type": "application/json" };
|
|
4
9
|
if (token)
|
|
5
10
|
headers.authorization = `Bearer ${token}`;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
async function once() {
|
|
12
|
+
let response;
|
|
13
|
+
try {
|
|
14
|
+
response = await fetch(`${apiUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
throw new RetryableError(`conexão com o servidor caiu (${err.message})`);
|
|
18
|
+
}
|
|
19
|
+
const json = (await response.json().catch(() => ({})));
|
|
20
|
+
if (response.ok)
|
|
21
|
+
return { ok: true, status: response.status, ...json };
|
|
22
|
+
const result = { ok: false, status: response.status, error: json.error ?? `HTTP ${response.status}`, ...json };
|
|
23
|
+
if (isRetryableStatus(response.status))
|
|
24
|
+
throw new RetryableError(result.error, result);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return await withRetry(once, opts.retryDelaysMs ?? [], warnRetry);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (!(err instanceof RetryableError))
|
|
32
|
+
throw err;
|
|
33
|
+
return err.result ?? { ok: false, status: 0, error: err.message };
|
|
34
|
+
}
|
|
11
35
|
}
|
package/dist/pull.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
6
|
import { callApi } from "./api.js";
|
|
7
|
+
import { isRetryableStatus, RETRY_DELAYS_MS, RetryableError, withRetry } from "./retry.js";
|
|
7
8
|
import { buildManifest, hashFile, LOCAL_BACKUPS_DIR, LOCAL_TMP_DIR } from "./include.js";
|
|
8
9
|
import { mergeText } from "./merge.js";
|
|
9
10
|
import { addConflicts } from "./conflicts.js";
|
|
@@ -70,38 +71,28 @@ async function cachedObjects(tmpDir) {
|
|
|
70
71
|
}
|
|
71
72
|
return ok;
|
|
72
73
|
}
|
|
73
|
-
class RetryableError extends Error {
|
|
74
|
-
}
|
|
75
74
|
/**
|
|
76
|
-
* A 5xx, a 429 or a dropped connection is
|
|
77
|
-
*
|
|
78
|
-
* backoff before giving up; a 4xx or a wrong hash is not, since trying again cannot fix it.
|
|
75
|
+
* A 5xx, a 429 or a dropped connection is retried (see retry.ts), quietly: downloads run in
|
|
76
|
+
* parallel under a progress line. A 4xx or a wrong hash is not, since trying again cannot fix it.
|
|
79
77
|
*/
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
function fetchBytes(url, hash, retryDelaysMs) {
|
|
79
|
+
return withRetry(async () => {
|
|
80
|
+
let response;
|
|
82
81
|
try {
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
response = await fetch(url);
|
|
86
|
-
}
|
|
87
|
-
catch (err) {
|
|
88
|
-
throw new RetryableError(`conexão caiu ao baixar ${hash.slice(0, 12)} (${err.message})`);
|
|
89
|
-
}
|
|
90
|
-
if (response.status >= 500 || response.status === 429) {
|
|
91
|
-
throw new RetryableError(`HTTP ${response.status} ao baixar ${hash.slice(0, 12)}`);
|
|
92
|
-
}
|
|
93
|
-
if (!response.ok)
|
|
94
|
-
throw new Error(`HTTP ${response.status} ao baixar ${hash.slice(0, 12)}`);
|
|
95
|
-
return new Uint8Array(await response.arrayBuffer());
|
|
82
|
+
response = await fetch(url);
|
|
96
83
|
}
|
|
97
84
|
catch (err) {
|
|
98
|
-
|
|
99
|
-
throw err;
|
|
100
|
-
await new Promise((r) => setTimeout(r, retryDelaysMs[attempt]));
|
|
85
|
+
throw new RetryableError(`conexão caiu ao baixar ${hash.slice(0, 12)} (${err.message})`);
|
|
101
86
|
}
|
|
102
|
-
|
|
87
|
+
const message = `HTTP ${response.status} ao baixar ${hash.slice(0, 12)}`;
|
|
88
|
+
if (isRetryableStatus(response.status))
|
|
89
|
+
throw new RetryableError(message);
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
throw new Error(message);
|
|
92
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
93
|
+
}, retryDelaysMs);
|
|
103
94
|
}
|
|
104
|
-
export async function download(url, hash, tmpDir, retryDelaysMs =
|
|
95
|
+
export async function download(url, hash, tmpDir, retryDelaysMs = RETRY_DELAYS_MS) {
|
|
105
96
|
const bytes = await fetchBytes(url, hash, retryDelaysMs);
|
|
106
97
|
if (sha256(bytes) !== hash)
|
|
107
98
|
throw new Error(`conteúdo baixado não confere com o hash ${hash.slice(0, 12)}`);
|
|
@@ -240,7 +231,7 @@ async function pullInto(root, config, token, opts = {}) {
|
|
|
240
231
|
}
|
|
241
232
|
/** Tells the server this folder is on `version` (what the /home machine panel shows). */
|
|
242
233
|
async function ack(config, token, version) {
|
|
243
|
-
const res = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version });
|
|
234
|
+
const res = await callApi(config.apiUrl, token, "/api/cli/sync/ack", { spaceId: config.spaceId, version }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
244
235
|
if (!res.ok)
|
|
245
236
|
console.error(`(aviso) A pasta está na versão ${version}, mas o servidor não registrou: ${res.error}`);
|
|
246
237
|
}
|
|
@@ -325,11 +316,7 @@ async function pullWith(root, config, token, opts, asker) {
|
|
|
325
316
|
const tmpDir = toAbs(root, LOCAL_TMP_DIR);
|
|
326
317
|
const cached = await cachedObjects(tmpDir);
|
|
327
318
|
const have = [...new Set([...manifest.files.map((f) => f.hash), ...cached])];
|
|
328
|
-
const pull = await callApi(config.apiUrl, token, "/api/cli/sync/pull", {
|
|
329
|
-
spaceId: config.spaceId,
|
|
330
|
-
baseVersion: config.baseVersion,
|
|
331
|
-
have,
|
|
332
|
-
});
|
|
319
|
+
const pull = await callApi(config.apiUrl, token, "/api/cli/sync/pull", { spaceId: config.spaceId, baseVersion: config.baseVersion, have }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
333
320
|
if (!pull.ok) {
|
|
334
321
|
console.error(`Falha ao consultar o servidor: ${pull.error}`);
|
|
335
322
|
return 1;
|
|
@@ -553,6 +540,8 @@ export async function cloneSpace(code, target) {
|
|
|
553
540
|
else {
|
|
554
541
|
const apiUrl = defaultApiUrl();
|
|
555
542
|
console.log(`\n→ Usando o código ${code}...`);
|
|
543
|
+
// Never retried: the code is single use, so a second try after a lost answer would only say
|
|
544
|
+
// "código já usado". The staging folder above is what makes the rest resumable.
|
|
556
545
|
const claim = await callApi(apiUrl, await loadToken(apiUrl), "/api/cli/clone", {
|
|
557
546
|
code,
|
|
558
547
|
hostname: machineHostname(),
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry for calls that can safely run twice. A 5xx, a 429 or a dropped connection is usually the
|
|
3
|
+
* server or the Storage having a bad second: the first real clone (Novo Dash, 2026-09-23) stopped
|
|
4
|
+
* at 995/1020 on a single 504, and the closing test of the Proof of Core Sync (2026-09-25) had a
|
|
5
|
+
* `prepare` fail once with "Falha ao gerar URL de envio". A 4xx is never retried, since trying
|
|
6
|
+
* again cannot fix it. Calls that are not safe to repeat (commit, clone) never go through here.
|
|
7
|
+
*/
|
|
8
|
+
/** Waits before the 2nd, 3rd and 4th attempts. */
|
|
9
|
+
export const RETRY_DELAYS_MS = [1000, 3000, 8000];
|
|
10
|
+
/** A failure worth trying again. `result` keeps the last server answer, if there was one. */
|
|
11
|
+
export class RetryableError extends Error {
|
|
12
|
+
result;
|
|
13
|
+
constructor(message, result) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.result = result;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function isRetryableStatus(status) {
|
|
19
|
+
return status >= 500 || status === 429;
|
|
20
|
+
}
|
|
21
|
+
/** Warning shown before each new attempt, on stderr so it never mixes with command output. */
|
|
22
|
+
export function warnRetry(err, waitMs) {
|
|
23
|
+
console.error(` (servidor instável: ${err.message}; tentando de novo em ${waitMs / 1000}s)`);
|
|
24
|
+
}
|
|
25
|
+
/** Runs `fn`, repeating it after each RetryableError with the given delays; the last one is thrown. */
|
|
26
|
+
export async function withRetry(fn, delaysMs, onRetry) {
|
|
27
|
+
for (let attempt = 0;; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
return await fn();
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (!(err instanceof RetryableError) || attempt >= delaysMs.length)
|
|
33
|
+
throw err;
|
|
34
|
+
onRetry?.(err, delaysMs[attempt]);
|
|
35
|
+
await new Promise((r) => setTimeout(r, delaysMs[attempt]));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/sync.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadToken, readSpaceConfig, writeSpaceConfig } from "./config.js";
|
|
|
7
7
|
import { callApi } from "./api.js";
|
|
8
8
|
import { unresolvedConflicts } from "./conflicts.js";
|
|
9
9
|
import { withLock } from "./lock.js";
|
|
10
|
+
import { isRetryableStatus, RETRY_DELAYS_MS, RetryableError, warnRetry, withRetry } from "./retry.js";
|
|
10
11
|
async function confirm(question) {
|
|
11
12
|
if (!process.stdin.isTTY)
|
|
12
13
|
return false;
|
|
@@ -19,19 +20,31 @@ async function confirm(question) {
|
|
|
19
20
|
* Uploads one object straight to Storage through the signed URL (never through the Omni server).
|
|
20
21
|
* The bytes are hashed again right before sending: a file edited after the manifest was built would
|
|
21
22
|
* otherwise land under the old hash, and every later version would reuse that wrong object.
|
|
23
|
+
* A 5xx, a 429 or a dropped connection is sent again: the PUT is an upsert of the same bytes, and
|
|
24
|
+
* the commit checks every object anyway.
|
|
22
25
|
*/
|
|
23
|
-
export async function uploadObject(url, abs, path, hash) {
|
|
26
|
+
export async function uploadObject(url, abs, path, hash, retryDelaysMs = RETRY_DELAYS_MS) {
|
|
24
27
|
const bytes = await readFile(abs);
|
|
25
28
|
if (createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
26
29
|
throw new Error(`o arquivo ${path} mudou durante o envio`);
|
|
27
30
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
await withRetry(async () => {
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await fetch(url, {
|
|
35
|
+
method: "PUT",
|
|
36
|
+
headers: { "content-type": "application/octet-stream", "x-upsert": "true" },
|
|
37
|
+
body: bytes,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new RetryableError(`conexão caiu ao enviar ${path} (${err.message})`);
|
|
42
|
+
}
|
|
43
|
+
if (response.ok)
|
|
44
|
+
return;
|
|
45
|
+
const message = `HTTP ${response.status} ao enviar ${path} ${await response.text().catch(() => "")}`.trim();
|
|
46
|
+
throw isRetryableStatus(response.status) ? new RetryableError(message) : new Error(message);
|
|
47
|
+
}, retryDelaysMs, warnRetry);
|
|
35
48
|
}
|
|
36
49
|
/**
|
|
37
50
|
* `omni sync --push` (Proof of Core Sync, fatia 2; docs/plan/adr/002-protocolo-sync.md).
|
|
@@ -83,11 +96,7 @@ async function pushLocked(root, opts) {
|
|
|
83
96
|
}
|
|
84
97
|
printSyncPreview(manifest);
|
|
85
98
|
const files = manifest.files.map(({ path, hash, size }) => ({ path, hash, size }));
|
|
86
|
-
const prepare = await callApi(config.apiUrl, token, "/api/cli/sync/prepare", {
|
|
87
|
-
spaceId: config.spaceId,
|
|
88
|
-
sessionId: config.spaceId ? undefined : config.sessionId,
|
|
89
|
-
files,
|
|
90
|
-
});
|
|
99
|
+
const prepare = await callApi(config.apiUrl, token, "/api/cli/sync/prepare", { spaceId: config.spaceId, sessionId: config.spaceId ? undefined : config.sessionId, files }, { retryDelaysMs: RETRY_DELAYS_MS });
|
|
91
100
|
if (!prepare.ok) {
|
|
92
101
|
console.error(`Falha ao preparar o envio: ${prepare.error}`);
|
|
93
102
|
return 1;
|
|
@@ -144,11 +153,16 @@ async function pushLocked(root, opts) {
|
|
|
144
153
|
if (uploads.length > 0)
|
|
145
154
|
console.log(process.stdout.isTTY ? "" : ` Enviados ${done}/${uploads.length}`);
|
|
146
155
|
console.log(`→ Publicando a versão ${head + 1} (o servidor confere ${files.length} arquivos, pode levar alguns segundos)...`);
|
|
156
|
+
// Never retried here: if the server published and only the answer got lost, a second commit would
|
|
157
|
+
// come back as "stale". The next run recovers instead (prepare sees `unchanged` and adopts the head).
|
|
147
158
|
const commit = await callApi(config.apiUrl, token, "/api/cli/sync/commit", { spaceId, parentVersion: head, files });
|
|
148
159
|
if (!commit.ok) {
|
|
149
160
|
console.error(`Falha ao publicar a versão: ${commit.error}`);
|
|
150
161
|
if (commit.stale)
|
|
151
162
|
console.error("Rode omni sync --pull e depois envie de novo.");
|
|
163
|
+
else if (commit.status === 0 || isRetryableStatus(commit.status)) {
|
|
164
|
+
console.error("Rode de novo: se a versão chegou a ser publicada, o próximo envio reconhece e não duplica.");
|
|
165
|
+
}
|
|
152
166
|
return 1;
|
|
153
167
|
}
|
|
154
168
|
const version = commit.version;
|