agentel 0.2.0 → 0.2.3
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 +161 -63
- package/agentlog-spec.md +42 -35
- package/bin/agentlog-recall.js +2 -0
- package/bin/agentlog.js +12 -0
- package/docs/code-reference.md +120 -34
- package/docs/history-source-handling.md +236 -81
- package/docs/release.md +8 -8
- package/package.json +5 -4
- package/src/archive.js +279 -20
- package/src/cli.js +3457 -511
- package/src/config.js +42 -1
- package/src/doctor.js +167 -10
- package/src/importers/gemini.js +369 -7
- package/src/importers.js +1893 -133
- package/src/mcp.js +4 -1
- package/src/parser-versions.js +37 -22
- package/src/paths.js +4 -2
- package/src/redaction.js +140 -17
- package/src/search.js +671 -52
- package/src/supervisor.js +206 -57
- package/src/sync.js +459 -12
package/src/sync.js
CHANGED
|
@@ -72,6 +72,56 @@ async function snapshotArchive(options = {}, env = process.env) {
|
|
|
72
72
|
return syncArchive({ ...options, snapshot: true, force: true }, env);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
async function replaceRemoteArchive(options = {}, env = process.env) {
|
|
76
|
+
const cfg = loadConfig(env);
|
|
77
|
+
const mode = normalizeSyncMode(options["sync-mode"] || options.syncMode || options.mode || cfg.sync?.mode || "upload");
|
|
78
|
+
if (mode !== "upload") {
|
|
79
|
+
throw new Error(`remote sync mode "${mode}" is planned but not implemented yet; use upload mode to replace this device namespace`);
|
|
80
|
+
}
|
|
81
|
+
const lock = acquireSyncLock(env);
|
|
82
|
+
try {
|
|
83
|
+
const target = remoteTargetFromFlags(options, cfg, env);
|
|
84
|
+
if (target.protocol === "file") return replaceRemoteDirectory(target, options, env);
|
|
85
|
+
return replaceRemoteS3(target, options, env);
|
|
86
|
+
} finally {
|
|
87
|
+
releaseSyncLock(lock);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function wipeRemoteArchive(options = {}, env = process.env) {
|
|
92
|
+
const lock = acquireSyncLock(env);
|
|
93
|
+
try {
|
|
94
|
+
const cfg = loadConfig(env);
|
|
95
|
+
const target = remoteTargetFromFlags(options, cfg, env);
|
|
96
|
+
if (target.protocol === "file") return wipeRemoteDirectory(target, options, env);
|
|
97
|
+
return wipeRemoteS3(target, options, env);
|
|
98
|
+
} finally {
|
|
99
|
+
releaseSyncLock(lock);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function listRemoteSnapshots(options = {}, env = process.env) {
|
|
104
|
+
const cfg = loadConfig(env);
|
|
105
|
+
const target = remoteTargetFromFlags(options, cfg, env);
|
|
106
|
+
const prefix = remoteSnapshotsPrefix(target);
|
|
107
|
+
if (target.protocol === "file") {
|
|
108
|
+
const root = path.join(target.root, prefix);
|
|
109
|
+
const objects = listDirectoryObjects(root).map((file) => {
|
|
110
|
+
const stat = safeStat(file);
|
|
111
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
112
|
+
return {
|
|
113
|
+
key: [prefix, relative].filter(Boolean).join("/"),
|
|
114
|
+
size: stat?.size || 0,
|
|
115
|
+
lastModified: stat ? stat.mtime.toISOString() : ""
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
return snapshotListSummary(target, prefix, objects);
|
|
119
|
+
}
|
|
120
|
+
const listPrefix = prefix ? `${prefix}/` : "";
|
|
121
|
+
const objects = await listS3Objects(target, listPrefix, options);
|
|
122
|
+
return snapshotListSummary(target, prefix, objects);
|
|
123
|
+
}
|
|
124
|
+
|
|
75
125
|
async function syncArchiveIfConfigured(env = process.env, options = {}) {
|
|
76
126
|
const cfg = loadConfig(env);
|
|
77
127
|
if (!hasRemoteTarget(cfg, env)) return { configured: false, scanned: 0, uploaded: 0, skipped: 0, errors: [] };
|
|
@@ -150,12 +200,19 @@ async function syncArchiveToS3(target, options = {}, env = process.env) {
|
|
|
150
200
|
const objectPrefix = remoteObjectPrefix(target, cfg, options);
|
|
151
201
|
const localObjects = listLocalArchiveObjects(env, objectPrefix);
|
|
152
202
|
const state = loadSyncState(env);
|
|
153
|
-
|
|
203
|
+
reportSyncEvent(options, {
|
|
204
|
+
current: 0,
|
|
205
|
+
total: 0,
|
|
206
|
+
message: "listing remote objects",
|
|
207
|
+
path: objectPrefix
|
|
208
|
+
});
|
|
209
|
+
const remoteObjects = await listS3Objects(target, objectPrefix, options);
|
|
154
210
|
const remoteByKey = new Map(remoteObjects.map((item) => [item.key, item]));
|
|
155
211
|
const summary = {
|
|
156
212
|
target: `${target.type}:${target.bucket}/${objectPrefix}`,
|
|
157
213
|
mode: "upload",
|
|
158
214
|
device: deviceLabel(cfg),
|
|
215
|
+
prefix: objectPrefix,
|
|
159
216
|
snapshot: Boolean(options.snapshot),
|
|
160
217
|
snapshotName: options.snapshot ? snapshotName(options) : "",
|
|
161
218
|
scanned: localObjects.length,
|
|
@@ -172,7 +229,7 @@ async function syncArchiveToS3(target, options = {}, env = process.env) {
|
|
|
172
229
|
const stateEntry = state.objects[object.key];
|
|
173
230
|
const unchangedRemote = remote && Number(remote.size) === object.size;
|
|
174
231
|
const unchangedState = stateEntry && stateEntry.fingerprint === object.fingerprint && unchangedRemote;
|
|
175
|
-
if (unchangedState ||
|
|
232
|
+
if (!options.force && (unchangedState || unchangedRemote)) {
|
|
176
233
|
summary.skipped++;
|
|
177
234
|
reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
|
|
178
235
|
continue;
|
|
@@ -183,7 +240,7 @@ async function syncArchiveToS3(target, options = {}, env = process.env) {
|
|
|
183
240
|
continue;
|
|
184
241
|
}
|
|
185
242
|
try {
|
|
186
|
-
const upload = await
|
|
243
|
+
const upload = await putS3FileObjectWithRetry(target, object, options);
|
|
187
244
|
summary.retried += upload.retried;
|
|
188
245
|
state.objects[object.key] = {
|
|
189
246
|
fingerprint: object.fingerprint,
|
|
@@ -210,6 +267,7 @@ function syncArchiveToDirectory(target, options = {}, env = process.env) {
|
|
|
210
267
|
target: pathToFileURL(root).href,
|
|
211
268
|
mode: "upload",
|
|
212
269
|
device: deviceLabel(cfg),
|
|
270
|
+
prefix: objectPrefix,
|
|
213
271
|
snapshot: Boolean(options.snapshot),
|
|
214
272
|
snapshotName: options.snapshot ? snapshotName(options) : "",
|
|
215
273
|
scanned: localObjects.length,
|
|
@@ -246,6 +304,221 @@ function syncArchiveToDirectory(target, options = {}, env = process.env) {
|
|
|
246
304
|
return summary;
|
|
247
305
|
}
|
|
248
306
|
|
|
307
|
+
async function replaceRemoteS3(target, options = {}, env = process.env) {
|
|
308
|
+
const cfg = loadConfig(env);
|
|
309
|
+
const objectPrefix = remoteObjectPrefix(target, cfg, options);
|
|
310
|
+
const localObjects = listLocalArchiveObjects(env, objectPrefix);
|
|
311
|
+
reportSyncEvent(options, {
|
|
312
|
+
current: 0,
|
|
313
|
+
total: 0,
|
|
314
|
+
message: "listing remote objects to replace",
|
|
315
|
+
path: objectPrefix
|
|
316
|
+
});
|
|
317
|
+
const remoteObjects = await listS3Objects(target, objectPrefix, options);
|
|
318
|
+
const summary = replaceSummary(target, cfg, options, objectPrefix, remoteObjects.length, localObjects.length);
|
|
319
|
+
if (options["dry-run"] || options.dryRun) {
|
|
320
|
+
reportSyncEvent(options, {
|
|
321
|
+
current: remoteObjects.length,
|
|
322
|
+
total: remoteObjects.length,
|
|
323
|
+
message: `would delete=${remoteObjects.length} upload=${localObjects.length}`,
|
|
324
|
+
path: objectPrefix
|
|
325
|
+
});
|
|
326
|
+
return summary;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
reportSyncEvent(options, {
|
|
330
|
+
current: 0,
|
|
331
|
+
total: remoteObjects.length,
|
|
332
|
+
message: "deleting remote objects",
|
|
333
|
+
path: objectPrefix
|
|
334
|
+
});
|
|
335
|
+
for (let index = 0; index < remoteObjects.length; index++) {
|
|
336
|
+
const object = remoteObjects[index];
|
|
337
|
+
try {
|
|
338
|
+
const deleted = await deleteS3ObjectWithRetry(target, object.key, options);
|
|
339
|
+
summary.retried += deleted.retried;
|
|
340
|
+
summary.deleted++;
|
|
341
|
+
} catch (error) {
|
|
342
|
+
summary.errors.push(`${object.key}: ${error.message}`);
|
|
343
|
+
}
|
|
344
|
+
reportSyncEvent(options, {
|
|
345
|
+
current: index + 1,
|
|
346
|
+
total: remoteObjects.length,
|
|
347
|
+
message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
|
|
348
|
+
path: object.key
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (summary.errors.length) return summary;
|
|
352
|
+
|
|
353
|
+
const uploaded = await syncArchiveToS3(target, { ...options, force: true }, env);
|
|
354
|
+
mergeReplaceUploadSummary(summary, uploaded);
|
|
355
|
+
return summary;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function wipeRemoteS3(target, options = {}, env = process.env) {
|
|
359
|
+
const cfg = loadConfig(env);
|
|
360
|
+
const scope = normalizeWipeScope(options.scope || options["wipe-scope"]);
|
|
361
|
+
const deletePrefix = remoteWipePrefix(target, cfg, scope, options);
|
|
362
|
+
reportSyncEvent(options, {
|
|
363
|
+
current: 0,
|
|
364
|
+
total: 0,
|
|
365
|
+
message: `listing remote objects to wipe (${scope})`,
|
|
366
|
+
path: deletePrefix || target.bucket
|
|
367
|
+
});
|
|
368
|
+
const remoteObjects = await listS3Objects(target, deletePrefix, options);
|
|
369
|
+
const summary = wipeSummary(target, cfg, options, scope, deletePrefix, remoteObjects.length);
|
|
370
|
+
if (options["dry-run"] || options.dryRun) {
|
|
371
|
+
reportSyncEvent(options, {
|
|
372
|
+
current: remoteObjects.length,
|
|
373
|
+
total: remoteObjects.length,
|
|
374
|
+
message: `would delete=${remoteObjects.length}`,
|
|
375
|
+
path: deletePrefix || target.bucket
|
|
376
|
+
});
|
|
377
|
+
return summary;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
reportSyncEvent(options, {
|
|
381
|
+
current: 0,
|
|
382
|
+
total: remoteObjects.length,
|
|
383
|
+
message: `deleting remote objects (${scope})`,
|
|
384
|
+
path: deletePrefix || target.bucket
|
|
385
|
+
});
|
|
386
|
+
for (let index = 0; index < remoteObjects.length; index++) {
|
|
387
|
+
const object = remoteObjects[index];
|
|
388
|
+
try {
|
|
389
|
+
const deleted = await deleteS3ObjectWithRetry(target, object.key, options);
|
|
390
|
+
summary.retried += deleted.retried;
|
|
391
|
+
summary.deleted++;
|
|
392
|
+
} catch (error) {
|
|
393
|
+
summary.errors.push(`${object.key}: ${error.message}`);
|
|
394
|
+
}
|
|
395
|
+
reportSyncEvent(options, {
|
|
396
|
+
current: index + 1,
|
|
397
|
+
total: remoteObjects.length,
|
|
398
|
+
message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
|
|
399
|
+
path: object.key
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return summary;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function replaceRemoteDirectory(target, options = {}, env = process.env) {
|
|
406
|
+
const cfg = loadConfig(env);
|
|
407
|
+
const objectPrefix = remoteObjectPrefix(target, cfg, options);
|
|
408
|
+
const localObjects = listLocalArchiveObjects(env, objectPrefix);
|
|
409
|
+
const root = path.join(target.root, objectPrefix);
|
|
410
|
+
const remoteObjects = listDirectoryObjects(root);
|
|
411
|
+
const summary = replaceSummary(target, cfg, options, objectPrefix, remoteObjects.length, localObjects.length);
|
|
412
|
+
summary.target = pathToFileURL(root).href;
|
|
413
|
+
if (options["dry-run"] || options.dryRun) {
|
|
414
|
+
reportSyncEvent(options, {
|
|
415
|
+
current: remoteObjects.length,
|
|
416
|
+
total: remoteObjects.length,
|
|
417
|
+
message: `would delete=${remoteObjects.length} upload=${localObjects.length}`,
|
|
418
|
+
path: objectPrefix
|
|
419
|
+
});
|
|
420
|
+
return summary;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
reportSyncEvent(options, {
|
|
424
|
+
current: 0,
|
|
425
|
+
total: remoteObjects.length,
|
|
426
|
+
message: "deleting remote objects",
|
|
427
|
+
path: objectPrefix
|
|
428
|
+
});
|
|
429
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
430
|
+
summary.deleted = remoteObjects.length;
|
|
431
|
+
reportSyncEvent(options, {
|
|
432
|
+
current: remoteObjects.length,
|
|
433
|
+
total: remoteObjects.length,
|
|
434
|
+
message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
|
|
435
|
+
path: objectPrefix
|
|
436
|
+
});
|
|
437
|
+
const uploaded = syncArchiveToDirectory(target, { ...options, force: true }, env);
|
|
438
|
+
mergeReplaceUploadSummary(summary, uploaded);
|
|
439
|
+
return summary;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function wipeRemoteDirectory(target, options = {}, env = process.env) {
|
|
443
|
+
const cfg = loadConfig(env);
|
|
444
|
+
const scope = normalizeWipeScope(options.scope || options["wipe-scope"]);
|
|
445
|
+
const deletePrefix = remoteWipePrefix(target, cfg, scope, options);
|
|
446
|
+
const root = deletePrefix ? path.join(target.root, deletePrefix) : target.root;
|
|
447
|
+
const remoteObjects = listDirectoryObjects(root);
|
|
448
|
+
const summary = wipeSummary(target, cfg, options, scope, deletePrefix, remoteObjects.length);
|
|
449
|
+
summary.target = pathToFileURL(root).href;
|
|
450
|
+
if (options["dry-run"] || options.dryRun) {
|
|
451
|
+
reportSyncEvent(options, {
|
|
452
|
+
current: remoteObjects.length,
|
|
453
|
+
total: remoteObjects.length,
|
|
454
|
+
message: `would delete=${remoteObjects.length}`,
|
|
455
|
+
path: deletePrefix || target.root
|
|
456
|
+
});
|
|
457
|
+
return summary;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
reportSyncEvent(options, {
|
|
461
|
+
current: 0,
|
|
462
|
+
total: remoteObjects.length,
|
|
463
|
+
message: `deleting remote objects (${scope})`,
|
|
464
|
+
path: deletePrefix || target.root
|
|
465
|
+
});
|
|
466
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
467
|
+
summary.deleted = remoteObjects.length;
|
|
468
|
+
reportSyncEvent(options, {
|
|
469
|
+
current: remoteObjects.length,
|
|
470
|
+
total: remoteObjects.length,
|
|
471
|
+
message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
|
|
472
|
+
path: deletePrefix || target.root
|
|
473
|
+
});
|
|
474
|
+
return summary;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function replaceSummary(target, cfg, options, objectPrefix, deleteCount, uploadCount) {
|
|
478
|
+
return {
|
|
479
|
+
target: target.protocol === "file" ? target.endpoint : `${target.type}:${target.bucket}/${objectPrefix}`,
|
|
480
|
+
mode: "replace",
|
|
481
|
+
scope: "device",
|
|
482
|
+
device: deviceLabel(cfg),
|
|
483
|
+
prefix: objectPrefix,
|
|
484
|
+
dryRun: Boolean(options["dry-run"] || options.dryRun),
|
|
485
|
+
remoteScanned: deleteCount,
|
|
486
|
+
deleted: options["dry-run"] || options.dryRun ? deleteCount : 0,
|
|
487
|
+
scanned: uploadCount,
|
|
488
|
+
uploaded: options["dry-run"] || options.dryRun ? uploadCount : 0,
|
|
489
|
+
skipped: 0,
|
|
490
|
+
retried: 0,
|
|
491
|
+
errors: []
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function wipeSummary(target, cfg, options, scope, deletePrefix, deleteCount) {
|
|
496
|
+
return {
|
|
497
|
+
target: target.protocol === "file" ? target.endpoint : `${target.type}:${target.bucket}/${deletePrefix || ""}`,
|
|
498
|
+
mode: "wipe",
|
|
499
|
+
scope,
|
|
500
|
+
snapshotName: scope === "snapshot" ? requestedSnapshotName(options) : "",
|
|
501
|
+
device: deviceLabel(cfg),
|
|
502
|
+
prefix: deletePrefix,
|
|
503
|
+
dryRun: Boolean(options["dry-run"] || options.dryRun),
|
|
504
|
+
remoteScanned: deleteCount,
|
|
505
|
+
deleted: options["dry-run"] || options.dryRun ? deleteCount : 0,
|
|
506
|
+
scanned: 0,
|
|
507
|
+
uploaded: 0,
|
|
508
|
+
skipped: 0,
|
|
509
|
+
retried: 0,
|
|
510
|
+
errors: []
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function mergeReplaceUploadSummary(summary, uploaded) {
|
|
515
|
+
summary.scanned = uploaded.scanned;
|
|
516
|
+
summary.uploaded = uploaded.uploaded;
|
|
517
|
+
summary.skipped = uploaded.skipped;
|
|
518
|
+
summary.retried += uploaded.retried || 0;
|
|
519
|
+
summary.errors.push(...(uploaded.errors || []));
|
|
520
|
+
}
|
|
521
|
+
|
|
249
522
|
function listLocalArchiveObjects(env = process.env, prefix = "agentlog") {
|
|
250
523
|
const root = archiveRoot(env);
|
|
251
524
|
const objects = [];
|
|
@@ -265,7 +538,17 @@ function listLocalArchiveObjects(env = process.env, prefix = "agentlog") {
|
|
|
265
538
|
return objects.sort((a, b) => a.key.localeCompare(b.key));
|
|
266
539
|
}
|
|
267
540
|
|
|
268
|
-
|
|
541
|
+
function listDirectoryObjects(root) {
|
|
542
|
+
const objects = [];
|
|
543
|
+
walk(root, (file) => {
|
|
544
|
+
const stat = safeStat(file);
|
|
545
|
+
if (!stat || !stat.isFile()) return;
|
|
546
|
+
objects.push(file);
|
|
547
|
+
});
|
|
548
|
+
return objects;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function listS3Objects(target, prefix, options = {}) {
|
|
269
552
|
const objects = [];
|
|
270
553
|
let continuationToken = "";
|
|
271
554
|
for (;;) {
|
|
@@ -278,6 +561,12 @@ async function listS3Objects(target, prefix) {
|
|
|
278
561
|
if (response.statusCode >= 300) throw new Error(`list remote objects failed (${response.statusCode}): ${response.body.toString("utf8")}`);
|
|
279
562
|
const parsed = parseListBucketResult(response.body.toString("utf8"));
|
|
280
563
|
objects.push(...parsed.objects);
|
|
564
|
+
reportSyncEvent(options, {
|
|
565
|
+
current: objects.length,
|
|
566
|
+
total: 0,
|
|
567
|
+
message: `listed=${objects.length} remote`,
|
|
568
|
+
path: prefix
|
|
569
|
+
});
|
|
281
570
|
if (!parsed.isTruncated || !parsed.nextContinuationToken) break;
|
|
282
571
|
continuationToken = parsed.nextContinuationToken;
|
|
283
572
|
}
|
|
@@ -297,6 +586,30 @@ async function putS3Object(target, key, body) {
|
|
|
297
586
|
return response;
|
|
298
587
|
}
|
|
299
588
|
|
|
589
|
+
async function putS3FileObject(target, object, bodySha256) {
|
|
590
|
+
const response = await s3Request(target, {
|
|
591
|
+
method: "PUT",
|
|
592
|
+
key: object.key,
|
|
593
|
+
bodyFile: object.file,
|
|
594
|
+
bodyLength: object.size,
|
|
595
|
+
bodySha256,
|
|
596
|
+
headers: {
|
|
597
|
+
"content-type": contentTypeForKey(object.key)
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
if (response.statusCode >= 300) throw new Error(`upload failed (${response.statusCode}): ${response.body.toString("utf8")}`);
|
|
601
|
+
return response;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function deleteS3Object(target, key) {
|
|
605
|
+
const response = await s3Request(target, {
|
|
606
|
+
method: "DELETE",
|
|
607
|
+
key
|
|
608
|
+
});
|
|
609
|
+
if (response.statusCode >= 300) throw new Error(`delete failed (${response.statusCode}): ${response.body.toString("utf8")}`);
|
|
610
|
+
return response;
|
|
611
|
+
}
|
|
612
|
+
|
|
300
613
|
async function putS3ObjectWithRetry(target, key, body, options = {}) {
|
|
301
614
|
const maxAttempts = Math.max(1, Number(options.retries || options["retries"] || 3));
|
|
302
615
|
let lastError;
|
|
@@ -313,8 +626,42 @@ async function putS3ObjectWithRetry(target, key, body, options = {}) {
|
|
|
313
626
|
throw lastError;
|
|
314
627
|
}
|
|
315
628
|
|
|
629
|
+
async function putS3FileObjectWithRetry(target, object, options = {}) {
|
|
630
|
+
const maxAttempts = Math.max(1, Number(options.retries || options["retries"] || 3));
|
|
631
|
+
const bodySha256 = await fileSha256Hex(object.file);
|
|
632
|
+
let lastError;
|
|
633
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
634
|
+
try {
|
|
635
|
+
await putS3FileObject(target, object, bodySha256);
|
|
636
|
+
return { retried: attempt - 1 };
|
|
637
|
+
} catch (error) {
|
|
638
|
+
lastError = error;
|
|
639
|
+
if (attempt >= maxAttempts || !isRetryableUploadError(error)) break;
|
|
640
|
+
await delay(Math.min(250 * 2 ** (attempt - 1), 2000));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
throw lastError;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function deleteS3ObjectWithRetry(target, key, options = {}) {
|
|
647
|
+
const maxAttempts = Math.max(1, Number(options.retries || options["retries"] || 3));
|
|
648
|
+
let lastError;
|
|
649
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
650
|
+
try {
|
|
651
|
+
await deleteS3Object(target, key);
|
|
652
|
+
return { retried: attempt - 1 };
|
|
653
|
+
} catch (error) {
|
|
654
|
+
lastError = error;
|
|
655
|
+
if (attempt >= maxAttempts || !isRetryableUploadError(error)) break;
|
|
656
|
+
await delay(Math.min(250 * 2 ** (attempt - 1), 2000));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
throw lastError;
|
|
660
|
+
}
|
|
661
|
+
|
|
316
662
|
function s3Request(target, request) {
|
|
317
|
-
const
|
|
663
|
+
const hasBodyFile = Boolean(request.bodyFile);
|
|
664
|
+
const body = hasBodyFile ? null : Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || "");
|
|
318
665
|
const method = request.method || "GET";
|
|
319
666
|
const key = request.key || "";
|
|
320
667
|
const query = request.query || {};
|
|
@@ -322,11 +669,12 @@ function s3Request(target, request) {
|
|
|
322
669
|
const now = new Date();
|
|
323
670
|
const headers = {
|
|
324
671
|
host: target.url.host,
|
|
325
|
-
"x-amz-content-sha256": sha256Hex(body),
|
|
672
|
+
"x-amz-content-sha256": hasBodyFile ? request.bodySha256 : sha256Hex(body),
|
|
326
673
|
"x-amz-date": amzDate(now),
|
|
327
674
|
...(request.headers || {})
|
|
328
675
|
};
|
|
329
|
-
|
|
676
|
+
const bodyLength = hasBodyFile ? Number(request.bodyLength || 0) : body.length;
|
|
677
|
+
if (bodyLength) headers["content-length"] = String(bodyLength);
|
|
330
678
|
headers.authorization = authorizationHeader({ target, method, pathName, query, headers, now });
|
|
331
679
|
|
|
332
680
|
return new Promise((resolve, reject) => {
|
|
@@ -347,6 +695,14 @@ function s3Request(target, request) {
|
|
|
347
695
|
}
|
|
348
696
|
);
|
|
349
697
|
req.on("error", reject);
|
|
698
|
+
if (hasBodyFile) {
|
|
699
|
+
const stream = fs.createReadStream(request.bodyFile);
|
|
700
|
+
stream.on("error", (error) => {
|
|
701
|
+
req.destroy(error);
|
|
702
|
+
});
|
|
703
|
+
stream.pipe(req);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
350
706
|
if (body.length) req.write(body);
|
|
351
707
|
req.end();
|
|
352
708
|
});
|
|
@@ -434,10 +790,7 @@ function decodeXml(value) {
|
|
|
434
790
|
}
|
|
435
791
|
|
|
436
792
|
function reportSyncProgress(options, summary, current, total, key = "") {
|
|
437
|
-
|
|
438
|
-
options.onProgress({
|
|
439
|
-
kind: "sync",
|
|
440
|
-
provider: "Remote Sync",
|
|
793
|
+
reportSyncEvent(options, {
|
|
441
794
|
current,
|
|
442
795
|
total,
|
|
443
796
|
uploaded: summary.uploaded,
|
|
@@ -449,6 +802,22 @@ function reportSyncProgress(options, summary, current, total, key = "") {
|
|
|
449
802
|
});
|
|
450
803
|
}
|
|
451
804
|
|
|
805
|
+
function reportSyncEvent(options, event) {
|
|
806
|
+
if (typeof options.onProgress !== "function") return;
|
|
807
|
+
options.onProgress({
|
|
808
|
+
kind: "sync",
|
|
809
|
+
provider: "Remote Sync",
|
|
810
|
+
current: event.current || 0,
|
|
811
|
+
total: event.total || 0,
|
|
812
|
+
uploaded: event.uploaded || 0,
|
|
813
|
+
skipped: event.skipped || 0,
|
|
814
|
+
retried: event.retried || 0,
|
|
815
|
+
errors: event.errors || 0,
|
|
816
|
+
message: event.message || "working",
|
|
817
|
+
path: event.path || ""
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
452
821
|
function loadSyncState(env = process.env) {
|
|
453
822
|
return readJson(path.join(paths(env).state, "sync.json"), { objects: {} });
|
|
454
823
|
}
|
|
@@ -582,6 +951,50 @@ function remoteObjectPrefix(target, cfg = {}, options = {}) {
|
|
|
582
951
|
return [prefix, "devices", deviceSlug(cfg)].filter(Boolean).join("/");
|
|
583
952
|
}
|
|
584
953
|
|
|
954
|
+
function remoteSnapshotsPrefix(target) {
|
|
955
|
+
return [normalizePrefix(target.prefix || "agentlog"), "snapshots"].filter(Boolean).join("/");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function remoteWipePrefix(target, cfg = {}, scope = "device", options = {}) {
|
|
959
|
+
if (scope === "bucket") return "";
|
|
960
|
+
const prefix = normalizePrefix(target.prefix || "agentlog");
|
|
961
|
+
if (scope === "prefix") return prefix;
|
|
962
|
+
if (scope === "snapshots") return `${remoteSnapshotsPrefix(target)}/`;
|
|
963
|
+
if (scope === "snapshot") return `${remoteSnapshotsPrefix(target)}/${requestedSnapshotName(options)}/`;
|
|
964
|
+
return remoteObjectPrefix(target, cfg, {});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function normalizeWipeScope(scope) {
|
|
968
|
+
const value = String(scope || "device").trim().toLowerCase();
|
|
969
|
+
const normalized = {
|
|
970
|
+
current: "device",
|
|
971
|
+
"current-device": "device",
|
|
972
|
+
device: "device",
|
|
973
|
+
namespace: "device",
|
|
974
|
+
prefix: "prefix",
|
|
975
|
+
archive: "prefix",
|
|
976
|
+
agentlog: "prefix",
|
|
977
|
+
snapshot: "snapshot",
|
|
978
|
+
"one-snapshot": "snapshot",
|
|
979
|
+
"single-snapshot": "snapshot",
|
|
980
|
+
snapshots: "snapshots",
|
|
981
|
+
"all-snapshots": "snapshots",
|
|
982
|
+
"snapshot-prefix": "snapshots",
|
|
983
|
+
all: "bucket",
|
|
984
|
+
bucket: "bucket"
|
|
985
|
+
}[value] || value;
|
|
986
|
+
if (!["device", "snapshot", "snapshots", "prefix", "bucket"].includes(normalized)) {
|
|
987
|
+
throw new Error("sync wipe --scope must be device, snapshot, snapshots, prefix, or bucket");
|
|
988
|
+
}
|
|
989
|
+
return normalized;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function requestedSnapshotName(options = {}) {
|
|
993
|
+
const explicit = value(options["snapshot-name"] || options.snapshotName || options.name || options.snapshot);
|
|
994
|
+
if (!explicit) throw new Error("sync wipe --scope snapshot requires --snapshot-name <name>");
|
|
995
|
+
return slugifyRemoteSegment(explicit);
|
|
996
|
+
}
|
|
997
|
+
|
|
585
998
|
function snapshotName(options = {}) {
|
|
586
999
|
const explicit = value(options["snapshot-name"] || options.snapshotName || options.name);
|
|
587
1000
|
if (explicit) return slugifyRemoteSegment(explicit);
|
|
@@ -591,6 +1004,27 @@ function snapshotName(options = {}) {
|
|
|
591
1004
|
return options._snapshotName;
|
|
592
1005
|
}
|
|
593
1006
|
|
|
1007
|
+
function snapshotListSummary(target, prefix, objects = []) {
|
|
1008
|
+
const snapshots = new Map();
|
|
1009
|
+
for (const object of objects) {
|
|
1010
|
+
const relative = stripPrefix(object.key, prefix).replace(/^\/+/, "");
|
|
1011
|
+
const name = relative.split("/").filter(Boolean)[0];
|
|
1012
|
+
if (!name) continue;
|
|
1013
|
+
const current = snapshots.get(name) || { name, objects: 0, bytes: 0, lastModified: "" };
|
|
1014
|
+
current.objects++;
|
|
1015
|
+
current.bytes += Number(object.size || 0);
|
|
1016
|
+
if (object.lastModified && (!current.lastModified || String(object.lastModified) > current.lastModified)) {
|
|
1017
|
+
current.lastModified = String(object.lastModified);
|
|
1018
|
+
}
|
|
1019
|
+
snapshots.set(name, current);
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
target: target.protocol === "file" ? pathToFileURL(path.join(target.root, prefix)).href : `${target.type}:${target.bucket}/${prefix}`,
|
|
1023
|
+
prefix,
|
|
1024
|
+
snapshots: Array.from(snapshots.values()).sort((a, b) => b.name.localeCompare(a.name))
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
594
1028
|
function deviceSlug(cfg = {}) {
|
|
595
1029
|
return slugifyRemoteSegment(cfg.device?.slug || cfg.device?.name || cfg.device?.id || "this-device");
|
|
596
1030
|
}
|
|
@@ -652,6 +1086,16 @@ function sha256Hex(value) {
|
|
|
652
1086
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
653
1087
|
}
|
|
654
1088
|
|
|
1089
|
+
function fileSha256Hex(file) {
|
|
1090
|
+
return new Promise((resolve, reject) => {
|
|
1091
|
+
const hash = crypto.createHash("sha256");
|
|
1092
|
+
const stream = fs.createReadStream(file);
|
|
1093
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
1094
|
+
stream.on("error", reject);
|
|
1095
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
655
1099
|
function hmac(key, value) {
|
|
656
1100
|
return crypto.createHmac("sha256", key).update(value).digest();
|
|
657
1101
|
}
|
|
@@ -668,10 +1112,13 @@ module.exports = {
|
|
|
668
1112
|
configureRemoteFromFlags,
|
|
669
1113
|
hasRemoteTarget,
|
|
670
1114
|
listLocalArchiveObjects,
|
|
1115
|
+
listRemoteSnapshots,
|
|
671
1116
|
parseListBucketResult,
|
|
672
1117
|
remoteTargetFromFlags,
|
|
673
1118
|
remoteObjectPrefix,
|
|
1119
|
+
replaceRemoteArchive,
|
|
674
1120
|
snapshotArchive,
|
|
675
1121
|
syncArchive,
|
|
676
|
-
syncArchiveIfConfigured
|
|
1122
|
+
syncArchiveIfConfigured,
|
|
1123
|
+
wipeRemoteArchive
|
|
677
1124
|
};
|