@sanlabs/sanbox-cli 0.0.1 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -7
- package/dist/activity.js +72 -0
- package/dist/api.js +109 -5
- package/dist/args.js +23 -7
- package/dist/artifacts.js +124 -0
- package/dist/cli.js +1219 -86
- package/dist/config.js +57 -9
- package/dist/errors.js +24 -0
- package/dist/inputs.js +150 -0
- package/dist/output.js +77 -1
- package/dist/runs.js +13 -25
- package/dist/version.js +1 -1
- package/dist/watch.js +170 -0
- package/package.json +9 -6
- package/dist/dossier.js +0 -108
- package/dist/mcp.js +0 -131
package/dist/config.js
CHANGED
|
@@ -1,12 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CliError, commandAction } from "./errors.js";
|
|
4
|
+
export const defaultApiUrl = "https://console.sanbox.cloud";
|
|
5
|
+
export const readLocalConfig = (cwd = process.cwd()) => {
|
|
6
|
+
const configPath = path.join(cwd, ".sanbox", "config.json");
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
9
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
10
|
+
return {};
|
|
11
|
+
const record = parsed;
|
|
12
|
+
return {
|
|
13
|
+
api_url: typeof record.api_url === "string" ? record.api_url : undefined,
|
|
14
|
+
org: typeof record.org === "string" ? record.org : undefined,
|
|
15
|
+
default_template: typeof record.default_template === "string" ? record.default_template : undefined
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return {};
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export const readConfig = (flags = {}, options = {}) => {
|
|
25
|
+
const localConfig = readLocalConfig();
|
|
26
|
+
const apiUrl = String(flags["api-url"] || process.env.SANBOX_API_URL || localConfig.api_url || defaultApiUrl).replace(/\/+$/, "");
|
|
27
|
+
const org = String(flags.org || process.env.SANBOX_ORG || localConfig.org || "").trim();
|
|
6
28
|
const apiKey = process.env.SANBOX_API_KEY || "";
|
|
7
|
-
if (!org)
|
|
8
|
-
throw new
|
|
9
|
-
|
|
10
|
-
|
|
29
|
+
if (options.requireOrg !== false && !org) {
|
|
30
|
+
throw new CliError("org_required", "Organization context is required.", {
|
|
31
|
+
nextActions: [commandAction(["sanbox", "context", "--json"], "Select an organization and retry with SANBOX_ORG set.", { SANBOX_ORG: "<org-slug>" })]
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (!apiKey) {
|
|
35
|
+
throw new CliError("api_key_required", "SANBOX_API_KEY is required.", {
|
|
36
|
+
nextActions: [commandAction(["sanbox", "auth", "check", "--json"], "Set a Sanbox control-plane API key and check access.", { SANBOX_API_KEY: "<sanbox-api-key>" })]
|
|
37
|
+
});
|
|
38
|
+
}
|
|
11
39
|
return { apiUrl, org, apiKey };
|
|
12
40
|
};
|
|
41
|
+
export function readTemplateSelection(flags = {}, options = {}) {
|
|
42
|
+
const flagValue = flags.template;
|
|
43
|
+
if (typeof flagValue === "string" && flagValue.trim())
|
|
44
|
+
return { id: flagValue.trim(), source: "flag" };
|
|
45
|
+
const envValue = process.env.SANBOX_TEMPLATE?.trim();
|
|
46
|
+
if (envValue)
|
|
47
|
+
return { id: envValue, source: "environment" };
|
|
48
|
+
const configValue = readLocalConfig(options.cwd).default_template?.trim();
|
|
49
|
+
if (configValue)
|
|
50
|
+
return { id: configValue, source: "project_config" };
|
|
51
|
+
if (options.required === false)
|
|
52
|
+
return null;
|
|
53
|
+
throw new CliError("template_required", "A template must be selected explicitly.", {
|
|
54
|
+
nextActions: [
|
|
55
|
+
commandAction(["sanbox", "templates", "list", "--json"], "List templates available to the selected organization."),
|
|
56
|
+
commandAction(["sanbox", "run", "<task>", "--template", "<template-id>"], "Run with an explicit template."),
|
|
57
|
+
commandAction(["sanbox", "context", "--json"], "Select a template for this shell and inspect the resolved context.", { SANBOX_TEMPLATE: "<template-id>" })
|
|
58
|
+
]
|
|
59
|
+
});
|
|
60
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
options;
|
|
4
|
+
constructor(code, message, options = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.options = options;
|
|
8
|
+
this.name = "CliError";
|
|
9
|
+
}
|
|
10
|
+
get exitCode() {
|
|
11
|
+
return this.options.exitCode ?? 1;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const commandAction = (argv, description, env) => ({
|
|
15
|
+
type: "command",
|
|
16
|
+
argv,
|
|
17
|
+
...(env ? { env } : {}),
|
|
18
|
+
description
|
|
19
|
+
});
|
|
20
|
+
export const consoleAction = (url, description) => ({
|
|
21
|
+
type: "console",
|
|
22
|
+
url,
|
|
23
|
+
description
|
|
24
|
+
});
|
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import fg from "fast-glob";
|
|
6
|
+
import ignore from "ignore";
|
|
7
|
+
import yazl from "yazl";
|
|
8
|
+
const maximumInputBytes = 64 * 1024 * 1024;
|
|
9
|
+
const maximumInputFiles = 10_000;
|
|
10
|
+
const defaultIgnorePatterns = [
|
|
11
|
+
".git/**",
|
|
12
|
+
"**/.git/**",
|
|
13
|
+
"node_modules/**",
|
|
14
|
+
"**/node_modules/**",
|
|
15
|
+
"dist/**",
|
|
16
|
+
"**/dist/**",
|
|
17
|
+
"build/**",
|
|
18
|
+
"**/build/**",
|
|
19
|
+
"coverage/**",
|
|
20
|
+
"**/coverage/**",
|
|
21
|
+
".next/**",
|
|
22
|
+
"**/.next/**",
|
|
23
|
+
".turbo/**",
|
|
24
|
+
"**/.turbo/**",
|
|
25
|
+
".env",
|
|
26
|
+
".env.*",
|
|
27
|
+
"**/.env",
|
|
28
|
+
"**/.env.*",
|
|
29
|
+
"**/*secret*",
|
|
30
|
+
"**/*Secret*",
|
|
31
|
+
"**/*token*",
|
|
32
|
+
"**/*Token*",
|
|
33
|
+
"**/*credential*",
|
|
34
|
+
"**/*Credential*",
|
|
35
|
+
"**/*.pem",
|
|
36
|
+
"**/*.key",
|
|
37
|
+
"**/id_rsa",
|
|
38
|
+
"**/id_ed25519"
|
|
39
|
+
];
|
|
40
|
+
const normalizePattern = async (cwd, pattern) => {
|
|
41
|
+
const slashPattern = pattern.replaceAll(path.sep, "/");
|
|
42
|
+
const normalizedPath = path.posix.normalize(slashPattern);
|
|
43
|
+
if (path.isAbsolute(pattern) || normalizedPath === ".." || normalizedPath.startsWith("../")) {
|
|
44
|
+
throw new Error(`Input path must be relative to the current directory: ${pattern}`);
|
|
45
|
+
}
|
|
46
|
+
if (pattern === ".")
|
|
47
|
+
return "**/*";
|
|
48
|
+
const normalized = normalizedPath.replace(/\/+$/, "");
|
|
49
|
+
if (normalized.includes("*"))
|
|
50
|
+
return normalized;
|
|
51
|
+
try {
|
|
52
|
+
const stat = await fs.stat(path.resolve(cwd, normalized));
|
|
53
|
+
if (stat.isDirectory())
|
|
54
|
+
return `${normalized}/**`;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code !== "ENOENT")
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
};
|
|
62
|
+
const readSanboxIgnore = async (cwd) => {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await fs.readFile(path.join(cwd, ".sanboxignore"), "utf8");
|
|
65
|
+
return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === "ENOENT")
|
|
69
|
+
return [];
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const selectedInputs = async (cwd, inputs) => {
|
|
74
|
+
const patterns = await Promise.all(inputs.map((input) => normalizePattern(cwd, input)));
|
|
75
|
+
const ignored = ignore().add([...defaultIgnorePatterns, ...(await readSanboxIgnore(cwd))]);
|
|
76
|
+
const candidates = await fg(patterns, {
|
|
77
|
+
cwd,
|
|
78
|
+
dot: true,
|
|
79
|
+
onlyFiles: true,
|
|
80
|
+
followSymbolicLinks: false,
|
|
81
|
+
unique: true
|
|
82
|
+
});
|
|
83
|
+
const normalizedCandidates = candidates
|
|
84
|
+
.map((item) => item.replaceAll(path.sep, "/"))
|
|
85
|
+
.filter((item) => !item.startsWith("../") && !path.isAbsolute(item))
|
|
86
|
+
.filter((item) => !ignored.ignores(item))
|
|
87
|
+
.sort();
|
|
88
|
+
const files = [];
|
|
89
|
+
for (const item of normalizedCandidates) {
|
|
90
|
+
const stat = await fs.lstat(path.join(cwd, item));
|
|
91
|
+
if (stat.isFile())
|
|
92
|
+
files.push(item);
|
|
93
|
+
}
|
|
94
|
+
return { patterns, files };
|
|
95
|
+
};
|
|
96
|
+
const zipToBuffer = async (zip) => new Promise((resolve, reject) => {
|
|
97
|
+
const chunks = [];
|
|
98
|
+
zip.outputStream.on("data", (chunk) => chunks.push(chunk));
|
|
99
|
+
zip.outputStream.on("error", reject);
|
|
100
|
+
zip.outputStream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
101
|
+
zip.end();
|
|
102
|
+
});
|
|
103
|
+
export const sha256 = (buffer) => crypto.createHash("sha256").update(buffer).digest("hex");
|
|
104
|
+
export const previewInputs = async (input) => {
|
|
105
|
+
const { patterns, files } = await selectedInputs(input.cwd, input.inputs);
|
|
106
|
+
const previewFiles = [];
|
|
107
|
+
let totalBytes = 0;
|
|
108
|
+
for (const relative of files) {
|
|
109
|
+
const stat = await fs.stat(path.join(input.cwd, relative));
|
|
110
|
+
if (!stat.isFile())
|
|
111
|
+
continue;
|
|
112
|
+
totalBytes += stat.size;
|
|
113
|
+
previewFiles.push({ path: relative, size: stat.size });
|
|
114
|
+
}
|
|
115
|
+
return { patterns, files: previewFiles, totalBytes };
|
|
116
|
+
};
|
|
117
|
+
export const buildInputBundle = async (input) => {
|
|
118
|
+
const { files } = await selectedInputs(input.cwd, input.inputs);
|
|
119
|
+
if (files.length === 0)
|
|
120
|
+
throw new Error("No files matched the supplied --input values.");
|
|
121
|
+
if (files.length > maximumInputFiles)
|
|
122
|
+
throw new Error(`Inputs exceed the ${maximumInputFiles}-file limit.`);
|
|
123
|
+
const zip = new yazl.ZipFile();
|
|
124
|
+
const metadata = [];
|
|
125
|
+
let totalBytes = 0;
|
|
126
|
+
for (const relative of files) {
|
|
127
|
+
const absolute = path.join(input.cwd, relative);
|
|
128
|
+
const file = await fs.open(absolute, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
129
|
+
try {
|
|
130
|
+
const stat = await file.stat();
|
|
131
|
+
if (!stat.isFile())
|
|
132
|
+
continue;
|
|
133
|
+
if (totalBytes + stat.size > maximumInputBytes)
|
|
134
|
+
throw new Error("Inputs exceed the 64 MiB uncompressed limit.");
|
|
135
|
+
const content = await file.readFile();
|
|
136
|
+
totalBytes += content.byteLength;
|
|
137
|
+
if (totalBytes > maximumInputBytes)
|
|
138
|
+
throw new Error("Inputs exceed the 64 MiB uncompressed limit.");
|
|
139
|
+
metadata.push({ path: relative, size: content.byteLength, sha256: sha256(content) });
|
|
140
|
+
zip.addBuffer(content, `input/${relative}`, { mode: stat.mode });
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await file.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const buffer = await zipToBuffer(zip);
|
|
147
|
+
if (buffer.byteLength > maximumInputBytes)
|
|
148
|
+
throw new Error("Inputs exceed the 64 MiB upload limit.");
|
|
149
|
+
return { buffer, sha256: sha256(buffer), files: metadata };
|
|
150
|
+
};
|
package/dist/output.js
CHANGED
|
@@ -1,9 +1,85 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { SanboxApiError } from "./api.js";
|
|
1
3
|
export const printJson = (value) => {
|
|
2
4
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
3
5
|
};
|
|
6
|
+
export const successEnvelope = (command, data, context = {}, nextActions = []) => ({
|
|
7
|
+
schema_version: 1,
|
|
8
|
+
ok: true,
|
|
9
|
+
command,
|
|
10
|
+
context,
|
|
11
|
+
data,
|
|
12
|
+
next_actions: nextActions
|
|
13
|
+
});
|
|
14
|
+
export const errorEnvelope = (command, error, context = {}) => {
|
|
15
|
+
if (error instanceof CliError) {
|
|
16
|
+
return {
|
|
17
|
+
schema_version: 1,
|
|
18
|
+
ok: false,
|
|
19
|
+
command,
|
|
20
|
+
context,
|
|
21
|
+
error: {
|
|
22
|
+
code: error.code,
|
|
23
|
+
message: error.message,
|
|
24
|
+
...(error.options.status === undefined ? {} : { status: error.options.status }),
|
|
25
|
+
...(error.options.details === undefined ? {} : { details: error.options.details })
|
|
26
|
+
},
|
|
27
|
+
next_actions: error.options.nextActions ?? []
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (error instanceof SanboxApiError) {
|
|
31
|
+
return {
|
|
32
|
+
schema_version: 1,
|
|
33
|
+
ok: false,
|
|
34
|
+
command,
|
|
35
|
+
context,
|
|
36
|
+
error: {
|
|
37
|
+
code: error.code,
|
|
38
|
+
message: error.message,
|
|
39
|
+
status: error.status
|
|
40
|
+
},
|
|
41
|
+
next_actions: []
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
schema_version: 1,
|
|
46
|
+
ok: false,
|
|
47
|
+
command,
|
|
48
|
+
context,
|
|
49
|
+
error: {
|
|
50
|
+
code: "cli_error",
|
|
51
|
+
message: error instanceof Error ? error.message : String(error)
|
|
52
|
+
},
|
|
53
|
+
next_actions: []
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
export const printSuccess = (command, data, context = {}, nextActions = []) => printJson(successEnvelope(command, data, context, nextActions));
|
|
57
|
+
export const printError = (command, error, context = {}) => {
|
|
58
|
+
process.stderr.write(`${JSON.stringify(errorEnvelope(command, error, context), null, 2)}\n`);
|
|
59
|
+
};
|
|
60
|
+
export const printJsonlError = (command, error, context = {}) => {
|
|
61
|
+
process.stderr.write(`${JSON.stringify(errorEnvelope(command, error, context))}\n`);
|
|
62
|
+
};
|
|
63
|
+
export const publicRun = (run) => {
|
|
64
|
+
const { workload_id: workloadId, ...rest } = run;
|
|
65
|
+
return {
|
|
66
|
+
...rest,
|
|
67
|
+
template_id: run.template_id || workloadId || null
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
export const publicRunPayload = (payload) => ({
|
|
71
|
+
run: publicRun(payload.run),
|
|
72
|
+
events: payload.events
|
|
73
|
+
});
|
|
4
74
|
export const summarizeRun = (payload) => {
|
|
5
75
|
const { run } = payload;
|
|
6
|
-
|
|
76
|
+
const templateId = run.template_id || run.workload_id;
|
|
77
|
+
const selection = [
|
|
78
|
+
templateId ? `template=${templateId}` : "",
|
|
79
|
+
run.provider_id ? `provider=${run.provider_id}` : "",
|
|
80
|
+
run.model_id ? `model=${run.model_id}` : ""
|
|
81
|
+
].filter(Boolean).join(" ");
|
|
82
|
+
return `${run.id} ${run.status}${selection ? ` ${selection}` : ""}${run.exit_code === null ? "" : ` exit=${run.exit_code}`}${run.error ? ` error=${run.error}` : ""}`;
|
|
7
83
|
};
|
|
8
84
|
export const printRun = (payload) => {
|
|
9
85
|
process.stdout.write(`${summarizeRun(payload)}\n`);
|
package/dist/runs.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { buildTaskDossier, readDossierFile } from "./dossier.js";
|
|
4
|
+
import { buildInputBundle } from "./inputs.js";
|
|
6
5
|
const terminalStatuses = new Set(["completed", "failed", "canceled", "expired"]);
|
|
7
|
-
const inlineLimitBytes = 8 * 1024 * 1024;
|
|
8
6
|
export const isTerminalRun = (run) => terminalStatuses.has(run.status);
|
|
9
7
|
export const waitForRun = async (client, runId, options = {}) => {
|
|
10
8
|
const pollIntervalMs = options.pollIntervalMs ?? 2000;
|
|
@@ -19,31 +17,21 @@ export const waitForRun = async (client, runId, options = {}) => {
|
|
|
19
17
|
return payload;
|
|
20
18
|
};
|
|
21
19
|
export const createRun = async (client, options) => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
let inputCollectionId;
|
|
21
|
+
if (options.inputs.length > 0) {
|
|
22
|
+
const bundle = await buildInputBundle({ cwd: options.cwd, inputs: options.inputs });
|
|
23
|
+
const uploaded = await client.uploadInputCollection({
|
|
24
|
+
buffer: bundle.buffer,
|
|
25
|
+
sha256: bundle.sha256,
|
|
26
|
+
fileCount: bundle.files.length
|
|
29
27
|
});
|
|
30
|
-
|
|
31
|
-
throw new Error(`Dossier is ${dossier.buffer.byteLength} bytes; inline dossier limit is ${inlineLimitBytes} bytes.`);
|
|
28
|
+
inputCollectionId = uploaded.input_collection.id;
|
|
32
29
|
}
|
|
33
|
-
const manifest = {
|
|
34
|
-
dossier: {
|
|
35
|
-
inline_base64: dossier.buffer.toString("base64"),
|
|
36
|
-
sha256: dossier.sha256
|
|
37
|
-
},
|
|
38
|
-
sanbox_cli: {
|
|
39
|
-
files: dossier.files,
|
|
40
|
-
source: options.dossierPath ? "dossier_file" : "generated_task"
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
30
|
return client.createRun({
|
|
44
31
|
external_run_id: options.externalRunId,
|
|
45
|
-
workload_id: options.
|
|
46
|
-
|
|
32
|
+
workload_id: options.templateId,
|
|
33
|
+
instruction: options.instruction,
|
|
34
|
+
...(inputCollectionId ? { input_collection_id: inputCollectionId } : {}),
|
|
47
35
|
retention_ttl_seconds: options.retentionTtlSeconds
|
|
48
36
|
});
|
|
49
37
|
};
|
|
@@ -65,7 +53,7 @@ export const readTasks = async (tasksPath) => {
|
|
|
65
53
|
return {
|
|
66
54
|
task,
|
|
67
55
|
external_run_id: record.external_run_id ? String(record.external_run_id) : undefined,
|
|
68
|
-
|
|
56
|
+
input: Array.isArray(record.input) ? record.input.map(String) : undefined
|
|
69
57
|
};
|
|
70
58
|
});
|
|
71
59
|
};
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.0.
|
|
1
|
+
export const version = "0.0.4";
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { SanboxApiError } from "./api.js";
|
|
2
|
+
const terminalStatuses = new Set(["completed", "failed", "canceled", "expired"]);
|
|
3
|
+
const terminalEventKinds = new Set(["run.completed", "run.failed", "run.canceled", "run.expired"]);
|
|
4
|
+
const retryableNetworkCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENETUNREACH"]);
|
|
5
|
+
export class WatchInterruptedError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super("Watch interrupted.");
|
|
8
|
+
this.name = "WatchInterruptedError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const defaultSleep = (milliseconds, signal) => new Promise((resolve, reject) => {
|
|
12
|
+
if (signal?.aborted) {
|
|
13
|
+
reject(new WatchInterruptedError());
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
signal?.removeEventListener("abort", onAbort);
|
|
18
|
+
resolve();
|
|
19
|
+
}, milliseconds);
|
|
20
|
+
const onAbort = () => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
reject(new WatchInterruptedError());
|
|
23
|
+
};
|
|
24
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
25
|
+
});
|
|
26
|
+
const isRetryable = (error) => {
|
|
27
|
+
if (error instanceof SanboxApiError)
|
|
28
|
+
return error.status === 429 || error.status >= 500;
|
|
29
|
+
if (error instanceof TypeError)
|
|
30
|
+
return true;
|
|
31
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
32
|
+
return retryableNetworkCodes.has(code);
|
|
33
|
+
};
|
|
34
|
+
const assertActive = (signal, deadline, now, runId) => {
|
|
35
|
+
if (signal?.aborted)
|
|
36
|
+
throw new WatchInterruptedError();
|
|
37
|
+
if (now() >= deadline)
|
|
38
|
+
throw new Error(`Timed out watching run ${runId}.`);
|
|
39
|
+
};
|
|
40
|
+
export const isTerminalStatus = (status) => terminalStatuses.has(status);
|
|
41
|
+
export const watchEventsUntil = async (client, runId, options) => {
|
|
42
|
+
const pageSize = Math.max(1, Math.min(500, Math.floor(options.pageSize ?? 200)));
|
|
43
|
+
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 2000));
|
|
44
|
+
const timeoutSeconds = Math.max(1, options.timeoutSeconds ?? 1800);
|
|
45
|
+
const maxReconnectDelayMs = Math.max(250, options.maxReconnectDelayMs ?? 10_000);
|
|
46
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
47
|
+
const now = options.now ?? Date.now;
|
|
48
|
+
const random = options.random ?? Math.random;
|
|
49
|
+
const deadline = now() + timeoutSeconds * 1000;
|
|
50
|
+
let cursor = Math.max(0, Math.floor(options.afterEventId ?? 0));
|
|
51
|
+
const request = async (operation) => {
|
|
52
|
+
let attempt = 0;
|
|
53
|
+
for (;;) {
|
|
54
|
+
assertActive(options.signal, deadline, now, runId);
|
|
55
|
+
try {
|
|
56
|
+
return await operation();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (options.signal?.aborted)
|
|
60
|
+
throw new WatchInterruptedError();
|
|
61
|
+
if (error instanceof WatchInterruptedError || !isRetryable(error))
|
|
62
|
+
throw error;
|
|
63
|
+
attempt += 1;
|
|
64
|
+
const exponential = Math.min(maxReconnectDelayMs, 250 * (2 ** Math.min(attempt - 1, 8)));
|
|
65
|
+
const remainingMs = deadline - now();
|
|
66
|
+
if (remainingMs <= 0)
|
|
67
|
+
throw new Error(`Timed out watching run ${runId}.`);
|
|
68
|
+
const delayMs = Math.min(remainingMs, Math.max(1, Math.floor(exponential * (0.8 + random() * 0.4))));
|
|
69
|
+
options.onRetry?.({ error, attempt, delayMs });
|
|
70
|
+
await sleep(delayMs, options.signal);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
for (;;) {
|
|
75
|
+
assertActive(options.signal, deadline, now, runId);
|
|
76
|
+
let delivered = 0;
|
|
77
|
+
let fetchAnotherPage = true;
|
|
78
|
+
while (fetchAnotherPage) {
|
|
79
|
+
const page = await request(() => client.listEvents(runId, cursor, pageSize, options.signal));
|
|
80
|
+
const previousCursor = cursor;
|
|
81
|
+
const ordered = [...page.events].sort((left, right) => left.id - right.id);
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
for (const event of ordered) {
|
|
84
|
+
if (!Number.isSafeInteger(event.id) || event.id <= cursor || seen.has(event.id))
|
|
85
|
+
continue;
|
|
86
|
+
seen.add(event.id);
|
|
87
|
+
await options.onEvent?.(event);
|
|
88
|
+
cursor = event.id;
|
|
89
|
+
delivered += 1;
|
|
90
|
+
if (options.stopWhen(event))
|
|
91
|
+
return event;
|
|
92
|
+
}
|
|
93
|
+
const reportedMore = page.has_more ?? page.events.length >= pageSize;
|
|
94
|
+
fetchAnotherPage = reportedMore && cursor > previousCursor;
|
|
95
|
+
}
|
|
96
|
+
if (delivered === 0)
|
|
97
|
+
await sleep(pollIntervalMs, options.signal);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
export const watchRun = async (client, runId, options) => {
|
|
101
|
+
const pageSize = Math.max(1, Math.min(500, Math.floor(options.pageSize ?? 200)));
|
|
102
|
+
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 2000));
|
|
103
|
+
const terminalDrainIntervalMs = Math.max(1, Math.floor(options.terminalDrainIntervalMs ?? Math.min(250, pollIntervalMs)));
|
|
104
|
+
const timeoutSeconds = Math.max(1, options.timeoutSeconds ?? 1800);
|
|
105
|
+
const maxReconnectDelayMs = Math.max(250, options.maxReconnectDelayMs ?? 10_000);
|
|
106
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
107
|
+
const now = options.now ?? Date.now;
|
|
108
|
+
const random = options.random ?? Math.random;
|
|
109
|
+
const deadline = now() + timeoutSeconds * 1000;
|
|
110
|
+
let cursor = Math.max(0, Math.floor(options.afterEventId ?? 0));
|
|
111
|
+
let sawTerminalEvent = false;
|
|
112
|
+
let emptyTerminalReads = 0;
|
|
113
|
+
const request = async (operation) => {
|
|
114
|
+
let attempt = 0;
|
|
115
|
+
for (;;) {
|
|
116
|
+
assertActive(options.signal, deadline, now, runId);
|
|
117
|
+
try {
|
|
118
|
+
return await operation();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (options.signal?.aborted)
|
|
122
|
+
throw new WatchInterruptedError();
|
|
123
|
+
if (error instanceof WatchInterruptedError || !isRetryable(error))
|
|
124
|
+
throw error;
|
|
125
|
+
attempt += 1;
|
|
126
|
+
const exponential = Math.min(maxReconnectDelayMs, 250 * (2 ** Math.min(attempt - 1, 8)));
|
|
127
|
+
const remainingMs = deadline - now();
|
|
128
|
+
if (remainingMs <= 0)
|
|
129
|
+
throw new Error(`Timed out watching run ${runId}.`);
|
|
130
|
+
const delayMs = Math.min(remainingMs, Math.max(1, Math.floor(exponential * (0.8 + random() * 0.4))));
|
|
131
|
+
options.onRetry?.({ error, attempt, delayMs });
|
|
132
|
+
await sleep(delayMs, options.signal);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
for (;;) {
|
|
137
|
+
assertActive(options.signal, deadline, now, runId);
|
|
138
|
+
let deliveredThisCycle = 0;
|
|
139
|
+
let fetchAnotherPage = true;
|
|
140
|
+
while (fetchAnotherPage) {
|
|
141
|
+
const page = await request(() => client.listEvents(runId, cursor, pageSize, options.signal));
|
|
142
|
+
const previousCursor = cursor;
|
|
143
|
+
const ordered = [...page.events].sort((left, right) => left.id - right.id);
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
for (const event of ordered) {
|
|
146
|
+
if (!Number.isSafeInteger(event.id) || event.id <= cursor || seen.has(event.id))
|
|
147
|
+
continue;
|
|
148
|
+
seen.add(event.id);
|
|
149
|
+
await options.onEvent(event);
|
|
150
|
+
cursor = event.id;
|
|
151
|
+
deliveredThisCycle += 1;
|
|
152
|
+
if (terminalEventKinds.has(event.kind))
|
|
153
|
+
sawTerminalEvent = true;
|
|
154
|
+
}
|
|
155
|
+
const reportedMore = page.has_more ?? page.events.length >= pageSize;
|
|
156
|
+
fetchAnotherPage = reportedMore && cursor > previousCursor;
|
|
157
|
+
}
|
|
158
|
+
const payload = await request(() => client.getRun(runId, options.signal));
|
|
159
|
+
if (isTerminalStatus(payload.run.status)) {
|
|
160
|
+
emptyTerminalReads = deliveredThisCycle === 0 ? emptyTerminalReads + 1 : 0;
|
|
161
|
+
const requiredEmptyReads = sawTerminalEvent ? 1 : 2;
|
|
162
|
+
if (emptyTerminalReads >= requiredEmptyReads)
|
|
163
|
+
return payload;
|
|
164
|
+
await sleep(terminalDrainIntervalMs, options.signal);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
emptyTerminalReads = 0;
|
|
168
|
+
await sleep(pollIntervalMs, options.signal);
|
|
169
|
+
}
|
|
170
|
+
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanlabs/sanbox-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"private": false,
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/sanlabs-ai/sanbox.git",
|
|
8
|
+
"directory": "cli"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"bin": {
|
|
7
|
-
"sanbox": "dist/cli.js"
|
|
8
|
-
"sanbox-mcp": "dist/mcp.js"
|
|
12
|
+
"sanbox": "dist/cli.js"
|
|
9
13
|
},
|
|
10
14
|
"files": [
|
|
11
15
|
"dist",
|
|
@@ -19,14 +23,13 @@
|
|
|
19
23
|
},
|
|
20
24
|
"scripts": {
|
|
21
25
|
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"test": "tsx --test test/**/*.test.ts",
|
|
22
27
|
"dev": "tsx src/cli.ts"
|
|
23
28
|
},
|
|
24
29
|
"dependencies": {
|
|
25
|
-
"@modelcontextprotocol/sdk": "^1.21.0",
|
|
26
30
|
"fast-glob": "^3.3.3",
|
|
27
31
|
"ignore": "^7.0.5",
|
|
28
|
-
"yazl": "^3.3.1"
|
|
29
|
-
"zod": "^3.25.76"
|
|
32
|
+
"yazl": "^3.3.1"
|
|
30
33
|
},
|
|
31
34
|
"devDependencies": {
|
|
32
35
|
"@types/node": "^25.6.0",
|