@sanlabs/sanbox-cli 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -6
- package/dist/activity.js +72 -0
- package/dist/api.js +62 -5
- package/dist/cli.js +865 -57
- package/dist/config.js +56 -8
- package/dist/dossier.js +84 -5
- package/dist/errors.js +24 -0
- package/dist/output.js +77 -1
- package/dist/runs.js +1 -2
- package/dist/version.js +1 -1
- package/dist/watch.js +111 -0
- package/package.json +9 -6
- package/dist/mcp.js +0 -131
package/dist/config.js
CHANGED
|
@@ -1,12 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
+
};
|
|
3
24
|
export const readConfig = (flags = {}) => {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
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 (!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/dossier.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { inflateRawSync } from "node:zlib";
|
|
4
5
|
import fg from "fast-glob";
|
|
5
6
|
import ignore from "ignore";
|
|
6
7
|
import yazl from "yazl";
|
|
@@ -34,7 +35,28 @@ const defaultIgnorePatterns = [
|
|
|
34
35
|
"**/id_rsa",
|
|
35
36
|
"**/id_ed25519"
|
|
36
37
|
];
|
|
37
|
-
const
|
|
38
|
+
const normalizePattern = async (cwd, pattern) => {
|
|
39
|
+
if (pattern === ".")
|
|
40
|
+
return "**/*";
|
|
41
|
+
const normalized = pattern.replaceAll(path.sep, "/").replace(/\/+$/, "");
|
|
42
|
+
if (normalized.includes("*"))
|
|
43
|
+
return normalized;
|
|
44
|
+
try {
|
|
45
|
+
const stat = await fs.stat(path.resolve(cwd, normalized));
|
|
46
|
+
if (stat.isDirectory())
|
|
47
|
+
return `${normalized}/**`;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== "ENOENT")
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
};
|
|
55
|
+
const normalizeInclude = async (cwd, patterns) => {
|
|
56
|
+
if (patterns.length === 0)
|
|
57
|
+
return ["**/*"];
|
|
58
|
+
return Promise.all(patterns.map((pattern) => normalizePattern(cwd, pattern)));
|
|
59
|
+
};
|
|
38
60
|
const readSanboxIgnore = async (cwd) => {
|
|
39
61
|
try {
|
|
40
62
|
const raw = await fs.readFile(path.join(cwd, ".sanboxignore"), "utf8");
|
|
@@ -54,11 +76,11 @@ const zipToBuffer = async (zip) => new Promise((resolve, reject) => {
|
|
|
54
76
|
zip.end();
|
|
55
77
|
});
|
|
56
78
|
export const sha256 = (buffer) => crypto.createHash("sha256").update(buffer).digest("hex");
|
|
57
|
-
|
|
58
|
-
const patterns = normalizeInclude(
|
|
59
|
-
const ig = ignore().add([...defaultIgnorePatterns, ...(await readSanboxIgnore(
|
|
79
|
+
const selectedFiles = async (cwd, include) => {
|
|
80
|
+
const patterns = await normalizeInclude(cwd, include);
|
|
81
|
+
const ig = ignore().add([...defaultIgnorePatterns, ...(await readSanboxIgnore(cwd))]);
|
|
60
82
|
const candidates = await fg(patterns, {
|
|
61
|
-
cwd
|
|
83
|
+
cwd,
|
|
62
84
|
dot: true,
|
|
63
85
|
onlyFiles: true,
|
|
64
86
|
followSymbolicLinks: false,
|
|
@@ -69,6 +91,23 @@ export const buildTaskDossier = async (input) => {
|
|
|
69
91
|
.filter((item) => !item.startsWith("../") && !path.isAbsolute(item))
|
|
70
92
|
.filter((item) => !ig.ignores(item))
|
|
71
93
|
.sort();
|
|
94
|
+
return { patterns, files };
|
|
95
|
+
};
|
|
96
|
+
export const previewTaskDossier = async (input) => {
|
|
97
|
+
const { patterns, files } = await selectedFiles(input.cwd, input.include);
|
|
98
|
+
const previewFiles = [];
|
|
99
|
+
let totalBytes = 0;
|
|
100
|
+
for (const rel of files) {
|
|
101
|
+
const stat = await fs.stat(path.join(input.cwd, rel));
|
|
102
|
+
if (!stat.isFile())
|
|
103
|
+
continue;
|
|
104
|
+
totalBytes += stat.size;
|
|
105
|
+
previewFiles.push({ path: rel, size: stat.size });
|
|
106
|
+
}
|
|
107
|
+
return { patterns, files: previewFiles, totalBytes };
|
|
108
|
+
};
|
|
109
|
+
export const buildTaskDossier = async (input) => {
|
|
110
|
+
const { files } = await selectedFiles(input.cwd, input.include);
|
|
72
111
|
const zip = new yazl.ZipFile();
|
|
73
112
|
const fileMetadata = [];
|
|
74
113
|
const runbook = [
|
|
@@ -106,3 +145,43 @@ export const readDossierFile = async (dossierPath) => {
|
|
|
106
145
|
const buffer = await fs.readFile(dossierPath);
|
|
107
146
|
return { buffer, sha256: sha256(buffer), files: [] };
|
|
108
147
|
};
|
|
148
|
+
export const writeTaskDossier = async (input) => {
|
|
149
|
+
const dossier = await buildTaskDossier(input);
|
|
150
|
+
await fs.mkdir(path.dirname(input.outPath), { recursive: true });
|
|
151
|
+
await fs.writeFile(input.outPath, dossier.buffer);
|
|
152
|
+
return dossier;
|
|
153
|
+
};
|
|
154
|
+
const readUInt16 = (buffer, offset) => buffer.readUInt16LE(offset);
|
|
155
|
+
const readUInt32 = (buffer, offset) => buffer.readUInt32LE(offset);
|
|
156
|
+
export const inspectDossierFile = async (dossierPath) => {
|
|
157
|
+
const buffer = await fs.readFile(dossierPath);
|
|
158
|
+
const entries = [];
|
|
159
|
+
let runbook;
|
|
160
|
+
let manifest;
|
|
161
|
+
let offset = 0;
|
|
162
|
+
while (offset + 30 <= buffer.length && readUInt32(buffer, offset) === 0x04034b50) {
|
|
163
|
+
const compressionMethod = readUInt16(buffer, offset + 8);
|
|
164
|
+
const compressedSize = readUInt32(buffer, offset + 18);
|
|
165
|
+
const uncompressedSize = readUInt32(buffer, offset + 22);
|
|
166
|
+
const nameLength = readUInt16(buffer, offset + 26);
|
|
167
|
+
const extraLength = readUInt16(buffer, offset + 28);
|
|
168
|
+
const nameStart = offset + 30;
|
|
169
|
+
const dataStart = nameStart + nameLength + extraLength;
|
|
170
|
+
const dataEnd = dataStart + compressedSize;
|
|
171
|
+
if (dataEnd > buffer.length)
|
|
172
|
+
break;
|
|
173
|
+
const entryPath = buffer.subarray(nameStart, nameStart + nameLength).toString("utf8");
|
|
174
|
+
entries.push({ path: entryPath, compressedSize, uncompressedSize });
|
|
175
|
+
if (entryPath === "RUNBOOK.md" || entryPath === "manifest.json") {
|
|
176
|
+
const compressed = buffer.subarray(dataStart, dataEnd);
|
|
177
|
+
const content = compressionMethod === 0 ? compressed : inflateRawSync(compressed);
|
|
178
|
+
const text = content.toString("utf8");
|
|
179
|
+
if (entryPath === "RUNBOOK.md")
|
|
180
|
+
runbook = text;
|
|
181
|
+
if (entryPath === "manifest.json")
|
|
182
|
+
manifest = JSON.parse(text);
|
|
183
|
+
}
|
|
184
|
+
offset = dataEnd;
|
|
185
|
+
}
|
|
186
|
+
return { entries, runbook, manifest };
|
|
187
|
+
};
|
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/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,7 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { defaultWorkloadId } from "./config.js";
|
|
5
4
|
import { buildTaskDossier, readDossierFile } from "./dossier.js";
|
|
6
5
|
const terminalStatuses = new Set(["completed", "failed", "canceled", "expired"]);
|
|
7
6
|
const inlineLimitBytes = 8 * 1024 * 1024;
|
|
@@ -42,7 +41,7 @@ export const createRun = async (client, options) => {
|
|
|
42
41
|
};
|
|
43
42
|
return client.createRun({
|
|
44
43
|
external_run_id: options.externalRunId,
|
|
45
|
-
workload_id: options.
|
|
44
|
+
workload_id: options.templateId,
|
|
46
45
|
manifest,
|
|
47
46
|
retention_ttl_seconds: options.retentionTtlSeconds
|
|
48
47
|
});
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "0.0.
|
|
1
|
+
export const version = "0.0.3";
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
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 watchRun = 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 terminalDrainIntervalMs = Math.max(1, Math.floor(options.terminalDrainIntervalMs ?? Math.min(250, pollIntervalMs)));
|
|
45
|
+
const timeoutSeconds = Math.max(1, options.timeoutSeconds ?? 1800);
|
|
46
|
+
const maxReconnectDelayMs = Math.max(250, options.maxReconnectDelayMs ?? 10_000);
|
|
47
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
48
|
+
const now = options.now ?? Date.now;
|
|
49
|
+
const random = options.random ?? Math.random;
|
|
50
|
+
const deadline = now() + timeoutSeconds * 1000;
|
|
51
|
+
let cursor = Math.max(0, Math.floor(options.afterEventId ?? 0));
|
|
52
|
+
let sawTerminalEvent = false;
|
|
53
|
+
let emptyTerminalReads = 0;
|
|
54
|
+
const request = async (operation) => {
|
|
55
|
+
let attempt = 0;
|
|
56
|
+
for (;;) {
|
|
57
|
+
assertActive(options.signal, deadline, now, runId);
|
|
58
|
+
try {
|
|
59
|
+
return await operation();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (options.signal?.aborted)
|
|
63
|
+
throw new WatchInterruptedError();
|
|
64
|
+
if (error instanceof WatchInterruptedError || !isRetryable(error))
|
|
65
|
+
throw error;
|
|
66
|
+
attempt += 1;
|
|
67
|
+
const exponential = Math.min(maxReconnectDelayMs, 250 * (2 ** Math.min(attempt - 1, 8)));
|
|
68
|
+
const remainingMs = deadline - now();
|
|
69
|
+
if (remainingMs <= 0)
|
|
70
|
+
throw new Error(`Timed out watching run ${runId}.`);
|
|
71
|
+
const delayMs = Math.min(remainingMs, Math.max(1, Math.floor(exponential * (0.8 + random() * 0.4))));
|
|
72
|
+
options.onRetry?.({ error, attempt, delayMs });
|
|
73
|
+
await sleep(delayMs, options.signal);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
for (;;) {
|
|
78
|
+
assertActive(options.signal, deadline, now, runId);
|
|
79
|
+
let deliveredThisCycle = 0;
|
|
80
|
+
let fetchAnotherPage = true;
|
|
81
|
+
while (fetchAnotherPage) {
|
|
82
|
+
const page = await request(() => client.listEvents(runId, cursor, pageSize, options.signal));
|
|
83
|
+
const previousCursor = cursor;
|
|
84
|
+
const ordered = [...page.events].sort((left, right) => left.id - right.id);
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
for (const event of ordered) {
|
|
87
|
+
if (!Number.isSafeInteger(event.id) || event.id <= cursor || seen.has(event.id))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(event.id);
|
|
90
|
+
await options.onEvent(event);
|
|
91
|
+
cursor = event.id;
|
|
92
|
+
deliveredThisCycle += 1;
|
|
93
|
+
if (terminalEventKinds.has(event.kind))
|
|
94
|
+
sawTerminalEvent = true;
|
|
95
|
+
}
|
|
96
|
+
const reportedMore = page.has_more ?? page.events.length >= pageSize;
|
|
97
|
+
fetchAnotherPage = reportedMore && cursor > previousCursor;
|
|
98
|
+
}
|
|
99
|
+
const payload = await request(() => client.getRun(runId, options.signal));
|
|
100
|
+
if (isTerminalStatus(payload.run.status)) {
|
|
101
|
+
emptyTerminalReads = deliveredThisCycle === 0 ? emptyTerminalReads + 1 : 0;
|
|
102
|
+
const requiredEmptyReads = sawTerminalEvent ? 1 : 2;
|
|
103
|
+
if (emptyTerminalReads >= requiredEmptyReads)
|
|
104
|
+
return payload;
|
|
105
|
+
await sleep(terminalDrainIntervalMs, options.signal);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
emptyTerminalReads = 0;
|
|
109
|
+
await sleep(pollIntervalMs, options.signal);
|
|
110
|
+
}
|
|
111
|
+
};
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanlabs/sanbox-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
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",
|
package/dist/mcp.js
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import * as z from "zod/v4";
|
|
5
|
-
import { SanboxClient } from "./api.js";
|
|
6
|
-
import { defaultWorkloadId, readConfig } from "./config.js";
|
|
7
|
-
import { createRun, readTasks, runPool, stableBatchId, waitForRun } from "./runs.js";
|
|
8
|
-
import { version } from "./version.js";
|
|
9
|
-
const textAndJson = (summary, structuredContent) => ({
|
|
10
|
-
content: [{ type: "text", text: `${summary}\n\n${JSON.stringify(structuredContent, null, 2)}` }],
|
|
11
|
-
structuredContent
|
|
12
|
-
});
|
|
13
|
-
const client = () => new SanboxClient(readConfig());
|
|
14
|
-
const server = new McpServer({
|
|
15
|
-
name: "sanbox",
|
|
16
|
-
version
|
|
17
|
-
});
|
|
18
|
-
server.registerTool("create_sandbox_run", {
|
|
19
|
-
title: "Create Sanbox Run",
|
|
20
|
-
description: "Create one Sanbox run from a task or existing dossier ZIP.",
|
|
21
|
-
inputSchema: {
|
|
22
|
-
task: z.string().optional().describe("Task instructions. Required unless dossier_path is provided."),
|
|
23
|
-
dossier_path: z.string().optional().describe("Path to an existing dossier ZIP."),
|
|
24
|
-
include: z.array(z.string()).optional().describe("Repo file globs to include when task is provided."),
|
|
25
|
-
external_run_id: z.string().optional().describe("Optional idempotency key."),
|
|
26
|
-
workload_id: z.string().optional().default(defaultWorkloadId),
|
|
27
|
-
wait: z.boolean().optional().default(false)
|
|
28
|
-
}
|
|
29
|
-
}, async (args) => {
|
|
30
|
-
if (!args.task && !args.dossier_path)
|
|
31
|
-
throw new Error("task or dossier_path is required.");
|
|
32
|
-
const api = client();
|
|
33
|
-
let payload = await createRun(api, {
|
|
34
|
-
cwd: process.cwd(),
|
|
35
|
-
cliVersion: version,
|
|
36
|
-
task: args.task,
|
|
37
|
-
dossierPath: args.dossier_path,
|
|
38
|
-
include: args.include || [],
|
|
39
|
-
externalRunId: args.external_run_id,
|
|
40
|
-
workloadId: args.workload_id || defaultWorkloadId
|
|
41
|
-
});
|
|
42
|
-
if (args.wait)
|
|
43
|
-
payload = await waitForRun(api, payload.run.id);
|
|
44
|
-
return textAndJson(`Run ${payload.run.id} is ${payload.run.status}.`, payload);
|
|
45
|
-
});
|
|
46
|
-
server.registerTool("create_sandbox_runs", {
|
|
47
|
-
title: "Create Multiple Sanbox Runs",
|
|
48
|
-
description: "Create independent Sanbox runs for parallel task delegation.",
|
|
49
|
-
inputSchema: {
|
|
50
|
-
tasks: z.array(z.object({
|
|
51
|
-
task: z.string(),
|
|
52
|
-
external_run_id: z.string().optional(),
|
|
53
|
-
include: z.array(z.string()).optional()
|
|
54
|
-
})).optional().describe("Inline tasks to run."),
|
|
55
|
-
tasks_path: z.string().optional().describe("Path to a JSON task array."),
|
|
56
|
-
include: z.array(z.string()).optional().describe("Default repo file globs for generated dossiers."),
|
|
57
|
-
batch_id: z.string().optional().describe("Optional stable batch id for idempotency."),
|
|
58
|
-
max_parallel: z.number().int().min(1).max(20).optional().default(5),
|
|
59
|
-
wait: z.boolean().optional().default(false)
|
|
60
|
-
}
|
|
61
|
-
}, async (args) => {
|
|
62
|
-
const tasks = args.tasks_path ? await readTasks(args.tasks_path) : args.tasks;
|
|
63
|
-
if (!tasks || tasks.length === 0)
|
|
64
|
-
throw new Error("tasks or tasks_path is required.");
|
|
65
|
-
const batchId = args.batch_id || (args.tasks_path ? await stableBatchId(args.tasks_path) : `mcp-${Date.now().toString(36)}`);
|
|
66
|
-
const api = client();
|
|
67
|
-
const results = await runPool(tasks, args.max_parallel || 5, async (task, index) => {
|
|
68
|
-
let payload = await createRun(api, {
|
|
69
|
-
cwd: process.cwd(),
|
|
70
|
-
cliVersion: version,
|
|
71
|
-
task: task.task,
|
|
72
|
-
include: task.include || args.include || [],
|
|
73
|
-
externalRunId: task.external_run_id || `sanbox-batch-${batchId}-${index + 1}`,
|
|
74
|
-
workloadId: defaultWorkloadId
|
|
75
|
-
});
|
|
76
|
-
if (args.wait)
|
|
77
|
-
payload = await waitForRun(api, payload.run.id);
|
|
78
|
-
return payload;
|
|
79
|
-
});
|
|
80
|
-
const structured = { batch_id: batchId, runs: results.map((result) => result.run), results };
|
|
81
|
-
return textAndJson(`Created ${results.length} Sanbox runs.`, structured);
|
|
82
|
-
});
|
|
83
|
-
server.registerTool("get_sandbox_run", {
|
|
84
|
-
title: "Get Sanbox Run",
|
|
85
|
-
description: "Fetch a Sanbox run and recent events.",
|
|
86
|
-
inputSchema: {
|
|
87
|
-
run_id: z.string()
|
|
88
|
-
}
|
|
89
|
-
}, async ({ run_id }) => {
|
|
90
|
-
const payload = await client().getRun(run_id);
|
|
91
|
-
return textAndJson(`Run ${payload.run.id} is ${payload.run.status}.`, payload);
|
|
92
|
-
});
|
|
93
|
-
server.registerTool("list_sandbox_events", {
|
|
94
|
-
title: "List Sanbox Events",
|
|
95
|
-
description: "List events for a Sanbox run.",
|
|
96
|
-
inputSchema: {
|
|
97
|
-
run_id: z.string(),
|
|
98
|
-
after_event_id: z.number().int().min(0).optional().default(0)
|
|
99
|
-
}
|
|
100
|
-
}, async ({ run_id, after_event_id }) => {
|
|
101
|
-
const payload = await client().listEvents(run_id, after_event_id || 0);
|
|
102
|
-
return textAndJson(`Found ${payload.events.length} events.`, payload);
|
|
103
|
-
});
|
|
104
|
-
server.registerTool("cancel_sandbox_run", {
|
|
105
|
-
title: "Cancel Sanbox Run",
|
|
106
|
-
description: "Request cancellation for a queued or running Sanbox run.",
|
|
107
|
-
inputSchema: {
|
|
108
|
-
run_id: z.string()
|
|
109
|
-
}
|
|
110
|
-
}, async ({ run_id }) => {
|
|
111
|
-
const payload = await client().cancelRun(run_id);
|
|
112
|
-
return textAndJson(`Cancel requested for ${payload.run.id}.`, payload);
|
|
113
|
-
});
|
|
114
|
-
server.registerTool("send_sandbox_message", {
|
|
115
|
-
title: "Send Sanbox Message",
|
|
116
|
-
description: "Ask a follow-up question against a retained Sanbox run workspace.",
|
|
117
|
-
inputSchema: {
|
|
118
|
-
run_id: z.string(),
|
|
119
|
-
message: z.string()
|
|
120
|
-
}
|
|
121
|
-
}, async ({ run_id, message }) => {
|
|
122
|
-
const payload = await client().sendMessage(run_id, message);
|
|
123
|
-
return textAndJson(`Message sent to ${payload.run.id}.`, payload);
|
|
124
|
-
});
|
|
125
|
-
const main = async () => {
|
|
126
|
-
await server.connect(new StdioServerTransport());
|
|
127
|
-
};
|
|
128
|
-
main().catch((error) => {
|
|
129
|
-
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
130
|
-
process.exit(1);
|
|
131
|
-
});
|