@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.
Files changed (169) hide show
  1. package/dist/analysis/janitor.d.ts +48 -0
  2. package/dist/analysis/janitor.js +170 -18
  3. package/dist/analysis/jobs.d.ts +11 -2
  4. package/dist/analysis/jobs.js +13 -0
  5. package/dist/analysis/oci.d.ts +11 -2
  6. package/dist/analysis/oci.js +20 -4
  7. package/dist/analysis/python/sources.js +2 -1
  8. package/dist/analysis/rerun.d.ts +1 -1
  9. package/dist/analysis/status.d.ts +3 -1
  10. package/dist/analysis/status.js +3 -0
  11. package/dist/application.d.ts +38 -2
  12. package/dist/application.js +213 -4
  13. package/dist/changelog/data.js +64 -0
  14. package/dist/config.d.ts +110 -2
  15. package/dist/config.js +188 -6
  16. package/dist/core/call-internals.d.ts +22 -0
  17. package/dist/core/call-internals.js +9 -0
  18. package/dist/core/contracts.d.ts +2 -2
  19. package/dist/core/runner.js +111 -38
  20. package/dist/forge/broker.d.ts +51 -0
  21. package/dist/forge/broker.js +244 -0
  22. package/dist/forge/budget.d.ts +27 -0
  23. package/dist/forge/budget.js +60 -0
  24. package/dist/forge/catalog-view.d.ts +24 -0
  25. package/dist/forge/catalog-view.js +43 -0
  26. package/dist/forge/errors.d.ts +26 -0
  27. package/dist/forge/errors.js +49 -0
  28. package/dist/forge/guides/javascript.d.ts +25 -0
  29. package/dist/forge/guides/javascript.js +90 -0
  30. package/dist/forge/guides/provider.d.ts +42 -0
  31. package/dist/forge/guides/provider.js +96 -0
  32. package/dist/forge/guides/python-environment.d.ts +15 -0
  33. package/dist/forge/guides/python-environment.js +81 -0
  34. package/dist/forge/guides/python.d.ts +38 -0
  35. package/dist/forge/guides/python.js +170 -0
  36. package/dist/forge/javascript/engine.d.ts +44 -0
  37. package/dist/forge/javascript/engine.js +229 -0
  38. package/dist/forge/javascript/hosts/factory.d.ts +25 -0
  39. package/dist/forge/javascript/hosts/factory.js +21 -0
  40. package/dist/forge/javascript/hosts/framing.d.ts +17 -0
  41. package/dist/forge/javascript/hosts/framing.js +49 -0
  42. package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
  43. package/dist/forge/javascript/hosts/isolate-main.js +63 -0
  44. package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
  45. package/dist/forge/javascript/hosts/process-entry.js +3 -0
  46. package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
  47. package/dist/forge/javascript/hosts/process-host.js +160 -0
  48. package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
  49. package/dist/forge/javascript/hosts/worker-entry.js +3 -0
  50. package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
  51. package/dist/forge/javascript/hosts/worker-host.js +101 -0
  52. package/dist/forge/javascript/prelude.d.ts +2 -0
  53. package/dist/forge/javascript/prelude.js +333 -0
  54. package/dist/forge/javascript/probe.d.ts +2 -0
  55. package/dist/forge/javascript/probe.js +53 -0
  56. package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
  57. package/dist/forge/javascript/runtime-manifest.js +14 -0
  58. package/dist/forge/javascript/vm-runner.d.ts +21 -0
  59. package/dist/forge/javascript/vm-runner.js +301 -0
  60. package/dist/forge/javascript/wasm-asset.d.ts +8 -0
  61. package/dist/forge/javascript/wasm-asset.js +8 -0
  62. package/dist/forge/javascript/wasm.d.ts +15 -0
  63. package/dist/forge/javascript/wasm.js +54 -0
  64. package/dist/forge/journal.d.ts +105 -0
  65. package/dist/forge/journal.js +174 -0
  66. package/dist/forge/kernel.d.ts +76 -0
  67. package/dist/forge/kernel.js +338 -0
  68. package/dist/forge/limits.d.ts +38 -0
  69. package/dist/forge/limits.js +73 -0
  70. package/dist/forge/nested.d.ts +96 -0
  71. package/dist/forge/nested.js +214 -0
  72. package/dist/forge/ports.d.ts +120 -0
  73. package/dist/forge/ports.js +1 -0
  74. package/dist/forge/progress.d.ts +24 -0
  75. package/dist/forge/progress.js +76 -0
  76. package/dist/forge/protocol.d.ts +110 -0
  77. package/dist/forge/protocol.js +171 -0
  78. package/dist/forge/python/backend-selector.d.ts +38 -0
  79. package/dist/forge/python/backend-selector.js +111 -0
  80. package/dist/forge/python/backend.d.ts +89 -0
  81. package/dist/forge/python/backend.js +1 -0
  82. package/dist/forge/python/engine.d.ts +84 -0
  83. package/dist/forge/python/engine.js +279 -0
  84. package/dist/forge/python/frame-parser.d.ts +24 -0
  85. package/dist/forge/python/frame-parser.js +107 -0
  86. package/dist/forge/python/frame-transport.d.ts +40 -0
  87. package/dist/forge/python/frame-transport.js +101 -0
  88. package/dist/forge/python/import-scan.d.ts +22 -0
  89. package/dist/forge/python/import-scan.js +133 -0
  90. package/dist/forge/python/managed-backend.d.ts +30 -0
  91. package/dist/forge/python/managed-backend.js +111 -0
  92. package/dist/forge/python/oci-backend.d.ts +27 -0
  93. package/dist/forge/python/oci-backend.js +84 -0
  94. package/dist/forge/python/pyodide-backend.d.ts +40 -0
  95. package/dist/forge/python/pyodide-backend.js +289 -0
  96. package/dist/forge/python/pyodide-install.d.ts +41 -0
  97. package/dist/forge/python/pyodide-install.js +167 -0
  98. package/dist/forge/python/pyodide-manifest.d.ts +42 -0
  99. package/dist/forge/python/pyodide-manifest.js +376 -0
  100. package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
  101. package/dist/forge/python/pyodide-runner-source.js +3 -0
  102. package/dist/forge/python/rss.d.ts +2 -0
  103. package/dist/forge/python/rss.js +33 -0
  104. package/dist/forge/python/scratch-copy.d.ts +6 -0
  105. package/dist/forge/python/scratch-copy.js +61 -0
  106. package/dist/forge/python/settle.d.ts +6 -0
  107. package/dist/forge/python/settle.js +21 -0
  108. package/dist/forge/python/status.d.ts +8 -0
  109. package/dist/forge/python/status.js +15 -0
  110. package/dist/forge/python/wasm-node.d.ts +59 -0
  111. package/dist/forge/python/wasm-node.js +220 -0
  112. package/dist/forge/python/wiring.d.ts +39 -0
  113. package/dist/forge/python/wiring.js +47 -0
  114. package/dist/forge/registry.d.ts +26 -0
  115. package/dist/forge/registry.js +83 -0
  116. package/dist/forge/result.d.ts +58 -0
  117. package/dist/forge/result.js +86 -0
  118. package/dist/forge/routing-rule.d.ts +19 -0
  119. package/dist/forge/routing-rule.js +31 -0
  120. package/dist/forge/runtime.d.ts +64 -0
  121. package/dist/forge/runtime.js +88 -0
  122. package/dist/forge/scratch-files.d.ts +36 -0
  123. package/dist/forge/scratch-files.js +94 -0
  124. package/dist/forge/scratch-provider.d.ts +41 -0
  125. package/dist/forge/scratch-provider.js +79 -0
  126. package/dist/forge/scratch-quota.d.ts +8 -0
  127. package/dist/forge/scratch-quota.js +25 -0
  128. package/dist/forge/selector.d.ts +35 -0
  129. package/dist/forge/selector.js +51 -0
  130. package/dist/forge/session-dir.d.ts +59 -0
  131. package/dist/forge/session-dir.js +239 -0
  132. package/dist/forge/session-path.d.ts +31 -0
  133. package/dist/forge/session-path.js +61 -0
  134. package/dist/forge/slots.d.ts +16 -0
  135. package/dist/forge/slots.js +47 -0
  136. package/dist/forge/state-delta.d.ts +54 -0
  137. package/dist/forge/state-delta.js +99 -0
  138. package/dist/forge/state-machine.d.ts +20 -0
  139. package/dist/forge/state-machine.js +40 -0
  140. package/dist/forge/state-store.d.ts +25 -0
  141. package/dist/forge/state-store.js +50 -0
  142. package/dist/forge/task-log.d.ts +9 -0
  143. package/dist/forge/task-log.js +42 -0
  144. package/dist/forge/verdict.d.ts +20 -0
  145. package/dist/forge/verdict.js +28 -0
  146. package/dist/forge-isolate.d.ts +6 -0
  147. package/dist/forge-isolate.js +6 -0
  148. package/dist/index.d.ts +7 -0
  149. package/dist/index.js +7 -0
  150. package/dist/permissions/decide.d.ts +42 -0
  151. package/dist/permissions/decide.js +24 -0
  152. package/dist/resources/context.d.ts +2 -1
  153. package/dist/resources/context.js +2 -1
  154. package/dist/runtime/access.d.ts +34 -0
  155. package/dist/runtime/access.js +80 -0
  156. package/dist/runtime/process.d.ts +2 -1
  157. package/dist/runtime/process.js +5 -5
  158. package/dist/runtime/store.d.ts +11 -0
  159. package/dist/runtime/store.js +103 -0
  160. package/dist/tools/analysis.d.ts +25 -2
  161. package/dist/tools/analysis.js +315 -267
  162. package/dist/tools/execute.js +6 -7
  163. package/dist/tools/forge-guide.d.ts +6 -0
  164. package/dist/tools/forge-guide.js +60 -0
  165. package/dist/tools/js-run.d.ts +19 -0
  166. package/dist/tools/js-run.js +126 -0
  167. package/dist/tools/standard.d.ts +7 -1
  168. package/dist/tools/standard.js +44 -43
  169. package/package.json +7 -2
