agentel 0.2.0
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/LICENSE +21 -0
- package/README.md +452 -0
- package/agentlog-spec.md +551 -0
- package/bin/agentlog-recall.js +8 -0
- package/bin/agentlog.js +14 -0
- package/docs/code-reference.md +1108 -0
- package/docs/history-source-handling.md +837 -0
- package/docs/release.md +69 -0
- package/package.json +57 -0
- package/src/archive.js +1130 -0
- package/src/autostart.js +182 -0
- package/src/canonical-events.js +575 -0
- package/src/cli.js +7928 -0
- package/src/collector.js +113 -0
- package/src/commands/logs.js +51 -0
- package/src/commands/server.js +11 -0
- package/src/config.js +240 -0
- package/src/doctor.js +102 -0
- package/src/importers/aider.js +553 -0
- package/src/importers/claude.js +349 -0
- package/src/importers/cline.js +471 -0
- package/src/importers/gemini.js +795 -0
- package/src/importers/providers.js +149 -0
- package/src/importers/shared.js +15 -0
- package/src/importers.js +7063 -0
- package/src/mcp.js +148 -0
- package/src/parser-versions.js +62 -0
- package/src/paths.js +61 -0
- package/src/redaction.js +228 -0
- package/src/repo.js +106 -0
- package/src/search.js +619 -0
- package/src/sources.js +86 -0
- package/src/supervisor.js +217 -0
- package/src/sync.js +677 -0
- package/src/version.js +7 -0
- package/src/web-accounts.js +122 -0
package/src/sync.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const http = require("http");
|
|
6
|
+
const https = require("https");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { fileURLToPath, pathToFileURL } = require("url");
|
|
9
|
+
const { archiveRoot } = require("./archive");
|
|
10
|
+
const { loadConfig, normalizeRemoteEndpointInput, saveConfig } = require("./config");
|
|
11
|
+
const { ensureDir, paths, readJson, writeJson } = require("./paths");
|
|
12
|
+
|
|
13
|
+
function configureRemoteFromFlags(flags = {}, env = process.env) {
|
|
14
|
+
const cfg = loadConfig(env);
|
|
15
|
+
const target = remoteTargetFromFlags(flags, cfg, env, { allowPartial: true });
|
|
16
|
+
cfg.storage.backend = target.type || cfg.storage.backend || "local";
|
|
17
|
+
if (flags["device-name"] || flags.deviceName || flags.device || flags["device-slug"] || flags.deviceSlug) {
|
|
18
|
+
const deviceName = value(flags["device-name"] || flags.deviceName || flags.device || cfg.device?.name || "");
|
|
19
|
+
cfg.device = {
|
|
20
|
+
...(cfg.device || {}),
|
|
21
|
+
name: deviceName || cfg.device?.name || "",
|
|
22
|
+
slug: value(flags["device-slug"] || flags.deviceSlug || (deviceName ? slugifyRemoteSegment(deviceName) : cfg.device?.slug || "")) || cfg.device?.slug || ""
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (flags["sync-mode"] || flags.syncMode || flags.mode) {
|
|
26
|
+
const mode = normalizeSyncMode(flags["sync-mode"] || flags.syncMode || flags.mode);
|
|
27
|
+
if (mode !== "upload") throw new Error(`remote sync mode "${mode}" is planned but not implemented yet; use upload mode for now`);
|
|
28
|
+
cfg.sync = {
|
|
29
|
+
...(cfg.sync || {}),
|
|
30
|
+
mode
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (flags["sync-interval-minutes"] || flags.syncIntervalMinutes || flags.syncInterval) {
|
|
34
|
+
cfg.sync = {
|
|
35
|
+
...(cfg.sync || {}),
|
|
36
|
+
intervalMinutes: normalizeSyncInterval(flags["sync-interval-minutes"] || flags.syncIntervalMinutes || flags.syncInterval)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
cfg.remote = {
|
|
40
|
+
...(cfg.remote || {}),
|
|
41
|
+
type: target.type || cfg.remote?.type || cfg.storage.backend,
|
|
42
|
+
endpoint: target.endpoint || cfg.remote?.endpoint || cfg.remote?.url || "",
|
|
43
|
+
bucket: target.bucket || cfg.remote?.bucket || "",
|
|
44
|
+
region: target.region || cfg.remote?.region || "auto",
|
|
45
|
+
prefix: target.prefix || cfg.remote?.prefix || "agentlog",
|
|
46
|
+
accessKeyId: target.accessKeyId || cfg.remote?.accessKeyId || "",
|
|
47
|
+
secretAccessKey: target.secretAccessKey || cfg.remote?.secretAccessKey || ""
|
|
48
|
+
};
|
|
49
|
+
if (cfg.remote.endpoint) cfg.remote.url = cfg.remote.endpoint;
|
|
50
|
+
cfg.updatedAt = new Date().toISOString();
|
|
51
|
+
saveConfig(cfg, env);
|
|
52
|
+
return cfg.remote;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function syncArchive(options = {}, env = process.env) {
|
|
56
|
+
const cfg = loadConfig(env);
|
|
57
|
+
const mode = normalizeSyncMode(options["sync-mode"] || options.syncMode || options.mode || cfg.sync?.mode || "upload");
|
|
58
|
+
if (mode !== "upload") {
|
|
59
|
+
throw new Error(`remote sync mode "${mode}" is planned but not implemented yet; use upload mode to push this device without remote deletes`);
|
|
60
|
+
}
|
|
61
|
+
const lock = acquireSyncLock(env);
|
|
62
|
+
try {
|
|
63
|
+
const target = remoteTargetFromFlags(options, cfg, env);
|
|
64
|
+
if (target.protocol === "file") return syncArchiveToDirectory(target, options, env);
|
|
65
|
+
return syncArchiveToS3(target, options, env);
|
|
66
|
+
} finally {
|
|
67
|
+
releaseSyncLock(lock);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function snapshotArchive(options = {}, env = process.env) {
|
|
72
|
+
return syncArchive({ ...options, snapshot: true, force: true }, env);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function syncArchiveIfConfigured(env = process.env, options = {}) {
|
|
76
|
+
const cfg = loadConfig(env);
|
|
77
|
+
if (!hasRemoteTarget(cfg, env)) return { configured: false, scanned: 0, uploaded: 0, skipped: 0, errors: [] };
|
|
78
|
+
return syncArchive({ ...options, quiet: true }, env);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasRemoteTarget(cfg = loadConfig(), env = process.env) {
|
|
82
|
+
try {
|
|
83
|
+
remoteTargetFromFlags({}, cfg, env);
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function remoteTargetFromFlags(flags = {}, cfg = loadConfig(), env = process.env, options = {}) {
|
|
91
|
+
const remote = cfg.remote || {};
|
|
92
|
+
let type = value(flags.type || flags.target || flags.storage || remote.type || cfg.storage?.backend || "r2");
|
|
93
|
+
let endpoint = value(
|
|
94
|
+
flags.endpoint ||
|
|
95
|
+
flags.remote ||
|
|
96
|
+
remote.endpoint ||
|
|
97
|
+
remote.url ||
|
|
98
|
+
env.AGENTLOG_REMOTE_ENDPOINT ||
|
|
99
|
+
env.R2_ENDPOINT ||
|
|
100
|
+
env.AWS_ENDPOINT_URL
|
|
101
|
+
);
|
|
102
|
+
let bucket = value(flags.bucket || remote.bucket || env.AGENTLOG_REMOTE_BUCKET || env.R2_BUCKET || env.AWS_BUCKET);
|
|
103
|
+
const normalized = normalizeRemoteEndpointInput(endpoint, bucket);
|
|
104
|
+
endpoint = normalized.endpoint;
|
|
105
|
+
bucket = normalized.bucket;
|
|
106
|
+
const region = value(flags.region || remote.region || env.AGENTLOG_REMOTE_REGION || env.AWS_REGION || (type === "r2" ? "auto" : "us-east-1"));
|
|
107
|
+
const prefix = normalizePrefix(value(flags.prefix || remote.prefix || env.AGENTLOG_REMOTE_PREFIX || "agentlog"));
|
|
108
|
+
const accessKeyId = value(
|
|
109
|
+
flags["access-key-id"] ||
|
|
110
|
+
flags.accessKeyId ||
|
|
111
|
+
remote.accessKeyId ||
|
|
112
|
+
env.AGENTLOG_REMOTE_ACCESS_KEY_ID ||
|
|
113
|
+
env.R2_ACCESS_KEY_ID ||
|
|
114
|
+
env.AWS_ACCESS_KEY_ID
|
|
115
|
+
);
|
|
116
|
+
const secretAccessKey = value(
|
|
117
|
+
flags["secret-access-key"] ||
|
|
118
|
+
flags.secretAccessKey ||
|
|
119
|
+
remote.secretAccessKey ||
|
|
120
|
+
env.AGENTLOG_REMOTE_SECRET_ACCESS_KEY ||
|
|
121
|
+
env.R2_SECRET_ACCESS_KEY ||
|
|
122
|
+
env.AWS_SECRET_ACCESS_KEY
|
|
123
|
+
);
|
|
124
|
+
if (type === "local" && endpoint) type = inferRemoteType(endpoint);
|
|
125
|
+
|
|
126
|
+
if (endpoint && endpoint.startsWith("file://")) {
|
|
127
|
+
return {
|
|
128
|
+
protocol: "file",
|
|
129
|
+
type,
|
|
130
|
+
endpoint,
|
|
131
|
+
root: fileUrlPath(endpoint),
|
|
132
|
+
prefix
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!endpoint || !bucket) {
|
|
137
|
+
if (options.allowPartial) return { type, endpoint, bucket, region, prefix, accessKeyId, secretAccessKey };
|
|
138
|
+
throw new Error("remote sync requires --endpoint and --bucket, or matching AGENTLOG_REMOTE_* / R2_* / AWS_* settings");
|
|
139
|
+
}
|
|
140
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
141
|
+
if (options.allowPartial) return { type, endpoint, bucket, region, prefix, accessKeyId, secretAccessKey };
|
|
142
|
+
throw new Error("remote sync requires --access-key-id and --secret-access-key, or matching AGENTLOG_REMOTE_* / R2_* / AWS_* settings");
|
|
143
|
+
}
|
|
144
|
+
const url = new URL(endpoint);
|
|
145
|
+
return { protocol: url.protocol.replace(":", ""), type, endpoint, url, bucket, region, prefix, accessKeyId, secretAccessKey };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function syncArchiveToS3(target, options = {}, env = process.env) {
|
|
149
|
+
const cfg = loadConfig(env);
|
|
150
|
+
const objectPrefix = remoteObjectPrefix(target, cfg, options);
|
|
151
|
+
const localObjects = listLocalArchiveObjects(env, objectPrefix);
|
|
152
|
+
const state = loadSyncState(env);
|
|
153
|
+
const remoteObjects = await listS3Objects(target, objectPrefix);
|
|
154
|
+
const remoteByKey = new Map(remoteObjects.map((item) => [item.key, item]));
|
|
155
|
+
const summary = {
|
|
156
|
+
target: `${target.type}:${target.bucket}/${objectPrefix}`,
|
|
157
|
+
mode: "upload",
|
|
158
|
+
device: deviceLabel(cfg),
|
|
159
|
+
snapshot: Boolean(options.snapshot),
|
|
160
|
+
snapshotName: options.snapshot ? snapshotName(options) : "",
|
|
161
|
+
scanned: localObjects.length,
|
|
162
|
+
uploaded: 0,
|
|
163
|
+
skipped: 0,
|
|
164
|
+
retried: 0,
|
|
165
|
+
errors: []
|
|
166
|
+
};
|
|
167
|
+
reportSyncProgress(options, summary, 0, localObjects.length);
|
|
168
|
+
|
|
169
|
+
for (let index = 0; index < localObjects.length; index++) {
|
|
170
|
+
const object = localObjects[index];
|
|
171
|
+
const remote = remoteByKey.get(object.key);
|
|
172
|
+
const stateEntry = state.objects[object.key];
|
|
173
|
+
const unchangedRemote = remote && Number(remote.size) === object.size;
|
|
174
|
+
const unchangedState = stateEntry && stateEntry.fingerprint === object.fingerprint && unchangedRemote;
|
|
175
|
+
if (unchangedState || (unchangedRemote && !options.force)) {
|
|
176
|
+
summary.skipped++;
|
|
177
|
+
reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (options["dry-run"] || options.dryRun) {
|
|
181
|
+
summary.uploaded++;
|
|
182
|
+
reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const upload = await putS3ObjectWithRetry(target, object.key, fs.readFileSync(object.file), options);
|
|
187
|
+
summary.retried += upload.retried;
|
|
188
|
+
state.objects[object.key] = {
|
|
189
|
+
fingerprint: object.fingerprint,
|
|
190
|
+
size: object.size,
|
|
191
|
+
syncedAt: new Date().toISOString()
|
|
192
|
+
};
|
|
193
|
+
summary.uploaded++;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
summary.errors.push(`${object.key}: ${error.message}`);
|
|
196
|
+
}
|
|
197
|
+
reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!options["dry-run"] && !options.dryRun) saveSyncState(state, env);
|
|
201
|
+
return summary;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function syncArchiveToDirectory(target, options = {}, env = process.env) {
|
|
205
|
+
const cfg = loadConfig(env);
|
|
206
|
+
const objectPrefix = remoteObjectPrefix(target, cfg, options);
|
|
207
|
+
const localObjects = listLocalArchiveObjects(env, objectPrefix);
|
|
208
|
+
const root = path.join(target.root, objectPrefix);
|
|
209
|
+
const summary = {
|
|
210
|
+
target: pathToFileURL(root).href,
|
|
211
|
+
mode: "upload",
|
|
212
|
+
device: deviceLabel(cfg),
|
|
213
|
+
snapshot: Boolean(options.snapshot),
|
|
214
|
+
snapshotName: options.snapshot ? snapshotName(options) : "",
|
|
215
|
+
scanned: localObjects.length,
|
|
216
|
+
uploaded: 0,
|
|
217
|
+
skipped: 0,
|
|
218
|
+
retried: 0,
|
|
219
|
+
errors: []
|
|
220
|
+
};
|
|
221
|
+
reportSyncProgress(options, summary, 0, localObjects.length);
|
|
222
|
+
for (let index = 0; index < localObjects.length; index++) {
|
|
223
|
+
const object = localObjects[index];
|
|
224
|
+
const relative = stripPrefix(object.key, objectPrefix);
|
|
225
|
+
const dest = path.join(root, relative);
|
|
226
|
+
const existing = safeStat(dest);
|
|
227
|
+
if (existing && existing.size === object.size && !options.force) {
|
|
228
|
+
summary.skipped++;
|
|
229
|
+
reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (options["dry-run"] || options.dryRun) {
|
|
233
|
+
summary.uploaded++;
|
|
234
|
+
reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
ensureDir(path.dirname(dest));
|
|
239
|
+
fs.copyFileSync(object.file, dest);
|
|
240
|
+
summary.uploaded++;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
summary.errors.push(`${object.key}: ${error.message}`);
|
|
243
|
+
}
|
|
244
|
+
reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
|
|
245
|
+
}
|
|
246
|
+
return summary;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function listLocalArchiveObjects(env = process.env, prefix = "agentlog") {
|
|
250
|
+
const root = archiveRoot(env);
|
|
251
|
+
const objects = [];
|
|
252
|
+
walk(root, (file) => {
|
|
253
|
+
const stat = safeStat(file);
|
|
254
|
+
if (!stat || !stat.isFile()) return;
|
|
255
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
256
|
+
const key = [normalizePrefix(prefix), relative].filter(Boolean).join("/");
|
|
257
|
+
objects.push({
|
|
258
|
+
file,
|
|
259
|
+
key,
|
|
260
|
+
size: stat.size,
|
|
261
|
+
mtimeMs: stat.mtimeMs,
|
|
262
|
+
fingerprint: `${stat.size}:${Math.floor(stat.mtimeMs)}`
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
return objects.sort((a, b) => a.key.localeCompare(b.key));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function listS3Objects(target, prefix) {
|
|
269
|
+
const objects = [];
|
|
270
|
+
let continuationToken = "";
|
|
271
|
+
for (;;) {
|
|
272
|
+
const query = {
|
|
273
|
+
"list-type": "2",
|
|
274
|
+
prefix
|
|
275
|
+
};
|
|
276
|
+
if (continuationToken) query["continuation-token"] = continuationToken;
|
|
277
|
+
const response = await s3Request(target, { method: "GET", query });
|
|
278
|
+
if (response.statusCode >= 300) throw new Error(`list remote objects failed (${response.statusCode}): ${response.body.toString("utf8")}`);
|
|
279
|
+
const parsed = parseListBucketResult(response.body.toString("utf8"));
|
|
280
|
+
objects.push(...parsed.objects);
|
|
281
|
+
if (!parsed.isTruncated || !parsed.nextContinuationToken) break;
|
|
282
|
+
continuationToken = parsed.nextContinuationToken;
|
|
283
|
+
}
|
|
284
|
+
return objects;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function putS3Object(target, key, body) {
|
|
288
|
+
const response = await s3Request(target, {
|
|
289
|
+
method: "PUT",
|
|
290
|
+
key,
|
|
291
|
+
body,
|
|
292
|
+
headers: {
|
|
293
|
+
"content-type": contentTypeForKey(key)
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
if (response.statusCode >= 300) throw new Error(`upload failed (${response.statusCode}): ${response.body.toString("utf8")}`);
|
|
297
|
+
return response;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function putS3ObjectWithRetry(target, key, body, options = {}) {
|
|
301
|
+
const maxAttempts = Math.max(1, Number(options.retries || options["retries"] || 3));
|
|
302
|
+
let lastError;
|
|
303
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
304
|
+
try {
|
|
305
|
+
await putS3Object(target, key, body);
|
|
306
|
+
return { retried: attempt - 1 };
|
|
307
|
+
} catch (error) {
|
|
308
|
+
lastError = error;
|
|
309
|
+
if (attempt >= maxAttempts || !isRetryableUploadError(error)) break;
|
|
310
|
+
await delay(Math.min(250 * 2 ** (attempt - 1), 2000));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
throw lastError;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function s3Request(target, request) {
|
|
317
|
+
const body = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || "");
|
|
318
|
+
const method = request.method || "GET";
|
|
319
|
+
const key = request.key || "";
|
|
320
|
+
const query = request.query || {};
|
|
321
|
+
const pathName = s3Path(target.bucket, key);
|
|
322
|
+
const now = new Date();
|
|
323
|
+
const headers = {
|
|
324
|
+
host: target.url.host,
|
|
325
|
+
"x-amz-content-sha256": sha256Hex(body),
|
|
326
|
+
"x-amz-date": amzDate(now),
|
|
327
|
+
...(request.headers || {})
|
|
328
|
+
};
|
|
329
|
+
if (body.length) headers["content-length"] = String(body.length);
|
|
330
|
+
headers.authorization = authorizationHeader({ target, method, pathName, query, headers, now });
|
|
331
|
+
|
|
332
|
+
return new Promise((resolve, reject) => {
|
|
333
|
+
const transport = target.url.protocol === "http:" ? http : https;
|
|
334
|
+
const req = transport.request(
|
|
335
|
+
{
|
|
336
|
+
protocol: target.url.protocol,
|
|
337
|
+
hostname: target.url.hostname,
|
|
338
|
+
port: target.url.port || undefined,
|
|
339
|
+
method,
|
|
340
|
+
path: pathName + canonicalQueryString(query, true),
|
|
341
|
+
headers
|
|
342
|
+
},
|
|
343
|
+
(res) => {
|
|
344
|
+
const chunks = [];
|
|
345
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
346
|
+
res.on("end", () => resolve({ statusCode: res.statusCode || 0, headers: res.headers, body: Buffer.concat(chunks) }));
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
req.on("error", reject);
|
|
350
|
+
if (body.length) req.write(body);
|
|
351
|
+
req.end();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function authorizationHeader({ target, method, pathName, query, headers, now }) {
|
|
356
|
+
const signedHeaderNames = Object.keys(headers).map((name) => name.toLowerCase()).sort();
|
|
357
|
+
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${String(headers[name] ?? headers[Object.keys(headers).find((key) => key.toLowerCase() === name)]).trim()}\n`).join("");
|
|
358
|
+
const signedHeaders = signedHeaderNames.join(";");
|
|
359
|
+
const requestHash = sha256Hex(
|
|
360
|
+
[
|
|
361
|
+
method,
|
|
362
|
+
pathName,
|
|
363
|
+
canonicalQueryString(query, false),
|
|
364
|
+
canonicalHeaders,
|
|
365
|
+
signedHeaders,
|
|
366
|
+
headers["x-amz-content-sha256"]
|
|
367
|
+
].join("\n")
|
|
368
|
+
);
|
|
369
|
+
const date = amzDate(now).slice(0, 8);
|
|
370
|
+
const scope = `${date}/${target.region}/s3/aws4_request`;
|
|
371
|
+
const stringToSign = ["AWS4-HMAC-SHA256", amzDate(now), scope, requestHash].join("\n");
|
|
372
|
+
const signature = hmacHex(signingKey(target.secretAccessKey, date, target.region), stringToSign);
|
|
373
|
+
return `AWS4-HMAC-SHA256 Credential=${target.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function signingKey(secret, date, region) {
|
|
377
|
+
const kDate = hmac(Buffer.from(`AWS4${secret}`, "utf8"), date);
|
|
378
|
+
const kRegion = hmac(kDate, region);
|
|
379
|
+
const kService = hmac(kRegion, "s3");
|
|
380
|
+
return hmac(kService, "aws4_request");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function s3Path(bucket, key = "") {
|
|
384
|
+
const segments = [bucket, ...String(key || "").split("/").filter(Boolean)];
|
|
385
|
+
return `/${segments.map(awsEncode).join("/")}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function canonicalQueryString(query = {}, includePrefix = false) {
|
|
389
|
+
const entries = Object.entries(query)
|
|
390
|
+
.filter(([, value]) => value != null && value !== "")
|
|
391
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
392
|
+
.map(([key, value]) => `${awsEncode(key)}=${awsEncode(value)}`)
|
|
393
|
+
.join("&");
|
|
394
|
+
if (!includePrefix) return entries;
|
|
395
|
+
return entries ? `?${entries}` : "";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function awsEncode(value) {
|
|
399
|
+
return encodeURIComponent(String(value)).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function parseListBucketResult(xml) {
|
|
403
|
+
const objects = [];
|
|
404
|
+
for (const match of String(xml || "").matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)) {
|
|
405
|
+
const block = match[1];
|
|
406
|
+
const key = xmlValue(block, "Key");
|
|
407
|
+
if (!key) continue;
|
|
408
|
+
objects.push({
|
|
409
|
+
key,
|
|
410
|
+
size: Number(xmlValue(block, "Size") || 0),
|
|
411
|
+
etag: xmlValue(block, "ETag").replace(/^"|"$/g, ""),
|
|
412
|
+
lastModified: xmlValue(block, "LastModified")
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
objects,
|
|
417
|
+
isTruncated: xmlValue(xml, "IsTruncated") === "true",
|
|
418
|
+
nextContinuationToken: xmlValue(xml, "NextContinuationToken")
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function xmlValue(xml, name) {
|
|
423
|
+
const match = String(xml || "").match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`));
|
|
424
|
+
return match ? decodeXml(match[1]) : "";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function decodeXml(value) {
|
|
428
|
+
return String(value || "")
|
|
429
|
+
.replace(/</g, "<")
|
|
430
|
+
.replace(/>/g, ">")
|
|
431
|
+
.replace(/"/g, '"')
|
|
432
|
+
.replace(/'/g, "'")
|
|
433
|
+
.replace(/&/g, "&");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function reportSyncProgress(options, summary, current, total, key = "") {
|
|
437
|
+
if (typeof options.onProgress !== "function") return;
|
|
438
|
+
options.onProgress({
|
|
439
|
+
kind: "sync",
|
|
440
|
+
provider: "Remote Sync",
|
|
441
|
+
current,
|
|
442
|
+
total,
|
|
443
|
+
uploaded: summary.uploaded,
|
|
444
|
+
skipped: summary.skipped,
|
|
445
|
+
retried: summary.retried || 0,
|
|
446
|
+
errors: summary.errors.length,
|
|
447
|
+
message: `uploaded=${summary.uploaded} current=${summary.skipped}${summary.retried ? ` retried=${summary.retried}` : ""} errors=${summary.errors.length}`,
|
|
448
|
+
path: key
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function loadSyncState(env = process.env) {
|
|
453
|
+
return readJson(path.join(paths(env).state, "sync.json"), { objects: {} });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function saveSyncState(state, env = process.env) {
|
|
457
|
+
writeJson(path.join(paths(env).state, "sync.json"), state);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function readJsonMaybe(file, fallback) {
|
|
461
|
+
try {
|
|
462
|
+
return readJson(file, fallback);
|
|
463
|
+
} catch {
|
|
464
|
+
return fallback;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function acquireSyncLock(env = process.env) {
|
|
469
|
+
const p = paths(env);
|
|
470
|
+
ensureDir(p.state);
|
|
471
|
+
const file = path.join(p.state, "sync.lock");
|
|
472
|
+
const payload = {
|
|
473
|
+
pid: process.pid,
|
|
474
|
+
startedAt: new Date().toISOString()
|
|
475
|
+
};
|
|
476
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
477
|
+
try {
|
|
478
|
+
const fd = fs.openSync(file, "wx", 0o600);
|
|
479
|
+
fs.writeFileSync(fd, `${JSON.stringify(payload)}\n`);
|
|
480
|
+
fs.closeSync(fd);
|
|
481
|
+
return { file, pid: process.pid };
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error.code !== "EEXIST") throw error;
|
|
484
|
+
const existing = readJsonMaybe(file, null);
|
|
485
|
+
if (isStaleSyncLock(existing)) {
|
|
486
|
+
safeUnlink(file);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const owner = existing?.pid ? `PID ${existing.pid}` : "another process";
|
|
490
|
+
throw new Error(`remote sync is already running (${owner}); wait for it to finish, then run agentlog sync again`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
throw new Error("remote sync is already running; wait for it to finish, then run agentlog sync again");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function releaseSyncLock(lock) {
|
|
497
|
+
if (!lock?.file) return;
|
|
498
|
+
const existing = readJsonMaybe(lock.file, null);
|
|
499
|
+
if (!existing?.pid || Number(existing.pid) === process.pid) safeUnlink(lock.file);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isStaleSyncLock(lock) {
|
|
503
|
+
if (!lock || typeof lock !== "object") return true;
|
|
504
|
+
const pid = Number(lock.pid);
|
|
505
|
+
if (!pid || !isProcessAlive(pid)) return true;
|
|
506
|
+
const started = new Date(lock.startedAt || 0);
|
|
507
|
+
if (Number.isNaN(started.getTime())) return false;
|
|
508
|
+
return Date.now() - started.getTime() > 6 * 60 * 60 * 1000;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isProcessAlive(pid) {
|
|
512
|
+
try {
|
|
513
|
+
process.kill(pid, 0);
|
|
514
|
+
return true;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return error?.code === "EPERM";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function contentTypeForKey(key) {
|
|
521
|
+
if (key.endsWith(".json") || key.endsWith(".jsonl")) return "application/json";
|
|
522
|
+
if (key.endsWith(".md") || key.endsWith(".markdown")) return "text/markdown; charset=utf-8";
|
|
523
|
+
return "application/octet-stream";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isRetryableUploadError(error) {
|
|
527
|
+
const message = String(error?.message || "");
|
|
528
|
+
const code = String(error?.code || "");
|
|
529
|
+
if (["EPIPE", "ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EAI_AGAIN"].includes(code)) return true;
|
|
530
|
+
if (/EPIPE|ECONNRESET|ETIMEDOUT|socket hang up|bad record mac|tls alert|SSL routines/i.test(message)) return true;
|
|
531
|
+
const status = message.match(/\((\d{3})\)/)?.[1];
|
|
532
|
+
return status ? Number(status) === 429 || Number(status) >= 500 : false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function delay(ms) {
|
|
536
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function walk(dir, visit) {
|
|
540
|
+
let entries;
|
|
541
|
+
try {
|
|
542
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error.code === "ENOENT") return;
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
const full = path.join(dir, entry.name);
|
|
549
|
+
if (entry.isDirectory()) walk(full, visit);
|
|
550
|
+
else visit(full);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function safeStat(file) {
|
|
555
|
+
try {
|
|
556
|
+
return fs.statSync(file);
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function safeUnlink(file) {
|
|
563
|
+
try {
|
|
564
|
+
fs.unlinkSync(file);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
if (error.code !== "ENOENT") throw error;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function stripPrefix(key, prefix) {
|
|
571
|
+
const normalized = normalizePrefix(prefix);
|
|
572
|
+
return normalized && key.startsWith(`${normalized}/`) ? key.slice(normalized.length + 1) : key;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function normalizePrefix(prefix) {
|
|
576
|
+
return String(prefix || "").replace(/^\/+|\/+$/g, "");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function remoteObjectPrefix(target, cfg = {}, options = {}) {
|
|
580
|
+
const prefix = normalizePrefix(target.prefix || "agentlog");
|
|
581
|
+
if (options.snapshot) return [prefix, "snapshots", snapshotName(options), deviceSlug(cfg)].filter(Boolean).join("/");
|
|
582
|
+
return [prefix, "devices", deviceSlug(cfg)].filter(Boolean).join("/");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function snapshotName(options = {}) {
|
|
586
|
+
const explicit = value(options["snapshot-name"] || options.snapshotName || options.name);
|
|
587
|
+
if (explicit) return slugifyRemoteSegment(explicit);
|
|
588
|
+
if (!options._snapshotName) {
|
|
589
|
+
options._snapshotName = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
590
|
+
}
|
|
591
|
+
return options._snapshotName;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function deviceSlug(cfg = {}) {
|
|
595
|
+
return slugifyRemoteSegment(cfg.device?.slug || cfg.device?.name || cfg.device?.id || "this-device");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function deviceLabel(cfg = {}) {
|
|
599
|
+
const name = value(cfg.device?.name || "");
|
|
600
|
+
const slug = deviceSlug(cfg);
|
|
601
|
+
return name && name !== slug ? `${name} (${slug})` : slug;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function slugifyRemoteSegment(value) {
|
|
605
|
+
return String(value || "this-device")
|
|
606
|
+
.trim()
|
|
607
|
+
.toLowerCase()
|
|
608
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
609
|
+
.replace(/^-+|-+$/g, "")
|
|
610
|
+
.slice(0, 96) || "this-device";
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function normalizeSyncMode(value) {
|
|
614
|
+
const normalized = String(value || "upload").trim().toLowerCase();
|
|
615
|
+
return {
|
|
616
|
+
"upload-only": "upload",
|
|
617
|
+
upload: "upload",
|
|
618
|
+
push: "upload",
|
|
619
|
+
"two-way": "two-way",
|
|
620
|
+
twoway: "two-way",
|
|
621
|
+
bidirectional: "two-way",
|
|
622
|
+
receive: "receive",
|
|
623
|
+
"receive-only": "receive",
|
|
624
|
+
pull: "receive"
|
|
625
|
+
}[normalized] || normalized;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function normalizeSyncInterval(value) {
|
|
629
|
+
if (String(value || "").trim().toLowerCase() === "manual") return 0;
|
|
630
|
+
const number = Number(value);
|
|
631
|
+
if (!Number.isFinite(number) || number < 0) return 30;
|
|
632
|
+
return Math.round(number);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function fileUrlPath(endpoint) {
|
|
636
|
+
return fileURLToPath(endpoint);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function value(input) {
|
|
640
|
+
return String(input || "").trim();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function inferRemoteType(endpoint) {
|
|
644
|
+
const value = String(endpoint || "").toLowerCase();
|
|
645
|
+
if (value.startsWith("file://")) return "custom";
|
|
646
|
+
if (value.includes("r2.cloudflarestorage.com")) return "r2";
|
|
647
|
+
if (value.includes("amazonaws.com")) return "s3";
|
|
648
|
+
return "custom";
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function sha256Hex(value) {
|
|
652
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function hmac(key, value) {
|
|
656
|
+
return crypto.createHmac("sha256", key).update(value).digest();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function hmacHex(key, value) {
|
|
660
|
+
return crypto.createHmac("sha256", key).update(value).digest("hex");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function amzDate(date) {
|
|
664
|
+
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
module.exports = {
|
|
668
|
+
configureRemoteFromFlags,
|
|
669
|
+
hasRemoteTarget,
|
|
670
|
+
listLocalArchiveObjects,
|
|
671
|
+
parseListBucketResult,
|
|
672
|
+
remoteTargetFromFlags,
|
|
673
|
+
remoteObjectPrefix,
|
|
674
|
+
snapshotArchive,
|
|
675
|
+
syncArchive,
|
|
676
|
+
syncArchiveIfConfigured
|
|
677
|
+
};
|