@alisio/core 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +38 -2
- package/dist/application.js +213 -4
- package/dist/changelog/data.js +64 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
- package/dist/forge/javascript/hosts/worker-host.js +101 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +338 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +103 -0
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
package/dist/tools/analysis.js
CHANGED
|
@@ -4,19 +4,25 @@
|
|
|
4
4
|
* job folder outside the repository; whatever it leaves in `$ALISIO_OUTPUT_DIR` is published as
|
|
5
5
|
* artifacts. Managed Python is NOT a sandbox.
|
|
6
6
|
*/
|
|
7
|
-
import { open } from "node:fs/promises";
|
|
8
|
-
import { basename, join } from "node:path";
|
|
7
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
9
9
|
import { textResult } from "@alisio/sdk";
|
|
10
10
|
import { detectHintHost, installHintsMarkdown, installHintsText, pythonInstallHints, } from "../analysis/install-hints.js";
|
|
11
11
|
import { ExtrasInstallError, installPreview, setupCommand, } from "../analysis/runtime-manager.js";
|
|
12
12
|
import { KIND_LABELS } from "../artifacts/kinds.js";
|
|
13
13
|
import { ArtifactRejected, toRef } from "../artifacts/store.js";
|
|
14
|
+
import { isForgeError, renderHint } from "../forge/errors.js";
|
|
15
|
+
import { engineIdOf, neededBackends, } from "../forge/python/backend-selector.js";
|
|
16
|
+
import { newPythonRunReport, PYTHON_PLAN_KEY, TERMINAL_BYTES, } from "../forge/python/engine.js";
|
|
17
|
+
import { stripFrames } from "../forge/python/frame-parser.js";
|
|
18
|
+
import { scanImports } from "../forge/python/import-scan.js";
|
|
19
|
+
import { createPythonForge } from "../forge/python/wiring.js";
|
|
20
|
+
import { clipText, outputText, pythonHintFor } from "../forge/result.js";
|
|
21
|
+
import { resolveScratchPath } from "../forge/session-dir.js";
|
|
14
22
|
import { safePath } from "../runtime/paths.js";
|
|
15
|
-
import { runProcess } from "../runtime/process.js";
|
|
16
23
|
import { artifactBlocks, describeArtifact, formatBytes } from "./artifact-blocks.js";
|
|
17
24
|
import { objectSchema } from "./standard.js";
|
|
18
25
|
export const NOT_SANDBOXED = "Managed Python is not a sandbox: the script runs with your user permissions and can read your files and use the network.";
|
|
19
|
-
const TERMINAL_BYTES = 256 * 1024;
|
|
20
26
|
const TEXT_TAIL = 4 * 1024;
|
|
21
27
|
const MAX_CODE = 200_000;
|
|
22
28
|
const MAX_TEXT = 5 * 1024 * 1024;
|
|
@@ -63,44 +69,18 @@ function publisherOf(context) {
|
|
|
63
69
|
throw new Error("Artifacts are not available here (no artifact store for this session)");
|
|
64
70
|
return publisher;
|
|
65
71
|
}
|
|
66
|
-
|
|
67
|
-
function scriptEnv(job) {
|
|
68
|
-
const keep = [
|
|
69
|
-
"PATH",
|
|
70
|
-
"LANG",
|
|
71
|
-
"LC_ALL",
|
|
72
|
-
"SystemRoot",
|
|
73
|
-
"COMSPEC",
|
|
74
|
-
"PATHEXT",
|
|
75
|
-
"SYSTEMDRIVE",
|
|
76
|
-
"WINDIR",
|
|
77
|
-
];
|
|
78
|
-
return {
|
|
79
|
-
...Object.fromEntries(keep.flatMap((k) => (process.env[k] ? [[k, process.env[k]]] : []))),
|
|
80
|
-
HOME: job.work,
|
|
81
|
-
USERPROFILE: job.work,
|
|
82
|
-
TMPDIR: job.work,
|
|
83
|
-
TEMP: job.work,
|
|
84
|
-
TMP: job.work,
|
|
85
|
-
ALISIO_INPUT_DIR: job.input,
|
|
86
|
-
ALISIO_OUTPUT_DIR: job.staging,
|
|
87
|
-
ALISIO_WORK_DIR: job.work,
|
|
88
|
-
ALISIO_EXECUTION_ID: job.id,
|
|
89
|
-
MPLBACKEND: "Agg",
|
|
90
|
-
PYTHONDONTWRITEBYTECODE: "1",
|
|
91
|
-
PYTHONUNBUFFERED: "1",
|
|
92
|
-
PYTHONNOUSERSITE: "1",
|
|
93
|
-
PYTHONIOENCODING: "utf-8",
|
|
94
|
-
};
|
|
95
|
-
}
|
|
72
|
+
const WASM_NOTE = "This run happens in a restricted WebAssembly process (Pyodide in Node with the Permission Model): no host files outside the job, no child processes; the network is NOT blocked and memory is a soft limit. Only the downloaded profile's packages exist.";
|
|
96
73
|
const CONTAINER_NOTE = "This run happens in a container (Docker or Podman) with no network access, a read-only root and only the job folders mounted; it is isolation with limits, not a hardened sandbox.";
|
|
97
74
|
function pythonRunDescription(deps) {
|
|
98
|
-
const
|
|
75
|
+
const setting = deps.oci?.mode() ?? "managed";
|
|
76
|
+
const container = setting === "oci";
|
|
99
77
|
const modules = container
|
|
100
78
|
? "whatever the configured container image provides (and alisio_runtime)"
|
|
101
|
-
:
|
|
102
|
-
?
|
|
103
|
-
:
|
|
79
|
+
: setting === "wasm"
|
|
80
|
+
? "the standard library, alisio_runtime and the downloaded Pyodide profile (analysis: pandas, numpy, matplotlib, openpyxl, python-docx, reportlab, plotly, jinja2; science adds scipy, statsmodels, scikit-learn); other packages are not available"
|
|
81
|
+
: deps.startup.extras.length
|
|
82
|
+
? `the standard library, alisio_runtime and the "${deps.startup.extras.join(", ")}" extras (pandas, numpy, matplotlib…)`
|
|
83
|
+
: 'the Python standard library and alisio_runtime only (no pandas or numpy; use csv, statistics, sqlite3). Pass extras: ["analysis"] to use pandas, numpy, matplotlib, openpyxl, python-docx, reportlab, plotly and jinja2: if they are not installed the user is asked once to approve a download, and without approval the call fails';
|
|
104
84
|
return [
|
|
105
85
|
...(deps.smartDashboard
|
|
106
86
|
? [
|
|
@@ -130,17 +110,122 @@ function pythonRunDescription(deps) {
|
|
|
130
110
|
"inline (no fetch, no CDN). Nothing is published when the script fails unless publishOnError.",
|
|
131
111
|
"Keep each call's code short: the model's output is capped per response, so split large scripts or dashboards into several calls or files.",
|
|
132
112
|
"To run an earlier analysis again unchanged (same script, inputs checked by hash, new artifacts) pass rerunOf: an artifact or execution id of this session, instead of code.",
|
|
133
|
-
container ? CONTAINER_NOTE : NOT_SANDBOXED,
|
|
134
|
-
...(deps.startup.candidates || container
|
|
113
|
+
container ? CONTAINER_NOTE : setting === "wasm" ? WASM_NOTE : NOT_SANDBOXED,
|
|
114
|
+
...(deps.startup.candidates || container || setting === "wasm"
|
|
135
115
|
? []
|
|
136
116
|
: ["Python was not found at startup: the tool answers with install instructions."]),
|
|
137
117
|
].join(" ");
|
|
138
118
|
}
|
|
119
|
+
/** PD11: the last line of the text names the engine, the isolation and why it was chosen. */
|
|
120
|
+
const withEngineLine = (text, line) => `${text}${text.endsWith("\n") ? "" : "\n"}${line}`;
|
|
139
121
|
const failure = (text) => ({
|
|
140
122
|
content: [{ type: "text", text }],
|
|
141
123
|
isError: true,
|
|
142
124
|
});
|
|
143
125
|
export function registerAnalysisTools(registry, deps) {
|
|
126
|
+
const python = createPythonForge({
|
|
127
|
+
jobs: deps.jobs,
|
|
128
|
+
runtime: deps.runtime,
|
|
129
|
+
...(deps.oci ? { oci: deps.oci } : {}),
|
|
130
|
+
...(deps.wasm ? { wasm: deps.wasm } : {}),
|
|
131
|
+
...(deps.forge ? { shared: deps.forge } : {}),
|
|
132
|
+
});
|
|
133
|
+
/** The pointer line to `python.environment`, only while the guide tool exists. */
|
|
134
|
+
const guideLine = (code) => deps.guideHints?.() ? `\nhint: ${renderHint(pythonHintFor(code))}` : "";
|
|
135
|
+
async function unavailable(runtime, state, fallback, host) {
|
|
136
|
+
const result = await unavailableText(runtime, state, fallback, host);
|
|
137
|
+
const line = guideLine(/wasm profiles|profile/.test(fallback) ? "PACKAGE_UNAVAILABLE" : "RUNTIME_UNAVAILABLE");
|
|
138
|
+
const first = result.content[0];
|
|
139
|
+
if (line && first?.type === "text")
|
|
140
|
+
first.text += line;
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
/** The text `python_run` has always answered when the chosen runtime cannot run. */
|
|
144
|
+
async function unavailableText(runtime, state, fallback, host) {
|
|
145
|
+
if (runtime === "auto" || runtime === "wasm") {
|
|
146
|
+
// The chain's own message lists every backend; the host's install hints ride along when the
|
|
147
|
+
// host was a candidate that failed for lack of Python.
|
|
148
|
+
const hostDown = runtime === "auto" && host && !host.ready ? host : undefined;
|
|
149
|
+
if (!hostDown)
|
|
150
|
+
return failure(`runtime_unavailable: ${fallback}`);
|
|
151
|
+
const found = hostDown.detail
|
|
152
|
+
?.found;
|
|
153
|
+
const hints = pythonInstallHints(await detectHintHost(found));
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{ type: "text", text: `runtime_unavailable: ${fallback}\n${installHintsText(hints)}` },
|
|
157
|
+
{ type: "ui", block: { kind: "markdown", text: installHintsMarkdown(hints) } },
|
|
158
|
+
],
|
|
159
|
+
isError: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (!state || state.ready)
|
|
163
|
+
return failure(`runtime_unavailable: ${fallback}`);
|
|
164
|
+
if (runtime === "oci")
|
|
165
|
+
return failure(`runtime_unavailable: ${state.reason}`);
|
|
166
|
+
const found = state.detail?.found;
|
|
167
|
+
const hints = pythonInstallHints(await detectHintHost(found));
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{ type: "text", text: `runtime_unavailable: ${state.reason}.\n${installHintsText(hints)}` },
|
|
171
|
+
{ type: "ui", block: { kind: "markdown", text: installHintsMarkdown(hints) } },
|
|
172
|
+
],
|
|
173
|
+
isError: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** The Pyodide start margin added to a run's deadline (0 without the backend). */
|
|
177
|
+
const wasmStartupMs = () => deps.wasm?.settings().startupMs ?? 0;
|
|
178
|
+
/** Bytes of the inputs a call declares, best effort: a bad input fails later with its own error. */
|
|
179
|
+
async function inputBytesOf(declared, rerun, context, root) {
|
|
180
|
+
if (rerun)
|
|
181
|
+
return rerun.inputs.reduce((sum, item) => sum + item.bytes, 0);
|
|
182
|
+
let total = 0;
|
|
183
|
+
for (const item of declared ??
|
|
184
|
+
[]) {
|
|
185
|
+
try {
|
|
186
|
+
const file = item.datasetId !== undefined
|
|
187
|
+
? deps.datasets?.file(deps.datasets.getFor(item.datasetId, root))
|
|
188
|
+
: item.path !== undefined
|
|
189
|
+
? await (context.resolvePath
|
|
190
|
+
? context.resolvePath(item.path)
|
|
191
|
+
: safePath(context.workspace, item.path))
|
|
192
|
+
: undefined;
|
|
193
|
+
if (file)
|
|
194
|
+
total += (await stat(file)).size;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
/* the real preparation reports it */
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return total;
|
|
201
|
+
}
|
|
202
|
+
/** The scratchpad to expose to the script (read-only), when it exists. */
|
|
203
|
+
async function exposedScratchpad(session) {
|
|
204
|
+
const pad = await deps.scratchpad?.(session).catch(() => undefined);
|
|
205
|
+
if (!pad)
|
|
206
|
+
return undefined;
|
|
207
|
+
return (await stat(pad).catch(() => undefined))?.isDirectory() ? pad : undefined;
|
|
208
|
+
}
|
|
209
|
+
/** `codePath`: the text of a `.py` file inside the session scratchpad (Python spec §5). */
|
|
210
|
+
async function codeFromScratchpad(codePath, session) {
|
|
211
|
+
if (typeof codePath !== "string" || codePath === "")
|
|
212
|
+
throw new Error("`codePath` must be a non-empty string");
|
|
213
|
+
const pad = deps.scratchpad ? await deps.scratchpad(session).catch(() => undefined) : undefined;
|
|
214
|
+
if (!pad)
|
|
215
|
+
throw new Error("`codePath` needs the session scratchpad, which is not available");
|
|
216
|
+
const path = await resolveScratchPath(pad, codePath);
|
|
217
|
+
if (extname(path) !== ".py")
|
|
218
|
+
throw new Error("`codePath` must be a .py file inside the scratchpad");
|
|
219
|
+
const info = await stat(path).catch(() => undefined);
|
|
220
|
+
if (!info?.isFile())
|
|
221
|
+
throw new Error(`\`codePath\` does not name a file: ${codePath}`);
|
|
222
|
+
const text = await readFile(path, "utf8");
|
|
223
|
+
if (text.length > MAX_CODE)
|
|
224
|
+
throw new Error(`\`codePath\` file is longer than ${MAX_CODE} characters`);
|
|
225
|
+
if (text.trim() === "")
|
|
226
|
+
throw new Error("`codePath` file is empty");
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
144
229
|
/**
|
|
145
230
|
* The optional extras (pandas…): when they are not installed, installing them needs an
|
|
146
231
|
* `analysis.install` approval (once, with the packages, the size and the network need). Where no
|
|
@@ -197,16 +282,25 @@ export function registerAnalysisTools(registry, deps) {
|
|
|
197
282
|
items: { type: "string", enum: ["analysis", "science"] },
|
|
198
283
|
},
|
|
199
284
|
rerunOf: { type: "string", minLength: 5, maxLength: 64 },
|
|
285
|
+
codePath: {
|
|
286
|
+
type: "string",
|
|
287
|
+
maxLength: 1024,
|
|
288
|
+
description: "A .py file inside the session scratchpad to run instead of code (edit it with the file tools between runs). The scratchpad is readable from the script as $ALISIO_SCRATCH_DIR and never writable. Only where the scratchpad exists.",
|
|
289
|
+
},
|
|
200
290
|
}, []),
|
|
201
291
|
async execute(input, context) {
|
|
202
292
|
const publisher = publisherOf(context);
|
|
203
293
|
const sessionId = context.session ?? "headless";
|
|
204
294
|
const root = deps.rootOf(sessionId);
|
|
205
295
|
const rerunRef = typeof input.rerunOf === "string" ? input.rerunOf.trim() : undefined;
|
|
296
|
+
const hasPath = input.codePath !== undefined;
|
|
206
297
|
if (rerunRef && input.code !== undefined)
|
|
207
298
|
throw new Error("Pass either code or rerunOf, not both");
|
|
208
|
-
if (
|
|
299
|
+
if (hasPath && (rerunRef || input.code !== undefined))
|
|
300
|
+
throw new Error("Pass only one of code, codePath or rerunOf");
|
|
301
|
+
if (!rerunRef && !hasPath && typeof input.code !== "string")
|
|
209
302
|
throw new Error("python_run needs code (or rerunOf to run an earlier analysis again)");
|
|
303
|
+
const source = hasPath ? await codeFromScratchpad(input.codePath, sessionId) : undefined;
|
|
210
304
|
// Rerun: the saved script and inputs, verified by hash; a mismatch runs nothing.
|
|
211
305
|
let rerun;
|
|
212
306
|
if (rerunRef) {
|
|
@@ -217,30 +311,67 @@ export function registerAnalysisTools(registry, deps) {
|
|
|
217
311
|
return failure(`rerun refused; nothing was executed:\n${prepared.problems.map((p) => `- ${p}`).join("\n")}`);
|
|
218
312
|
rerun = prepared.plan;
|
|
219
313
|
}
|
|
220
|
-
const code = rerun ? rerun.code : String(input.code);
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
314
|
+
const code = rerun ? rerun.code : (source ?? String(input.code));
|
|
315
|
+
await python.ready();
|
|
316
|
+
// select (Python spec §6): the configured runtime under the isolation policy, from facts
|
|
317
|
+
// gathered once. Nothing is probed after this point, and nothing falls back after a start.
|
|
318
|
+
const runtimeSetting = deps.oci ? deps.oci.mode() : "managed";
|
|
319
|
+
const isolation = deps.isolation?.() ?? "preferred";
|
|
320
|
+
const rerunOfBackend = rerun?.runtime;
|
|
321
|
+
const extrasAsked = [
|
|
322
|
+
...new Set([
|
|
323
|
+
...(input.extras ?? []),
|
|
324
|
+
...(rerun?.extras ?? []),
|
|
325
|
+
]),
|
|
326
|
+
];
|
|
327
|
+
const probe = neededBackends({
|
|
328
|
+
runtime: runtimeSetting,
|
|
329
|
+
isolation,
|
|
330
|
+
...(rerunOfBackend ? { rerunOfBackend } : {}),
|
|
331
|
+
});
|
|
332
|
+
const readiness = {};
|
|
333
|
+
await Promise.all(probe.map(async (id) => {
|
|
334
|
+
const backend = python.backends[id];
|
|
335
|
+
readiness[id] = backend
|
|
336
|
+
? await backend.check(context.signal)
|
|
337
|
+
: { ready: false, reason: `${id} is not available here` };
|
|
338
|
+
}));
|
|
339
|
+
const requirements = {
|
|
340
|
+
extras: extrasAsked.filter((e) => ["analysis", "science"].includes(e)),
|
|
341
|
+
importedModules: scanImports(code),
|
|
342
|
+
inputBytes: probe.includes("wasm")
|
|
343
|
+
? await inputBytesOf(input.inputs, rerun, context, root)
|
|
344
|
+
: 0,
|
|
345
|
+
...(rerunOfBackend ? { rerunOfBackend } : {}),
|
|
346
|
+
};
|
|
347
|
+
const support = {};
|
|
348
|
+
const networkDenied = {};
|
|
349
|
+
for (const id of probe) {
|
|
350
|
+
const backend = python.backends[id];
|
|
351
|
+
if (!backend || !readiness[id]?.ready)
|
|
352
|
+
continue;
|
|
353
|
+
support[id] = backend.supports(requirements);
|
|
354
|
+
if (isolation === "strict")
|
|
355
|
+
networkDenied[id] = (await backend.describe()).capabilities.networkDeniedByOS;
|
|
228
356
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
357
|
+
let selection;
|
|
358
|
+
try {
|
|
359
|
+
selection = python.selector.select({
|
|
360
|
+
runtime: runtimeSetting,
|
|
361
|
+
isolation,
|
|
362
|
+
...(rerunOfBackend ? { rerunOfBackend } : {}),
|
|
363
|
+
readiness,
|
|
364
|
+
support,
|
|
365
|
+
networkDenied,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (!isForgeError(error))
|
|
370
|
+
throw error;
|
|
371
|
+
return unavailable(runtimeSetting, runtimeSetting === "auto" ? undefined : readiness[runtimeSetting], error.message, readiness.managed);
|
|
372
|
+
}
|
|
373
|
+
const managed = python.backends.managed;
|
|
374
|
+
if (selection.engine === "managed" && managed) {
|
|
244
375
|
const wanted = new Set([
|
|
245
376
|
...(input.extras ?? []),
|
|
246
377
|
...(rerun?.extras ?? []),
|
|
@@ -250,157 +381,97 @@ export function registerAnalysisTools(registry, deps) {
|
|
|
250
381
|
const outcome = await ensureExtras(need, context);
|
|
251
382
|
if (outcome)
|
|
252
383
|
return outcome;
|
|
253
|
-
|
|
254
|
-
if (!
|
|
255
|
-
return failure(`runtime_unavailable: ${
|
|
384
|
+
const again = await managed.check(context.signal);
|
|
385
|
+
if (!again.ready)
|
|
386
|
+
return failure(`runtime_unavailable: ${again.reason}`);
|
|
256
387
|
}
|
|
257
388
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
389
|
+
const timeoutMs = Math.min(Number(input.timeoutMs ?? deps.limits.timeoutMs), deps.limits.timeoutMs);
|
|
390
|
+
const pad = await exposedScratchpad(sessionId);
|
|
391
|
+
const engineId = engineIdOf(selection.engine);
|
|
392
|
+
const report = newPythonRunReport();
|
|
393
|
+
const plan = {
|
|
394
|
+
code,
|
|
395
|
+
...(rerun ? { rerun } : {}),
|
|
396
|
+
inputs: input.inputs ?? [],
|
|
397
|
+
extrasAsked,
|
|
398
|
+
resolvePath: (path) => context.resolvePath ? context.resolvePath(path) : safePath(context.workspace, path),
|
|
399
|
+
...(deps.datasets ? { datasets: deps.datasets } : {}),
|
|
260
400
|
rootSessionId: root,
|
|
401
|
+
sessionId,
|
|
261
402
|
workspace: context.workspace,
|
|
262
403
|
...(context.runId ? { runId: context.runId } : {}),
|
|
263
404
|
...(context.callId ? { callId: context.callId } : {}),
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
405
|
+
publisher,
|
|
406
|
+
...(typeof input.title === "string" && input.title.trim()
|
|
407
|
+
? { title: input.title.trim() }
|
|
408
|
+
: {}),
|
|
409
|
+
publishOnError: input.publishOnError === true,
|
|
410
|
+
onText: (text) => context.emit(text),
|
|
411
|
+
...(pad ? { scratch: pad } : {}),
|
|
412
|
+
maxLogBytes: deps.limits.maxLogBytes,
|
|
413
|
+
engine: {
|
|
414
|
+
id: engineId,
|
|
415
|
+
isolation: python.engines.descriptors()[engineId]?.isolation ?? "none",
|
|
416
|
+
selectionReason: selection.reason,
|
|
417
|
+
},
|
|
418
|
+
report,
|
|
419
|
+
};
|
|
420
|
+
const { result } = await python.kernel.execute({
|
|
421
|
+
source: code,
|
|
422
|
+
language: "python",
|
|
423
|
+
selection: { engine: engineId, reason: selection.reason },
|
|
424
|
+
// Pyodide's start is a margin on top of the run's own deadline, not part of it (PD13).
|
|
425
|
+
limitOverrides: {
|
|
426
|
+
timeoutMs: timeoutMs + (selection.engine === "wasm" ? wasmStartupMs() : 0),
|
|
427
|
+
maxSourceBytes: MAX_CODE * 4,
|
|
428
|
+
},
|
|
429
|
+
payload: { [PYTHON_PLAN_KEY]: plan },
|
|
430
|
+
call: {
|
|
431
|
+
toolCallId: context.callId ?? "python_run",
|
|
432
|
+
session: sessionId,
|
|
433
|
+
rootSessionId: root,
|
|
434
|
+
workspace: context.workspace,
|
|
435
|
+
signal: context.signal,
|
|
436
|
+
// Python has no nested calls; these only describe the caller (the call passed the
|
|
437
|
+
// analysis.run gate, and its effect is `process`).
|
|
438
|
+
permissionMode: "ask",
|
|
439
|
+
allowAnalysis: true,
|
|
440
|
+
allowProcess: true,
|
|
441
|
+
emit: (progress) => context.emit(progress),
|
|
442
|
+
...(pad ? { tasksDir: join(dirname(pad), "tasks") } : {}),
|
|
443
|
+
},
|
|
267
444
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
for (const item of rerun.inputs) {
|
|
280
|
-
const added = await deps.jobs.addInput(job, item.source, item.name);
|
|
281
|
-
if (added.sha256 !== (item.copySha256 ?? item.sha256) && !item.datasetId)
|
|
282
|
-
throw new Error(`Input ${item.name} changed while the rerun started`);
|
|
283
|
-
inputs.push({
|
|
284
|
-
...added,
|
|
285
|
-
sha256: item.sha256,
|
|
286
|
-
copySha256: added.sha256,
|
|
287
|
-
...(item.datasetId ? { datasetId: item.datasetId } : {}),
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
manifest = rerun.manifest;
|
|
291
|
-
}
|
|
292
|
-
else
|
|
293
|
-
for (const item of input.inputs ?? []) {
|
|
294
|
-
if ((item.path === undefined) === (item.datasetId === undefined))
|
|
295
|
-
throw new Error("Each input needs exactly one of path or datasetId");
|
|
296
|
-
if (item.datasetId !== undefined) {
|
|
297
|
-
if (!deps.datasets)
|
|
298
|
-
throw new Error("Datasets are not available here");
|
|
299
|
-
const record = deps.datasets.getFor(item.datasetId, root);
|
|
300
|
-
const added = await deps.jobs.addInput(job, deps.datasets.file(record), `${basename(record.name)
|
|
301
|
-
.replace(/\.[^.]*$/, "")
|
|
302
|
-
.replace(/[^\w.-]+/g, "_") || "dataset"}.sqlite`);
|
|
303
|
-
// Provenance names the original content, so a rerun can verify it by hash.
|
|
304
|
-
inputs.push({
|
|
305
|
-
...added,
|
|
306
|
-
sha256: record.sha256,
|
|
307
|
-
copySha256: added.sha256,
|
|
308
|
-
datasetId: record.id,
|
|
309
|
-
});
|
|
310
|
-
manifest.push({
|
|
311
|
-
name: added.name,
|
|
312
|
-
kind: "dataset",
|
|
313
|
-
datasetId: record.id,
|
|
314
|
-
source: record.name,
|
|
315
|
-
format: record.format,
|
|
316
|
-
sheets: record.sheets,
|
|
317
|
-
});
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
const resolved = context.resolvePath
|
|
321
|
-
? await context.resolvePath(item.path)
|
|
322
|
-
: await safePath(context.workspace, item.path);
|
|
323
|
-
const added = await deps.jobs.addInput(job, resolved);
|
|
324
|
-
inputs.push({ ...added, copySha256: added.sha256 });
|
|
325
|
-
manifest.push({ name: added.name, kind: "file", bytes: added.bytes });
|
|
326
|
-
}
|
|
327
|
-
if (manifest.length)
|
|
328
|
-
await deps.jobs.writeInputsManifest(job, manifest);
|
|
329
|
-
await deps.jobs.recordInputs(job, {
|
|
330
|
-
inputs,
|
|
331
|
-
manifest,
|
|
332
|
-
...(extrasAsked.length ? { extras: extrasAsked } : {}),
|
|
333
|
-
});
|
|
445
|
+
// The tool's errors keep their shape: a failed preparation is the original error, a cancelled
|
|
446
|
+
// call ends by throwing, a crash of the launch rethrows.
|
|
447
|
+
if (report.prepareError)
|
|
448
|
+
throw report.prepareError;
|
|
449
|
+
const job = report.job;
|
|
450
|
+
if (!job)
|
|
451
|
+
return failure(`${result.error?.code ?? "ISOLATE_FAILURE"}: ${result.error?.message ?? "the script did not start"}`);
|
|
452
|
+
if (result.status === "cancelled") {
|
|
453
|
+
deps.jobs.finish(job.id, "cancelled");
|
|
454
|
+
context.signal.throwIfAborted();
|
|
455
|
+
throw new Error("python_run was cancelled");
|
|
334
456
|
}
|
|
335
|
-
|
|
457
|
+
if (report.crash) {
|
|
336
458
|
deps.jobs.finish(job.id, "failed", {
|
|
337
|
-
error:
|
|
459
|
+
error: report.crash instanceof Error ? report.crash.message : String(report.crash),
|
|
338
460
|
});
|
|
339
|
-
throw
|
|
461
|
+
throw report.crash;
|
|
340
462
|
}
|
|
341
|
-
const
|
|
342
|
-
const started = Date.now();
|
|
463
|
+
const nonce = report.nonce ?? "";
|
|
343
464
|
const logs = { stdout: join(job.logs, "stdout.log"), stderr: join(job.logs, "stderr.log") };
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
mounts: {
|
|
350
|
-
script: job.script,
|
|
351
|
-
input: job.input,
|
|
352
|
-
work: job.work,
|
|
353
|
-
staging: job.staging,
|
|
354
|
-
},
|
|
355
|
-
signal: context.signal,
|
|
356
|
-
timeoutMs,
|
|
357
|
-
maxBytes: TERMINAL_BYTES,
|
|
358
|
-
logFiles: logs,
|
|
359
|
-
maxLogBytes: deps.limits.maxLogBytes,
|
|
360
|
-
onData: context.emit,
|
|
361
|
-
});
|
|
362
|
-
else {
|
|
363
|
-
const interpreter = runtime;
|
|
364
|
-
const args = [
|
|
365
|
-
...(interpreter.prefixArgs ?? []),
|
|
366
|
-
"-E",
|
|
367
|
-
"-s",
|
|
368
|
-
"-B",
|
|
369
|
-
"-u",
|
|
370
|
-
"-X",
|
|
371
|
-
"utf8",
|
|
372
|
-
job.main,
|
|
373
|
-
];
|
|
374
|
-
result = await runProcess(interpreter.executable, args, {
|
|
375
|
-
cwd: job.work,
|
|
376
|
-
signal: context.signal,
|
|
377
|
-
timeoutMs,
|
|
378
|
-
maxBytes: TERMINAL_BYTES,
|
|
379
|
-
onOverflow: "truncate",
|
|
380
|
-
logFiles: logs,
|
|
381
|
-
maxLogBytes: deps.limits.maxLogBytes,
|
|
382
|
-
env: scriptEnv(job),
|
|
383
|
-
onData: context.emit,
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
catch (error) {
|
|
388
|
-
if (context.signal.aborted) {
|
|
389
|
-
deps.jobs.finish(job.id, "cancelled");
|
|
390
|
-
throw error;
|
|
391
|
-
}
|
|
392
|
-
const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
393
|
-
deps.jobs.finish(job.id, timedOut ? "timed_out" : "failed", {
|
|
394
|
-
error: error instanceof Error ? error.message : String(error),
|
|
395
|
-
});
|
|
396
|
-
if (!timedOut)
|
|
397
|
-
throw error;
|
|
398
|
-
const output = `${await readTail(logs.stdout, TEXT_TAIL)}${await readTail(logs.stderr, TEXT_TAIL)}`;
|
|
465
|
+
const engineLine = `engine: ${result.engine} (${result.isolation}) · reason: ${result.selectionReason}`;
|
|
466
|
+
if (result.status === "timed_out") {
|
|
467
|
+
deps.jobs.finish(job.id, "timed_out", { error: result.error?.message ?? "timed out" });
|
|
468
|
+
const stdoutTail = stripFrames(await readTail(logs.stdout, TEXT_TAIL), nonce);
|
|
469
|
+
const output = `${stdoutTail}${await readTail(logs.stderr, TEXT_TAIL)}`;
|
|
399
470
|
return {
|
|
400
471
|
content: [
|
|
401
472
|
{
|
|
402
473
|
type: "text",
|
|
403
|
-
text: `timed out after ${Math.round(timeoutMs / 1000)}s; nothing was published (${job.id})${output ? `\n--- output (last 4 KB) ---\n${tail(output, TEXT_TAIL)}` : ""}`,
|
|
474
|
+
text: withEngineLine(`timed out after ${Math.round(timeoutMs / 1000)}s; nothing was published (${job.id})${output ? `\n--- output (last 4 KB) ---\n${tail(output, TEXT_TAIL)}` : ""}`, engineLine),
|
|
404
475
|
},
|
|
405
476
|
{
|
|
406
477
|
type: "ui",
|
|
@@ -408,7 +479,7 @@ export function registerAnalysisTools(registry, deps) {
|
|
|
408
479
|
kind: "terminal",
|
|
409
480
|
command: "python main.py",
|
|
410
481
|
output,
|
|
411
|
-
durationMs:
|
|
482
|
+
durationMs: report.durationMs ?? timeoutMs,
|
|
412
483
|
truncated: true,
|
|
413
484
|
},
|
|
414
485
|
},
|
|
@@ -416,98 +487,75 @@ export function registerAnalysisTools(registry, deps) {
|
|
|
416
487
|
isError: true,
|
|
417
488
|
};
|
|
418
489
|
}
|
|
419
|
-
const durationMs =
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
? {
|
|
436
|
-
mode: "oci",
|
|
437
|
-
engine: settings.engine,
|
|
438
|
-
...(settings.image ? { image: settings.image } : {}),
|
|
439
|
-
extras: [],
|
|
440
|
-
}
|
|
441
|
-
: {
|
|
442
|
-
mode: "managed",
|
|
443
|
-
python: interpreter?.version,
|
|
444
|
-
extras: interpreter?.extras ?? [],
|
|
445
|
-
...(interpreter?.runtimeVersion
|
|
446
|
-
? { runtimeVersion: interpreter.runtimeVersion }
|
|
447
|
-
: {}),
|
|
448
|
-
},
|
|
449
|
-
scriptSha256: job.scriptSha256,
|
|
450
|
-
inputs: inputs.map((i) => ({
|
|
451
|
-
name: i.name,
|
|
452
|
-
sha256: i.sha256,
|
|
453
|
-
...(i.datasetId ? { datasetId: i.datasetId } : {}),
|
|
454
|
-
})),
|
|
455
|
-
...(rerun
|
|
456
|
-
? {
|
|
457
|
-
rerunOf: rerun.executionId,
|
|
458
|
-
...(rerun.artifactId ? { rerunOfArtifact: rerun.artifactId } : {}),
|
|
459
|
-
}
|
|
460
|
-
: {}),
|
|
461
|
-
},
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
catch (error) {
|
|
465
|
-
if (!(error instanceof ArtifactRejected))
|
|
466
|
-
throw error;
|
|
467
|
-
publishError = error.message;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
deps.jobs.finish(job.id, failed || publishError ? "failed" : "completed", {
|
|
471
|
-
exitCode: result.exitCode,
|
|
472
|
-
...(publishError ? { error: `publish: ${publishError}` } : {}),
|
|
490
|
+
const durationMs = report.durationMs ?? 0;
|
|
491
|
+
const proc = report.process;
|
|
492
|
+
const published = report.published;
|
|
493
|
+
const publishError = report.publishError;
|
|
494
|
+
const failed = result.status !== "succeeded";
|
|
495
|
+
// Anything else that stopped the run (a protocol violation, a refused state write...).
|
|
496
|
+
const forgeError = result.error && !["SCRIPT_RUNTIME", "PUBLICATION_REJECTED"].includes(result.error.code)
|
|
497
|
+
? result.error
|
|
498
|
+
: undefined;
|
|
499
|
+
deps.jobs.finish(job.id, failed ? "failed" : "completed", {
|
|
500
|
+
...(proc ? { exitCode: proc.exitCode } : {}),
|
|
501
|
+
...(publishError
|
|
502
|
+
? { error: `publish: ${publishError}` }
|
|
503
|
+
: forgeError
|
|
504
|
+
? { error: `${forgeError.code}: ${forgeError.message}` }
|
|
505
|
+
: {}),
|
|
473
506
|
});
|
|
474
|
-
const
|
|
507
|
+
const stdout = proc?.stdout ?? stripFrames(await readTail(logs.stdout, TERMINAL_BYTES), nonce);
|
|
508
|
+
const stderr = proc?.stderr ?? (await readTail(logs.stderr, TERMINAL_BYTES));
|
|
509
|
+
const warnings = [...published.flatMap((p) => p.warnings), ...report.warnings];
|
|
510
|
+
const seconds = (durationMs / 1000).toFixed(1);
|
|
475
511
|
const head = [
|
|
476
|
-
`${rerun ? `rerun of ${rerun.executionId} · ` : ""}exit ${
|
|
512
|
+
`${rerun ? `rerun of ${rerun.executionId} · ` : ""}${proc ? `exit ${proc.exitCode}` : "stopped"} in ${seconds}s${typeof report.runtime?.startupMs === "number" ? ` (startup ${(report.runtime.startupMs / 1000).toFixed(1)}s)` : ""}`,
|
|
477
513
|
publishError
|
|
478
514
|
? `publication rejected (nothing was published): ${publishError}`
|
|
479
515
|
: failed && input.publishOnError !== true
|
|
480
516
|
? "nothing was published (non-zero exit; pass publishOnError to keep partial outputs)"
|
|
481
517
|
: `published ${published.length} artifact${published.length === 1 ? "" : "s"}${published.length ? `: ${published.map(describeArtifact).join(", ")}` : ""}`,
|
|
482
518
|
].join(" · ");
|
|
519
|
+
const forgeOutput = outputText(result.output);
|
|
520
|
+
const forgeValue = result.value !== undefined ? JSON.stringify(result.value) : undefined;
|
|
483
521
|
const sections = [
|
|
484
522
|
head,
|
|
523
|
+
...(forgeError ? [`error: ${forgeError.code}: ${forgeError.message}`] : []),
|
|
485
524
|
...warnings.map((w) => `warning: ${w}`),
|
|
486
|
-
...(
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
525
|
+
...(stdout.trim() ? [`--- stdout (last 4 KB) ---\n${tail(stdout, TEXT_TAIL)}`] : []),
|
|
526
|
+
...(stderr.trim() ? [`--- stderr (last 4 KB) ---\n${tail(stderr, TEXT_TAIL)}`] : []),
|
|
527
|
+
...(forgeOutput || forgeValue !== undefined
|
|
528
|
+
? [
|
|
529
|
+
`--- forge output ---\n${clipText([forgeOutput, ...(forgeValue !== undefined ? [`value: ${forgeValue}`] : [])]
|
|
530
|
+
.filter(Boolean)
|
|
531
|
+
.join("\n"), 16_000).text}`,
|
|
532
|
+
]
|
|
491
533
|
: []),
|
|
492
534
|
];
|
|
493
|
-
|
|
535
|
+
// A script that failed on an import: point at the packages this backend really has.
|
|
536
|
+
const missingModule = failed && /ModuleNotFoundError|ImportError: No module named/.test(stderr)
|
|
537
|
+
? guideLine("PACKAGE_UNAVAILABLE").trim()
|
|
538
|
+
: "";
|
|
539
|
+
if (missingModule)
|
|
540
|
+
sections.push(missingModule);
|
|
541
|
+
const separator = stdout && stderr && !stdout.endsWith("\n") ? "\n" : "";
|
|
494
542
|
return {
|
|
495
543
|
content: [
|
|
496
|
-
{ type: "text", text: sections.join("\n") },
|
|
544
|
+
{ type: "text", text: withEngineLine(sections.join("\n"), engineLine) },
|
|
497
545
|
{
|
|
498
546
|
type: "ui",
|
|
499
547
|
block: {
|
|
500
548
|
kind: "terminal",
|
|
501
549
|
command: "python main.py",
|
|
502
|
-
output: tail(`${
|
|
503
|
-
exitCode:
|
|
550
|
+
output: tail(`${stdout}${separator}${stderr}`, TERMINAL_BYTES),
|
|
551
|
+
...(proc ? { exitCode: proc.exitCode } : {}),
|
|
504
552
|
durationMs,
|
|
505
|
-
...(
|
|
553
|
+
...(proc?.truncated ? { truncated: true } : {}),
|
|
506
554
|
},
|
|
507
555
|
},
|
|
508
556
|
...artifactBlocks(published),
|
|
509
557
|
],
|
|
510
|
-
...(failed
|
|
558
|
+
...(failed ? { isError: true } : {}),
|
|
511
559
|
};
|
|
512
560
|
},
|
|
513
561
|
});
|