@@ -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
- /** Environment of the script: the usual whitelist, HOME/TMP inside work/, and the job paths. */
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 container = deps.oci?.mode() === "oci";
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
- : deps.startup.extras.length
102
- ? `the standard library, alisio_runtime and the "${deps.startup.extras.join(", ")}" extras (pandas, numpy, matplotlib…)`
103
- : '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';
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 (!rerunRef && typeof input.code !== "string")
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
- const container = deps.oci?.mode() === "oci" ? deps.oci.runtime() : undefined;
222
- // Container mode needs neither host Python nor extras (the image carries them).
223
- let runtime;
224
- if (container) {
225
- const unavailable = await container.unavailable(context.signal);
226
- if (unavailable)
227
- return failure(`runtime_unavailable: ${unavailable}`);
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
- else {
230
- runtime = await deps.runtime.interpreter(context.signal);
231
- if (!runtime.ok) {
232
- const hints = pythonInstallHints(await detectHintHost(runtime.found));
233
- return {
234
- content: [
235
- {
236
- type: "text",
237
- text: `runtime_unavailable: ${runtime.reason}.\n${installHintsText(hints)}`,
238
- },
239
- { type: "ui", block: { kind: "markdown", text: installHintsMarkdown(hints) } },
240
- ],
241
- isError: true,
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
- runtime = await deps.runtime.interpreter(context.signal);
254
- if (!runtime.ok)
255
- return failure(`runtime_unavailable: ${runtime.reason}`);
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 job = await deps.jobs.create({
259
- sessionId,
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
- code,
265
- runtime: container ? "oci" : "managed",
266
- ...(rerun ? { rerunOf: rerun.executionId } : {}),
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
- const inputs = [];
269
- let manifest = [];
270
- const extrasAsked = [
271
- ...new Set([
272
- ...(input.extras ?? []),
273
- ...(rerun?.extras ?? []),
274
- ]),
275
- ];
276
- try {
277
- if (rerun) {
278
- // The verified copies of the original run, under the same names.
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
- catch (error) {
457
+ if (report.crash) {
336
458
  deps.jobs.finish(job.id, "failed", {
337
- error: error instanceof Error ? error.message : String(error),
459
+ error: report.crash instanceof Error ? report.crash.message : String(report.crash),
338
460
  });
339
- throw error;
461
+ throw report.crash;
340
462
  }
341
- const timeoutMs = Math.min(Number(input.timeoutMs ?? deps.limits.timeoutMs), deps.limits.timeoutMs);
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
- let result;
345
- try {
346
- if (container)
347
- result = await container.run({
348
- executionId: job.id,
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: Date.now() - started,
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 = Date.now() - started;
420
- const failed = result.exitCode !== 0;
421
- let published = [];
422
- let publishError;
423
- if (!failed || input.publishOnError === true) {
424
- try {
425
- const settings = container?.settings;
426
- const interpreter = runtime?.ok ? runtime : undefined;
427
- published = await publisher.publishOutputs(job.staging, {
428
- executionId: job.id,
429
- ...(typeof input.title === "string" && input.title.trim()
430
- ? { title: input.title.trim() }
431
- : {}),
432
- ...(failed ? { partial: true } : {}),
433
- provenance: {
434
- runtime: settings
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 warnings = published.flatMap((p) => p.warnings);
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 ${result.exitCode} in ${(durationMs / 1000).toFixed(1)}s`,
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
- ...(result.stdout.trim()
487
- ? [`--- stdout (last 4 KB) ---\n${tail(result.stdout, TEXT_TAIL)}`]
488
- : []),
489
- ...(result.stderr.trim()
490
- ? [`--- stderr (last 4 KB) ---\n${tail(result.stderr, TEXT_TAIL)}`]
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
- const separator = result.stdout && result.stderr && !result.stdout.endsWith("\n") ? "\n" : "";
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(`${result.stdout}${separator}${result.stderr}`, TERMINAL_BYTES),
503
- exitCode: result.exitCode,
550
+ output: tail(`${stdout}${separator}${stderr}`, TERMINAL_BYTES),
551
+ ...(proc ? { exitCode: proc.exitCode } : {}),
504
552
  durationMs,
505
- ...(result.truncated ? { truncated: true } : {}),
553
+ ...(proc?.truncated ? { truncated: true } : {}),
506
554
  },
507
555
  },
508
556
  ...artifactBlocks(published),
509
557
  ],
510
- ...(failed || publishError ? { isError: true } : {}),
558
+ ...(failed ? { isError: true } : {}),
511
559
  };
512
560
  },
513
561
  });