trajectories-sh 1.18.9 → 1.21.1

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.
@@ -9,6 +9,7 @@ import { join, dirname } from "path";
9
9
  var UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
10
10
  var BATCH_SIZE = 100;
11
11
  var CONCURRENCY = 25;
12
+ var DOWNLOAD_TIMEOUT_MS = 18e4;
12
13
  function parseJobId(input) {
13
14
  const trimmed = input.trim();
14
15
  if (UUID_RE.test(trimmed) && !trimmed.includes("/")) {
@@ -34,7 +35,10 @@ async function downloadJob(input, outputDir) {
34
35
  let jobId = parseJobId(input);
35
36
  if (jobId.startsWith("org:")) {
36
37
  const [, orgId, slug2] = jobId.split(":");
37
- const configResp = await fetch(`${apiUrl}/api/viewer/${orgId}/${slug2}/api/jobs`, { headers });
38
+ const configResp = await fetch(`${apiUrl}/api/viewer/${orgId}/${slug2}/api/jobs`, {
39
+ headers,
40
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
41
+ });
38
42
  if (!configResp.ok) throw new Error(`Could not resolve job: ${orgId}/${slug2}`);
39
43
  const configData = await configResp.json();
40
44
  const items = configData.items || [];
@@ -46,7 +50,7 @@ async function downloadJob(input, outputDir) {
46
50
  }
47
51
  const listResp = await fetch(`${apiUrl}/api/cli/download/${jobId}/files`, {
48
52
  headers,
49
- signal: AbortSignal.timeout(3e4)
53
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
50
54
  });
51
55
  if (!listResp.ok) {
52
56
  throw new Error(`Failed to list files: ${await listResp.text()}`);
@@ -66,7 +70,7 @@ async function downloadJob(input, outputDir) {
66
70
  method: "POST",
67
71
  headers: { ...headers, "Content-Type": "application/json" },
68
72
  body: JSON.stringify({ paths: batch.map((f) => f.path) }),
69
- signal: AbortSignal.timeout(3e4)
73
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
70
74
  });
71
75
  let urlMap = /* @__PURE__ */ new Map();
72
76
  if (urlResp.ok) {
@@ -85,7 +89,7 @@ async function downloadJob(input, outputDir) {
85
89
  try {
86
90
  const resp = await fetch(downloadUrl, {
87
91
  headers: downloadHeaders,
88
- signal: AbortSignal.timeout(12e4)
92
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
89
93
  });
90
94
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
91
95
  const data = Buffer.from(await resp.arrayBuffer());
@@ -121,7 +125,9 @@ async function downloadJob(input, outputDir) {
121
125
  bar.done();
122
126
  return { files: downloaded, totalSize };
123
127
  }
128
+
124
129
  export {
125
- downloadJob,
126
- parseJobId
130
+ DOWNLOAD_TIMEOUT_MS,
131
+ parseJobId,
132
+ downloadJob
127
133
  };
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import { readFileSync } from "fs";
6
6
  import { fileURLToPath } from "url";
7
7
  import { dirname, join } from "path";
8
8
  var __dirname = dirname(fileURLToPath(import.meta.url));
9
- var version = "1.8.0";
9
+ var version = "1.20.0";
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
12
12
  version = pkg.version;
@@ -98,7 +98,7 @@ async function uploadTrajectoryAction(pathArg, opts) {
98
98
  );
99
99
  }
100
100
  if (isZip) {
101
- const { uploadZip } = await import("./upload-2OUEVJOQ.js");
101
+ const { uploadZip } = await import("./upload-4QD3JFJE.js");
102
102
  const slug2 = opts.slug ?? pathArg.replace(/\.zip$/i, "").replace(/\/+$/, "").split("/").pop();
103
103
  console.log(chalk.bold(`
104
104
  Uploading ZIP ${pathArg} \u2192 ${chalk.cyan(slug2)}
@@ -127,7 +127,7 @@ async function uploadTrajectoryAction(pathArg, opts) {
127
127
  Uploading trajectory ${pathArg} \u2192 ${chalk.cyan(slug)}
128
128
  `));
129
129
  try {
130
- const { uploadJob } = await import("./upload-2OUEVJOQ.js");
130
+ const { uploadJob } = await import("./upload-4QD3JFJE.js");
131
131
  const result = await uploadJob(pathArg, {
132
132
  slug,
133
133
  name: opts.name
@@ -200,7 +200,7 @@ async function downloadTrajectoryAction(url, opts) {
200
200
  Downloading trajectory...
201
201
  `));
202
202
  try {
203
- const { downloadJob, parseJobId } = await import("./download-HTNOD7O4.js");
203
+ const { downloadJob, parseJobId } = await import("./download-4L7DZO3X.js");
204
204
  const outputDir = opts.output ?? parseJobId(url).replace(/^org:/, "").replace(/:/g, "-");
205
205
  const result = await downloadJob(url, outputDir);
206
206
  console.log(chalk.green.bold(` \u2713 Downloaded ${result.files} files`));
@@ -216,7 +216,7 @@ async function downloadTrajectoryAction(url, opts) {
216
216
  }
217
217
  async function deleteTrajectoryAction(url, opts) {
218
218
  const chalk = (await import("chalk")).default;
219
- const { parseJobId } = await import("./download-HTNOD7O4.js");
219
+ const { parseJobId } = await import("./download-4L7DZO3X.js");
220
220
  const { getApiUrl, getAuthHeader } = await import("./config-4XJRECIW.js");
221
221
  let jobId = parseJobId(url);
222
222
  if (jobId.startsWith("org:")) {
@@ -273,6 +273,91 @@ async function deleteTrajectoryAction(url, opts) {
273
273
  process.exit(1);
274
274
  }
275
275
  }
276
+ function printCommentError(error, json) {
277
+ const message = error?.message || String(error);
278
+ if (json) {
279
+ console.error(JSON.stringify({ error: { message, status: error?.status ?? null } }));
280
+ } else {
281
+ console.error(` \u2717 ${message}`);
282
+ }
283
+ process.exitCode = 1;
284
+ }
285
+ async function addCommentAction(targetInput, opts) {
286
+ try {
287
+ const { addComment, formatComment, parseCommentTarget, readCommentBody } = await import("./comments-RDCVHCZ7.js");
288
+ const target = parseCommentTarget(targetInput, opts);
289
+ const body = await readCommentBody(opts);
290
+ const comment2 = await addComment(target, body);
291
+ if (opts.json) console.log(JSON.stringify(comment2));
292
+ else console.log(`
293
+ \u2713 Comment added
294
+
295
+ ${formatComment(comment2)}
296
+ `);
297
+ } catch (error) {
298
+ printCommentError(error, Boolean(opts.json));
299
+ }
300
+ }
301
+ async function listCommentsAction(targetInput, opts) {
302
+ try {
303
+ const { formatComment, listComments, parseCommentTarget } = await import("./comments-RDCVHCZ7.js");
304
+ const target = parseCommentTarget(targetInput, opts);
305
+ const comments = await listComments(target);
306
+ if (opts.json) {
307
+ console.log(JSON.stringify(comments));
308
+ } else if (comments.length === 0) {
309
+ console.log(" No comments found.");
310
+ } else {
311
+ console.log(`
312
+ ${comments.map(formatComment).join("\n\n")}
313
+ `);
314
+ }
315
+ } catch (error) {
316
+ printCommentError(error, Boolean(opts.json));
317
+ }
318
+ }
319
+ async function updateCommentAction(commentId, opts) {
320
+ try {
321
+ const { formatComment, readCommentBody, updateComment } = await import("./comments-RDCVHCZ7.js");
322
+ const body = await readCommentBody(opts);
323
+ const comment2 = await updateComment(commentId, body);
324
+ if (opts.json) console.log(JSON.stringify(comment2));
325
+ else console.log(`
326
+ \u2713 Comment updated
327
+
328
+ ${formatComment(comment2)}
329
+ `);
330
+ } catch (error) {
331
+ printCommentError(error, Boolean(opts.json));
332
+ }
333
+ }
334
+ async function deleteCommentAction(commentId, opts) {
335
+ try {
336
+ if (!opts.yes) {
337
+ if (!process.stdin.isTTY) {
338
+ throw new Error("Refusing non-interactive deletion without --yes");
339
+ }
340
+ const readline = await import("readline");
341
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
342
+ const answer = await new Promise((resolve) => {
343
+ rl.question(` Delete comment ${commentId}? (y/N) `, resolve);
344
+ });
345
+ rl.close();
346
+ if (answer.toLowerCase() !== "y" && answer.toLowerCase() !== "yes") {
347
+ if (opts.json) console.log(JSON.stringify({ status: "cancelled", comment_id: commentId }));
348
+ else console.log(" Cancelled.");
349
+ return;
350
+ }
351
+ }
352
+ const { deleteComment } = await import("./comments-RDCVHCZ7.js");
353
+ await deleteComment(commentId);
354
+ const result = { status: "deleted", comment_id: commentId };
355
+ if (opts.json) console.log(JSON.stringify(result));
356
+ else console.log(` \u2713 Deleted comment ${commentId}`);
357
+ } catch (error) {
358
+ printCommentError(error, Boolean(opts.json));
359
+ }
360
+ }
276
361
  var upload = program.command("upload").description("Upload trajectories or tasks");
277
362
  upload.command("trajectory <path>").description("Upload a trajectory job directory or ZIP file (always uploaded as unlisted)").option("-s, --slug <slug>", "URL slug (default: directory name)").option("-n, --name <name>", "Display name (default: slug)").option("--api-key <key>", "API key (or set TRAJECTORIES_API_KEY in your env)").option("-v, --visibility <vis>", "(deprecated, ignored \u2014 set visibility in the UI)").action(uploadTrajectoryAction);
278
363
  upload.command("task <directory>").description("Upload a benchmark task directory").option("-n, --name <name>", "Task name (default: directory name)").option("--api-key <key>", "API key (or set TRAJECTORIES_API_KEY in your env)").option("-v, --visibility <vis>", "public or private", "public").action(uploadTaskAction);
@@ -280,6 +365,23 @@ var download = program.command("download").description("Download trajectories");
280
365
  download.command("trajectory <url>").description("Download a trajectory job from trajectories.sh").option("-o, --output <dir>", "Output directory (default: slug name)").action(downloadTrajectoryAction);
281
366
  var del = program.command("delete").description("Delete trajectories");
282
367
  del.command("trajectory <url>").description("Delete a trajectory job from trajectories.sh").option("-y, --yes", "Skip confirmation prompt").action(deleteTrajectoryAction);
368
+ var comment = program.command("comment").description("Manage trajectory comments");
369
+ function addCommentTargetOptions(command) {
370
+ return command.option("-t, --trial <trial>", "Trial UUID or trial name").option("-s, --step <step>", "ATIF step ID (not an array index)").option("--phase <phase>", "Step phase: action or result").option("--json", "Print machine-readable JSON");
371
+ }
372
+ function addCommentBodyOptions(command) {
373
+ return command.option("-m, --message <text>", "Comment body").option("-F, --file <path>", "Read comment body from a file; use - for stdin");
374
+ }
375
+ addCommentBodyOptions(addCommentTargetOptions(
376
+ comment.command("add <target>").description("Add a comment to a trajectory, trial, or step")
377
+ )).action(addCommentAction);
378
+ addCommentTargetOptions(
379
+ comment.command("list <target>").description("List comments at a trajectory, trial, or step")
380
+ ).action(listCommentsAction);
381
+ addCommentBodyOptions(
382
+ comment.command("update <comment-id>").description("Update the body of one of your comments").option("--json", "Print machine-readable JSON")
383
+ ).action(updateCommentAction);
384
+ comment.command("delete <comment-id>").description("Delete one of your comments").option("-y, --yes", "Skip confirmation prompt").option("--json", "Print machine-readable JSON").action(deleteCommentAction);
283
385
  program.command("whoami").description("Show current authentication status").action(async () => {
284
386
  const chalk = (await import("chalk")).default;
285
387
  const { status } = await import("./auth-4UEZSE4C.js");
@@ -293,4 +395,4 @@ program.command("whoami").description("Show current authentication status").acti
293
395
  console.log(chalk.dim(" Not logged in. Run: trajectories auth login"));
294
396
  }
295
397
  });
296
- program.parse();
398
+ await program.parseAsync();
@@ -0,0 +1,169 @@
1
+ import {
2
+ parseJobId
3
+ } from "./chunk-PF2EMA6Q.js";
4
+ import {
5
+ getApiUrl,
6
+ getAuthHeader
7
+ } from "./chunk-Q2ZEN43L.js";
8
+
9
+ // src/comments.ts
10
+ import { readFileSync } from "fs";
11
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ function parseCommentTarget(input, opts = {}) {
13
+ let urlTrial;
14
+ let urlStep;
15
+ let urlPhase;
16
+ try {
17
+ const url = new URL(input);
18
+ urlTrial = url.searchParams.get("trial") ?? void 0;
19
+ urlStep = url.searchParams.get("step") ?? void 0;
20
+ urlPhase = url.searchParams.get("phase") ?? void 0;
21
+ } catch {
22
+ }
23
+ const trial = opts.trial ?? urlTrial;
24
+ const rawStep = opts.step ?? urlStep;
25
+ const rawPhase = opts.phase ?? urlPhase ?? (rawStep !== void 0 ? "action" : void 0);
26
+ const target = { jobId: parseJobId(input) };
27
+ if (target.jobId.startsWith("org:")) {
28
+ throw new Error("Legacy trajectory URLs are not supported for comments; use a /t/<uuid> URL or job UUID");
29
+ }
30
+ if (trial) {
31
+ if (UUID_RE.test(trial)) target.trialId = trial;
32
+ else target.trialName = trial;
33
+ }
34
+ if (rawStep !== void 0) {
35
+ const stepId = typeof rawStep === "number" ? rawStep : Number(rawStep);
36
+ if (!Number.isInteger(stepId) || stepId < 0) {
37
+ throw new Error("Step must be a non-negative integer");
38
+ }
39
+ target.stepId = stepId;
40
+ }
41
+ if (rawPhase !== void 0) {
42
+ if (rawPhase !== "action" && rawPhase !== "result") {
43
+ throw new Error("Phase must be 'action' or 'result'");
44
+ }
45
+ target.phase = rawPhase;
46
+ }
47
+ if (target.stepId !== void 0 && !target.trialId && !target.trialName) {
48
+ throw new Error("A step comment requires --trial or a URL containing ?trial=");
49
+ }
50
+ if (target.phase && target.stepId === void 0) {
51
+ throw new Error("--phase requires --step or a URL containing ?step=");
52
+ }
53
+ return target;
54
+ }
55
+ async function readStdin() {
56
+ const chunks = [];
57
+ for await (const chunk of process.stdin) {
58
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
59
+ }
60
+ return Buffer.concat(chunks).toString("utf-8");
61
+ }
62
+ async function readCommentBody(opts) {
63
+ if (opts.message !== void 0 && opts.file !== void 0) {
64
+ throw new Error("Use only one comment source: --message, --file, or stdin");
65
+ }
66
+ let body;
67
+ if (opts.message !== void 0) {
68
+ body = opts.message;
69
+ } else if (opts.file !== void 0) {
70
+ body = opts.file === "-" ? await readStdin() : readFileSync(opts.file, "utf-8");
71
+ } else if (!process.stdin.isTTY) {
72
+ body = await readStdin();
73
+ } else {
74
+ throw new Error("Provide a comment with --message, --file, or piped stdin");
75
+ }
76
+ body = body.trim();
77
+ if (!body) throw new Error("Comment body cannot be empty");
78
+ return body;
79
+ }
80
+ function targetPayload(target) {
81
+ const payload = {};
82
+ if (target.trialId) payload.trial_id = target.trialId;
83
+ if (target.trialName) payload.trial_name = target.trialName;
84
+ if (target.stepId !== void 0) payload.step_id = target.stepId;
85
+ if (target.phase) payload.phase = target.phase;
86
+ return payload;
87
+ }
88
+ async function apiRequest(path, init = {}, requireAuth = false) {
89
+ const auth = await getAuthHeader();
90
+ if (requireAuth && !auth) {
91
+ throw new Error("Not authenticated. Run: trajectories auth login");
92
+ }
93
+ const headers = {
94
+ ...init.headers
95
+ };
96
+ if (auth) headers.Authorization = auth;
97
+ const response = await fetch(`${getApiUrl()}${path}`, { ...init, headers });
98
+ if (!response.ok) {
99
+ const responseText = await response.text();
100
+ let detail = responseText;
101
+ try {
102
+ const payload = JSON.parse(responseText);
103
+ if (typeof payload?.detail === "string") detail = payload.detail;
104
+ } catch {
105
+ }
106
+ const message = detail || `Request failed (${response.status})`;
107
+ const error = new Error(message);
108
+ error.status = response.status;
109
+ throw error;
110
+ }
111
+ return response.json();
112
+ }
113
+ async function addComment(target, body) {
114
+ return apiRequest(
115
+ `/api/org/trajectories/${target.jobId}/comments`,
116
+ {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify({ body, ...targetPayload(target) })
120
+ },
121
+ true
122
+ );
123
+ }
124
+ async function listComments(target) {
125
+ const params = new URLSearchParams();
126
+ if (target.trialId) params.set("trial_id", target.trialId);
127
+ if (target.trialName) params.set("trial_name", target.trialName);
128
+ if (target.stepId !== void 0) params.set("step_id", String(target.stepId));
129
+ if (target.phase) params.set("phase", target.phase);
130
+ const encoded = params.toString();
131
+ const query = encoded ? `?${encoded}` : "";
132
+ return apiRequest(
133
+ `/api/org/trajectories/${target.jobId}/comments${query}`
134
+ );
135
+ }
136
+ async function updateComment(commentId, body) {
137
+ return apiRequest(
138
+ `/api/org/trajectories/comments/${commentId}`,
139
+ {
140
+ method: "PATCH",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify({ body })
143
+ },
144
+ true
145
+ );
146
+ }
147
+ async function deleteComment(commentId) {
148
+ return apiRequest(
149
+ `/api/org/trajectories/comments/${commentId}`,
150
+ { method: "DELETE" },
151
+ true
152
+ );
153
+ }
154
+ function formatComment(comment) {
155
+ const scope = comment.step_id != null ? `step ${comment.step_id}${comment.phase ? ` (${comment.phase})` : ""}` : comment.trial_id ? `trial ${comment.trial_id}` : "trajectory";
156
+ const author = comment.author_name || comment.user_id;
157
+ const when = comment.updated_at || comment.created_at || "";
158
+ return `[${comment.id}] ${author} \xB7 ${scope}${when ? ` \xB7 ${when}` : ""}
159
+ ${comment.body}`;
160
+ }
161
+ export {
162
+ addComment,
163
+ deleteComment,
164
+ formatComment,
165
+ listComments,
166
+ parseCommentTarget,
167
+ readCommentBody,
168
+ updateComment
169
+ };
@@ -0,0 +1,11 @@
1
+ import {
2
+ DOWNLOAD_TIMEOUT_MS,
3
+ downloadJob,
4
+ parseJobId
5
+ } from "./chunk-PF2EMA6Q.js";
6
+ import "./chunk-Q2ZEN43L.js";
7
+ export {
8
+ DOWNLOAD_TIMEOUT_MS,
9
+ downloadJob,
10
+ parseJobId
11
+ };
@@ -7,13 +7,107 @@ import {
7
7
  } from "./chunk-Q2ZEN43L.js";
8
8
 
9
9
  // src/upload.ts
10
- import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from "fs";
11
- import { join, relative, dirname } from "path";
10
+ import { readdirSync as readdirSync2, statSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, copyFileSync, unlinkSync } from "fs";
11
+ import { join as join2, relative, dirname } from "path";
12
12
  import { tmpdir } from "os";
13
13
  import { randomBytes, createHash } from "crypto";
14
+
15
+ // src/screenshot-chunks.ts
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
17
+ import { extname, join } from "path";
18
+ var CHUNK_DIR = "_screenshot_chunks";
19
+ var MANIFEST_NAME = "manifest.json";
20
+ var MANIFEST_VERSION = 1;
21
+ var CHUNK_TARGET_BYTES = 4 * 1024 * 1024;
22
+ var IMAGE_MIME_BY_EXT = {
23
+ ".webp": "image/webp",
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif"
28
+ };
29
+ function mimeFor(path) {
30
+ return IMAGE_MIME_BY_EXT[extname(path).toLowerCase()] ?? null;
31
+ }
32
+ function referencedImagePaths(trajectory) {
33
+ const out = [];
34
+ const seen = /* @__PURE__ */ new Set();
35
+ const collect = (parts) => {
36
+ if (!Array.isArray(parts)) return;
37
+ for (const part of parts) {
38
+ if (!part || typeof part !== "object" || part.type !== "image") continue;
39
+ const path = part.source?.path;
40
+ if (typeof path === "string" && path && !seen.has(path)) {
41
+ seen.add(path);
42
+ out.push(path);
43
+ }
44
+ }
45
+ };
46
+ for (const step of Array.isArray(trajectory?.steps) ? trajectory.steps : []) {
47
+ if (!step || typeof step !== "object") continue;
48
+ collect(step.message);
49
+ for (const r of Array.isArray(step.observation?.results) ? step.observation.results : []) {
50
+ if (r && typeof r === "object") collect(r.content);
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function buildScreenshotChunks(jobDir, outDir, opts = {}) {
56
+ const targetBytes = opts.targetBytes ?? CHUNK_TARGET_BYTES;
57
+ const files = [];
58
+ let imagesPacked = 0;
59
+ let trialsPacked = 0;
60
+ for (const entry of readdirSync(jobDir, { withFileTypes: true })) {
61
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
62
+ const trial = entry.name;
63
+ const trajPath = join(jobDir, trial, "agent", "trajectory.json");
64
+ if (!existsSync(trajPath)) continue;
65
+ let paths;
66
+ try {
67
+ paths = referencedImagePaths(JSON.parse(readFileSync(trajPath, "utf-8")));
68
+ } catch {
69
+ continue;
70
+ }
71
+ const images = paths.map((p) => ({ p, abs: join(jobDir, trial, "agent", p), mime: mimeFor(p) })).filter((x) => x.mime && existsSync(x.abs));
72
+ if (images.length === 0) continue;
73
+ const relDir = `${trial}/${CHUNK_DIR}/agent`;
74
+ const absDir = join(outDir, relDir);
75
+ mkdirSync(absDir, { recursive: true });
76
+ const manifest = { version: MANIFEST_VERSION, chunks: [] };
77
+ let current = null;
78
+ const seal = () => {
79
+ if (!current) return;
80
+ const abs = join(absDir, current.name);
81
+ writeFileSync(abs, Buffer.concat(current.parts));
82
+ manifest.chunks.push({ name: current.name, size: current.size, entries: current.entries });
83
+ files.push({ absPath: abs, relPath: `${relDir}/${current.name}`, size: current.size });
84
+ current = null;
85
+ };
86
+ for (const { p, abs, mime } of images) {
87
+ const data = readFileSync(abs);
88
+ if (current && current.size + data.length > targetBytes) seal();
89
+ if (!current) {
90
+ current = { name: `chunk-${String(manifest.chunks.length).padStart(3, "0")}.bin`, parts: [], entries: [], size: 0 };
91
+ }
92
+ current.entries.push([p, current.size, data.length, mime]);
93
+ current.parts.push(data);
94
+ current.size += data.length;
95
+ imagesPacked++;
96
+ }
97
+ seal();
98
+ const manifestAbs = join(absDir, MANIFEST_NAME);
99
+ const manifestBytes = Buffer.from(JSON.stringify(manifest));
100
+ writeFileSync(manifestAbs, manifestBytes);
101
+ files.push({ absPath: manifestAbs, relPath: `${relDir}/${MANIFEST_NAME}`, size: manifestBytes.length });
102
+ trialsPacked++;
103
+ }
104
+ return { files, imagesPacked, trialsPacked };
105
+ }
106
+
107
+ // src/upload.ts
14
108
  var SKIP = /* @__PURE__ */ new Set(["__pycache__", ".DS_Store", ".git", "node_modules"]);
15
109
  var MAX_FILE_SIZE = 50 * 1024 * 1024;
16
- var CONCURRENCY = Number(process.env.TRAJECTORIES_CLI_CONCURRENCY ?? "256");
110
+ var CONCURRENCY = Number(process.env.TRAJECTORIES_CLI_CONCURRENCY ?? "24");
17
111
  var BATCH_SIZE = Number(process.env.TRAJECTORIES_CLI_BATCH_SIZE ?? "500");
18
112
  var WEBP_CONCURRENCY = Number(process.env.TRAJECTORIES_CLI_WEBP_CONCURRENCY ?? "4");
19
113
  var HASH_CONCURRENCY = Number(process.env.TRAJECTORIES_CLI_HASH_CONCURRENCY ?? "16");
@@ -21,9 +115,9 @@ function collectFiles(dir) {
21
115
  const files = [];
22
116
  const skipped = [];
23
117
  function walk(current) {
24
- for (const entry of readdirSync(current, { withFileTypes: true })) {
118
+ for (const entry of readdirSync2(current, { withFileTypes: true })) {
25
119
  if (SKIP.has(entry.name)) continue;
26
- const full = join(current, entry.name);
120
+ const full = join2(current, entry.name);
27
121
  if (entry.isDirectory()) {
28
122
  walk(full);
29
123
  } else if (entry.isFile()) {
@@ -172,13 +266,13 @@ async function convertToWebP(dir) {
172
266
  console.log(" \u26A0 sharp not available, skipping WebP conversion");
173
267
  return { convertedDir: dir, pngsConverted: 0, savedBytes: 0 };
174
268
  }
175
- const tmpDir = join(tmpdir(), `trajectories-webp-${randomBytes(4).toString("hex")}`);
269
+ const tmpDir = join2(tmpdir(), `trajectories-webp-${randomBytes(4).toString("hex")}`);
176
270
  function copyDir(src, dest) {
177
- mkdirSync(dest, { recursive: true });
178
- for (const entry of readdirSync(src, { withFileTypes: true })) {
271
+ mkdirSync2(dest, { recursive: true });
272
+ for (const entry of readdirSync2(src, { withFileTypes: true })) {
179
273
  if (SKIP.has(entry.name)) continue;
180
- const srcPath = join(src, entry.name);
181
- const destPath = join(dest, entry.name);
274
+ const srcPath = join2(src, entry.name);
275
+ const destPath = join2(dest, entry.name);
182
276
  if (entry.isDirectory()) {
183
277
  copyDir(srcPath, destPath);
184
278
  } else {
@@ -206,7 +300,7 @@ async function convertToWebP(dir) {
206
300
  let processed = 0;
207
301
  const total = pngs.length;
208
302
  async function convertOne(f) {
209
- const srcPath = join(tmpDir, f.relPath);
303
+ const srcPath = join2(tmpDir, f.relPath);
210
304
  const webpPath = srcPath.replace(/\.png$/i, ".webp");
211
305
  try {
212
306
  await pngToWebp(srcPath, webpPath);
@@ -238,10 +332,10 @@ async function convertToWebP(dir) {
238
332
  const trajFiles = collectFiles(tmpDir).files.filter((f) => f.relPath.endsWith("trajectory.json"));
239
333
  for (const tf of trajFiles) {
240
334
  try {
241
- let content = readFileSync(tf.absPath, "utf-8");
335
+ let content = readFileSync2(tf.absPath, "utf-8");
242
336
  const original = content;
243
337
  content = content.replace(/screenshots\/([^"]+)\.png/g, (match, name) => {
244
- const webpPath = join(dirname(tf.absPath), "screenshots", `${name}.webp`);
338
+ const webpPath = join2(dirname(tf.absPath), "screenshots", `${name}.webp`);
245
339
  try {
246
340
  statSync(webpPath);
247
341
  return `screenshots/${name}.webp`;
@@ -250,7 +344,7 @@ async function convertToWebP(dir) {
250
344
  }
251
345
  });
252
346
  if (content !== original) {
253
- writeFileSync(tf.absPath, content);
347
+ writeFileSync2(tf.absPath, content);
254
348
  }
255
349
  } catch {
256
350
  }
@@ -296,7 +390,7 @@ function validateAtifFormat(dir) {
296
390
  errors.push("Missing result.json at the job root");
297
391
  } else {
298
392
  try {
299
- const jobResult = JSON.parse(readFileSync(join(dir, "result.json"), "utf-8"));
393
+ const jobResult = JSON.parse(readFileSync2(join2(dir, "result.json"), "utf-8"));
300
394
  const jobRequired = [
301
395
  ["n_total_trials", "n_total_trials (number of trials)"],
302
396
  ["started_at", "started_at (ISO timestamp)"],
@@ -332,9 +426,9 @@ function validateAtifFormat(dir) {
332
426
  return errors;
333
427
  }
334
428
  for (const trial of trialDirs) {
335
- const trialResultPath = join(dir, trial, "result.json");
429
+ const trialResultPath = join2(dir, trial, "result.json");
336
430
  try {
337
- const data = JSON.parse(readFileSync(trialResultPath, "utf-8"));
431
+ const data = JSON.parse(readFileSync2(trialResultPath, "utf-8"));
338
432
  const required = [
339
433
  ["task_name", "task_name (string)"],
340
434
  ["started_at", "started_at (ISO timestamp)"],
@@ -364,7 +458,7 @@ function validateAtifFormat(dir) {
364
458
  errors.push(`Trial "${trial}": missing agent/trajectory.json`);
365
459
  } else {
366
460
  try {
367
- const trajData = JSON.parse(readFileSync(join(dir, trajectoryRelPath), "utf-8"));
461
+ const trajData = JSON.parse(readFileSync2(join2(dir, trajectoryRelPath), "utf-8"));
368
462
  const steps = trajData.steps ?? [];
369
463
  const missingImages = [];
370
464
  for (const step of steps) {
@@ -445,7 +539,16 @@ ${samples}${more}
445
539
  const t0 = Date.now();
446
540
  const contentHash = await computeContentHash(files);
447
541
  console.log(` Hash: ${contentHash.slice(0, 16)}... (${((Date.now() - t0) / 1e3).toFixed(1)}s)`);
448
- const initResp = await apiPost("/api/cli/push/init", { slug, name, visibility, estimated_bytes: totalSize, content_hash: contentHash });
542
+ const chunkDir = join2(tmpdir(), `trajectories-chunks-${randomBytes(4).toString("hex")}`);
543
+ const chunkBuild = buildScreenshotChunks(convertedDir, chunkDir);
544
+ const uploadFiles = [...files, ...chunkBuild.files];
545
+ const chunkBytes = chunkBuild.files.reduce((s, f) => s + f.size, 0);
546
+ if (chunkBuild.imagesPacked > 0) {
547
+ console.log(
548
+ ` \u2713 Packed ${chunkBuild.imagesPacked} screenshots into ${chunkBuild.files.length - chunkBuild.trialsPacked} chunks (${(chunkBytes / 1024 / 1024).toFixed(1)} MB) across ${chunkBuild.trialsPacked} trial${chunkBuild.trialsPacked === 1 ? "" : "s"}`
549
+ );
550
+ }
551
+ const initResp = await apiPost("/api/cli/push/init", { slug, name, visibility, estimated_bytes: totalSize + chunkBytes, content_hash: contentHash });
449
552
  if (!initResp.ok) {
450
553
  throw new Error(`Init failed: ${await initResp.text()}`);
451
554
  }
@@ -459,26 +562,37 @@ ${samples}${more}
459
562
  console.log(` \u24D8 This content was already uploaded as '${initBody.existing_duplicate.slug}' \u2014 uploading anyway.`);
460
563
  console.log(` Existing: ${existingDuplicateUrl}`);
461
564
  }
462
- await uploadDirect(jobId, files);
463
- console.log(" Finalizing...");
464
- const finalResp = await apiPost(`/api/cli/push/${jobId}/finalize`, {});
465
- if (!finalResp.ok) {
466
- let detail;
565
+ const { rmSync } = await import("fs");
566
+ const cleanupTemp = () => {
467
567
  try {
468
- const body = await finalResp.json();
469
- detail = body.detail || JSON.stringify(body);
568
+ rmSync(chunkDir, { recursive: true, force: true });
470
569
  } catch {
471
- detail = await finalResp.text();
472
570
  }
473
- throw new Error(`Finalize failed (${finalResp.status}): ${detail}`);
474
- }
475
- const result = await finalResp.json();
476
- if (convertedDir !== dir) {
477
- try {
478
- const { rmSync } = await import("fs");
479
- rmSync(convertedDir, { recursive: true, force: true });
480
- } catch {
571
+ if (convertedDir !== dir) {
572
+ try {
573
+ rmSync(convertedDir, { recursive: true, force: true });
574
+ } catch {
575
+ }
481
576
  }
577
+ };
578
+ let result;
579
+ try {
580
+ await uploadDirect(jobId, uploadFiles);
581
+ console.log(" Finalizing...");
582
+ const finalResp = await apiPost(`/api/cli/push/${jobId}/finalize`, {});
583
+ if (!finalResp.ok) {
584
+ let detail;
585
+ try {
586
+ const body = await finalResp.json();
587
+ detail = body.detail || JSON.stringify(body);
588
+ } catch {
589
+ detail = await finalResp.text();
590
+ }
591
+ throw new Error(`Finalize failed (${finalResp.status}): ${detail}`);
592
+ }
593
+ result = await finalResp.json();
594
+ } finally {
595
+ cleanupTemp();
482
596
  }
483
597
  const apiUrl = getApiUrl();
484
598
  const siteUrl = apiUrl.includes("localhost") ? "http://localhost:3000" : apiUrl.replace("api.", "").replace(/\/$/, "");
@@ -595,9 +709,10 @@ async function fetchSignedUrlsForBatch(jobId, paths) {
595
709
  return { urlMap, errors };
596
710
  }
597
711
  async function uploadOneFileWithError(file, signedUrl) {
598
- const data = readFileSync(file.absPath);
712
+ const data = readFileSync2(file.absPath);
599
713
  let lastError = "unknown error";
600
- for (let attempt = 0; attempt < 5; attempt++) {
714
+ const MAX_ATTEMPTS = 8;
715
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
601
716
  try {
602
717
  const resp = await fetch(signedUrl, {
603
718
  method: "PUT",
@@ -607,22 +722,37 @@ async function uploadOneFileWithError(file, signedUrl) {
607
722
  });
608
723
  if (resp.ok) return { ok: true };
609
724
  lastError = `HTTP ${resp.status} ${resp.statusText}`;
725
+ let body = "";
610
726
  try {
611
- const body = await resp.text();
727
+ body = await resp.text();
612
728
  if (body) lastError += ` \u2014 ${body.slice(0, 150).replace(/\s+/g, " ")}`;
613
729
  } catch {
614
730
  }
615
- if (resp.status === 503 || resp.status === 429) {
616
- await sleep(2e3 * (attempt + 1));
731
+ const isTransient = isTransientUploadError(resp.status, body);
732
+ if (isTransient) {
733
+ const base = Math.min(2e3 * Math.pow(2, attempt), 3e4);
734
+ const jitter = base * (0.8 + Math.random() * 0.4);
735
+ await sleep(jitter);
617
736
  continue;
618
737
  }
619
738
  } catch (e) {
620
739
  lastError = `fetch error: ${e?.name ?? "Error"} ${e?.message ?? e}`;
740
+ const base = Math.min(2e3 * Math.pow(2, attempt), 3e4);
741
+ const jitter = base * (0.8 + Math.random() * 0.4);
742
+ if (attempt < MAX_ATTEMPTS - 1) await sleep(jitter);
743
+ continue;
621
744
  }
622
- if (attempt < 4) await sleep(1e3 * (attempt + 1));
745
+ if (attempt < MAX_ATTEMPTS - 1) await sleep(1e3 * (attempt + 1));
623
746
  }
624
747
  return { ok: false, error: lastError };
625
748
  }
749
+ function isTransientUploadError(status, body) {
750
+ if (status === 429 || status === 502 || status === 503 || status === 504) return true;
751
+ if (status === 544) return true;
752
+ if (!body) return false;
753
+ const lower = body.toLowerCase();
754
+ return lower.includes("databaseconnectionlimit") || lower.includes("databasetimeout") || lower.includes('"statuscode":"503"') || lower.includes('"statuscode":"504"') || lower.includes('"statuscode":"544"') || lower.includes("too many connections") || lower.includes("connection timed out");
755
+ }
626
756
  function sleep(ms) {
627
757
  return new Promise((r) => setTimeout(r, ms));
628
758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trajectories-sh",
3
- "version": "1.18.9",
3
+ "version": "1.21.1",
4
4
  "description": "CLI for uploading trajectory jobs to trajectories.sh",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "scripts": {
11
11
  "build": "tsup src/cli.ts --format esm --target node18 --clean",
12
12
  "dev": "tsx src/cli.ts",
13
+ "test": "tsx --test src/*.test.ts",
13
14
  "prepublishOnly": "npm run build"
14
15
  },
15
16
  "files": [