trajectories-sh 1.18.9 → 1.21.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.
|
@@ -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`, {
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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
|
+
};
|
|
@@ -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 ?? "
|
|
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
|
|
118
|
+
for (const entry of readdirSync2(current, { withFileTypes: true })) {
|
|
25
119
|
if (SKIP.has(entry.name)) continue;
|
|
26
|
-
const full =
|
|
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 =
|
|
269
|
+
const tmpDir = join2(tmpdir(), `trajectories-webp-${randomBytes(4).toString("hex")}`);
|
|
176
270
|
function copyDir(src, dest) {
|
|
177
|
-
|
|
178
|
-
for (const entry of
|
|
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 =
|
|
181
|
-
const destPath =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
429
|
+
const trialResultPath = join2(dir, trial, "result.json");
|
|
336
430
|
try {
|
|
337
|
-
const data = JSON.parse(
|
|
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(
|
|
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
|
|
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
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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 =
|
|
712
|
+
const data = readFileSync2(file.absPath);
|
|
599
713
|
let lastError = "unknown error";
|
|
600
|
-
|
|
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
|
-
|
|
727
|
+
body = await resp.text();
|
|
612
728
|
if (body) lastError += ` \u2014 ${body.slice(0, 150).replace(/\s+/g, " ")}`;
|
|
613
729
|
} catch {
|
|
614
730
|
}
|
|
615
|
-
|
|
616
|
-
|
|
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 <
|
|
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.
|
|
3
|
+
"version": "1.21.0",
|
|
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": [
|