@alisio/core 0.5.2 → 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 +28 -2
- package/dist/application.js +189 -4
- package/dist/changelog/data.js +26 -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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PythonEngine` (Python spec §4, PD1): the `ExecutionEngine` that runs a `python_run` script
|
|
3
|
+
* through a `PythonBackend`. It reuses what `python_run` always did, only moved behind the kernel's
|
|
4
|
+
* lifecycle: `prepare` creates the job folders and copies the authorized inputs, `run` launches the
|
|
5
|
+
* script and reads its stdout frames, `collect` publishes the files it left (before the shared
|
|
6
|
+
* state is committed), `terminate` stops the process.
|
|
7
|
+
*
|
|
8
|
+
* One instance per backend (`python-managed`, `python-oci`). Everything that belongs to ONE call
|
|
9
|
+
* (the code, the inputs, the publisher, where output goes) travels in the request payload as a
|
|
10
|
+
* `PythonRunPlan`; the engine keeps nothing between calls. `PythonRunReport` is how the tool gets
|
|
11
|
+
* back what the kernel's `ForgeResult` does not carry (the raw process result, the artifacts).
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { basename } from "node:path";
|
|
15
|
+
import { ArtifactRejected } from "../../artifacts/store.js";
|
|
16
|
+
import { FORGE_ERROR_CODES, ForgeError } from "../errors.js";
|
|
17
|
+
import { stripFrames } from "./frame-parser.js";
|
|
18
|
+
import { StdoutFrameTransport } from "./frame-transport.js";
|
|
19
|
+
/** Key of the plan in `ExecutionRequest.payload`. */
|
|
20
|
+
export const PYTHON_PLAN_KEY = "python";
|
|
21
|
+
/** The terminal text the model and the UI read is bounded to this many bytes. */
|
|
22
|
+
export const TERMINAL_BYTES = 256 * 1024;
|
|
23
|
+
/** A process that ignores the kernel's stop is killed by its own backstop this much later. */
|
|
24
|
+
const BACKSTOP_SLACK_MS = 15_000;
|
|
25
|
+
export const newPythonRunReport = () => ({
|
|
26
|
+
inputs: [],
|
|
27
|
+
published: [],
|
|
28
|
+
warnings: [],
|
|
29
|
+
});
|
|
30
|
+
const KNOWN_CODES = new Set(FORGE_ERROR_CODES);
|
|
31
|
+
export class PythonEngine {
|
|
32
|
+
id;
|
|
33
|
+
#backend;
|
|
34
|
+
#jobs;
|
|
35
|
+
constructor(deps) {
|
|
36
|
+
this.#backend = deps.backend;
|
|
37
|
+
this.#jobs = deps.jobs;
|
|
38
|
+
this.id = `python-${deps.backend.id}`;
|
|
39
|
+
}
|
|
40
|
+
describe() {
|
|
41
|
+
return this.#backend.describe();
|
|
42
|
+
}
|
|
43
|
+
async prepare(request, ctx) {
|
|
44
|
+
const plan = request.payload?.[PYTHON_PLAN_KEY];
|
|
45
|
+
if (!plan)
|
|
46
|
+
throw new ForgeError("INVALID_INPUT", "python_run did not provide its run plan");
|
|
47
|
+
const { report } = plan;
|
|
48
|
+
const jobs = this.#jobs;
|
|
49
|
+
const rerun = plan.rerun;
|
|
50
|
+
let job;
|
|
51
|
+
try {
|
|
52
|
+
job = await jobs.create({
|
|
53
|
+
sessionId: plan.sessionId,
|
|
54
|
+
rootSessionId: plan.rootSessionId,
|
|
55
|
+
workspace: plan.workspace,
|
|
56
|
+
...(plan.runId ? { runId: plan.runId } : {}),
|
|
57
|
+
...(plan.callId ? { callId: plan.callId } : {}),
|
|
58
|
+
code: plan.code,
|
|
59
|
+
runtime: this.#backend.id,
|
|
60
|
+
...(rerun ? { rerunOf: rerun.executionId } : {}),
|
|
61
|
+
});
|
|
62
|
+
report.job = job;
|
|
63
|
+
const inputs = report.inputs;
|
|
64
|
+
let manifest = [];
|
|
65
|
+
if (rerun) {
|
|
66
|
+
// The verified copies of the original run, under the same names.
|
|
67
|
+
for (const item of rerun.inputs) {
|
|
68
|
+
const added = await jobs.addInput(job, item.source, item.name);
|
|
69
|
+
if (added.sha256 !== (item.copySha256 ?? item.sha256) && !item.datasetId)
|
|
70
|
+
throw new Error(`Input ${item.name} changed while the rerun started`);
|
|
71
|
+
inputs.push({
|
|
72
|
+
...added,
|
|
73
|
+
sha256: item.sha256,
|
|
74
|
+
copySha256: added.sha256,
|
|
75
|
+
...(item.datasetId ? { datasetId: item.datasetId } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
manifest = rerun.manifest;
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
for (const item of plan.inputs) {
|
|
82
|
+
if ((item.path === undefined) === (item.datasetId === undefined))
|
|
83
|
+
throw new Error("Each input needs exactly one of path or datasetId");
|
|
84
|
+
if (item.datasetId !== undefined) {
|
|
85
|
+
if (!plan.datasets)
|
|
86
|
+
throw new Error("Datasets are not available here");
|
|
87
|
+
const record = plan.datasets.getFor(item.datasetId, plan.rootSessionId);
|
|
88
|
+
const added = await jobs.addInput(job, plan.datasets.file(record), `${basename(record.name)
|
|
89
|
+
.replace(/\.[^.]*$/, "")
|
|
90
|
+
.replace(/[^\w.-]+/g, "_") || "dataset"}.sqlite`);
|
|
91
|
+
// Provenance names the original content, so a rerun can verify it by hash.
|
|
92
|
+
inputs.push({
|
|
93
|
+
...added,
|
|
94
|
+
sha256: record.sha256,
|
|
95
|
+
copySha256: added.sha256,
|
|
96
|
+
datasetId: record.id,
|
|
97
|
+
});
|
|
98
|
+
manifest.push({
|
|
99
|
+
name: added.name,
|
|
100
|
+
kind: "dataset",
|
|
101
|
+
datasetId: record.id,
|
|
102
|
+
source: record.name,
|
|
103
|
+
format: record.format,
|
|
104
|
+
sheets: record.sheets,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const resolved = await plan.resolvePath(item.path);
|
|
109
|
+
const added = await jobs.addInput(job, resolved);
|
|
110
|
+
inputs.push({ ...added, copySha256: added.sha256 });
|
|
111
|
+
manifest.push({ name: added.name, kind: "file", bytes: added.bytes });
|
|
112
|
+
}
|
|
113
|
+
if (manifest.length)
|
|
114
|
+
await jobs.writeInputsManifest(job, manifest);
|
|
115
|
+
await jobs.recordInputs(job, {
|
|
116
|
+
inputs,
|
|
117
|
+
manifest,
|
|
118
|
+
...(plan.extrasAsked.length ? { extras: plan.extrasAsked } : {}),
|
|
119
|
+
});
|
|
120
|
+
const state = request.payload?.state;
|
|
121
|
+
await jobs.writeForgeState(job, state ?? {});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
report.prepareError = error;
|
|
125
|
+
if (job)
|
|
126
|
+
jobs.finish(job.id, "failed", {
|
|
127
|
+
error: error instanceof Error ? error.message : String(error),
|
|
128
|
+
});
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
const nonce = randomBytes(16).toString("hex");
|
|
132
|
+
report.nonce = nonce;
|
|
133
|
+
const prepared = {
|
|
134
|
+
executionId: ctx.executionId,
|
|
135
|
+
engine: this.id,
|
|
136
|
+
limits: ctx.limits,
|
|
137
|
+
analysisExecutionId: job.id,
|
|
138
|
+
plan,
|
|
139
|
+
job,
|
|
140
|
+
nonce,
|
|
141
|
+
abort: new AbortController(),
|
|
142
|
+
finished: false,
|
|
143
|
+
};
|
|
144
|
+
return prepared;
|
|
145
|
+
}
|
|
146
|
+
async run(prepared, channel) {
|
|
147
|
+
const p = prepared;
|
|
148
|
+
const { plan, job } = p;
|
|
149
|
+
const { report } = plan;
|
|
150
|
+
let failure;
|
|
151
|
+
const transport = new StdoutFrameTransport({
|
|
152
|
+
nonce: p.nonce,
|
|
153
|
+
receive: (message) => channel.receive(message),
|
|
154
|
+
onText: plan.onText,
|
|
155
|
+
onFailure: (error) => {
|
|
156
|
+
failure = error;
|
|
157
|
+
p.abort.abort();
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const signal = AbortSignal.any([channel.signal, p.abort.signal]);
|
|
161
|
+
const started = Date.now();
|
|
162
|
+
const launchJob = {
|
|
163
|
+
executionId: job.id,
|
|
164
|
+
paths: job,
|
|
165
|
+
env: { ALISIO_FORGE_NONCE: p.nonce, ALISIO_FORGE_ENGINE: this.id },
|
|
166
|
+
...(plan.scratch ? { scratch: plan.scratch } : {}),
|
|
167
|
+
signal,
|
|
168
|
+
backstopMs: p.limits.timeoutMs + BACKSTOP_SLACK_MS,
|
|
169
|
+
maxBytes: TERMINAL_BYTES,
|
|
170
|
+
maxLogBytes: plan.maxLogBytes,
|
|
171
|
+
logFiles: {
|
|
172
|
+
stdout: `${job.logs}/stdout.log`,
|
|
173
|
+
stderr: `${job.logs}/stderr.log`,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
try {
|
|
177
|
+
const running = await this.#backend.launch(launchJob, {
|
|
178
|
+
onData: (chunk, stream) => stream === "stdout" ? transport.feed(chunk) : plan.onText(chunk),
|
|
179
|
+
});
|
|
180
|
+
p.running = running;
|
|
181
|
+
report.runtime = running.runtime;
|
|
182
|
+
const result = await running.result;
|
|
183
|
+
report.process = { ...result, stdout: stripFrames(result.stdout, p.nonce) };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (!signal.aborted)
|
|
187
|
+
report.crash = error;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
report.durationMs = Date.now() - started;
|
|
191
|
+
await transport.end();
|
|
192
|
+
report.warnings.push(...transport.warnings);
|
|
193
|
+
}
|
|
194
|
+
if (failure)
|
|
195
|
+
return { kind: "failed", code: failure.code, message: failure.message };
|
|
196
|
+
// A limit the backend enforced by killing the process (the wasm memory watchdog).
|
|
197
|
+
const limit = p.running?.limit?.();
|
|
198
|
+
if (limit && !signal.aborted)
|
|
199
|
+
return { kind: "failed", code: limit.code, message: limit.message };
|
|
200
|
+
if (signal.aborted)
|
|
201
|
+
return { kind: "failed", code: "ISOLATE_FAILURE", message: "the script was stopped" };
|
|
202
|
+
if (report.crash)
|
|
203
|
+
return {
|
|
204
|
+
kind: "failed",
|
|
205
|
+
code: "ISOLATE_FAILURE",
|
|
206
|
+
message: report.crash instanceof Error ? report.crash.message : String(report.crash),
|
|
207
|
+
};
|
|
208
|
+
const exitCode = report.process?.exitCode ?? 1;
|
|
209
|
+
p.finished = true;
|
|
210
|
+
const fatal = transport.fatal;
|
|
211
|
+
if (fatal)
|
|
212
|
+
return {
|
|
213
|
+
kind: "failed",
|
|
214
|
+
code: (KNOWN_CODES.has(fatal.code) ? fatal.code : "SCRIPT_RUNTIME"),
|
|
215
|
+
message: fatal.message,
|
|
216
|
+
};
|
|
217
|
+
if (exitCode !== 0)
|
|
218
|
+
return {
|
|
219
|
+
kind: "failed",
|
|
220
|
+
code: "SCRIPT_RUNTIME",
|
|
221
|
+
message: `the script exited with code ${exitCode}`,
|
|
222
|
+
};
|
|
223
|
+
const value = transport.completion?.value;
|
|
224
|
+
return { kind: "completed", ...(value !== undefined ? { value } : {}) };
|
|
225
|
+
}
|
|
226
|
+
/** Publishes the files the script left, when the run earned it (exit 0, or `publishOnError`). */
|
|
227
|
+
async collect(prepared, outcome) {
|
|
228
|
+
const p = prepared;
|
|
229
|
+
const { plan, job } = p;
|
|
230
|
+
const { report } = plan;
|
|
231
|
+
if (!p.finished || !p.running)
|
|
232
|
+
return outcome;
|
|
233
|
+
const failed = outcome.kind === "failed";
|
|
234
|
+
if (failed && !plan.publishOnError)
|
|
235
|
+
return outcome;
|
|
236
|
+
try {
|
|
237
|
+
report.published = await plan.publisher.publishOutputs(job.staging, {
|
|
238
|
+
executionId: job.id,
|
|
239
|
+
...(plan.title ? { title: plan.title } : {}),
|
|
240
|
+
...(failed ? { partial: true } : {}),
|
|
241
|
+
provenance: {
|
|
242
|
+
runtime: p.running.runtime,
|
|
243
|
+
scriptSha256: job.scriptSha256,
|
|
244
|
+
inputs: report.inputs.map((i) => ({
|
|
245
|
+
name: i.name,
|
|
246
|
+
sha256: i.sha256,
|
|
247
|
+
...(i.datasetId ? { datasetId: i.datasetId } : {}),
|
|
248
|
+
})),
|
|
249
|
+
...(plan.rerun
|
|
250
|
+
? {
|
|
251
|
+
rerunOf: plan.rerun.executionId,
|
|
252
|
+
...(plan.rerun.artifactId ? { rerunOfArtifact: plan.rerun.artifactId } : {}),
|
|
253
|
+
}
|
|
254
|
+
: {}),
|
|
255
|
+
engine: plan.engine.id,
|
|
256
|
+
isolation: plan.engine.isolation,
|
|
257
|
+
selectionReason: plan.engine.selectionReason,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (!(error instanceof ArtifactRejected))
|
|
263
|
+
throw error;
|
|
264
|
+
report.publishError = error.message;
|
|
265
|
+
return { kind: "failed", code: "PUBLICATION_REJECTED", message: error.message };
|
|
266
|
+
}
|
|
267
|
+
return outcome;
|
|
268
|
+
}
|
|
269
|
+
async terminate(prepared, reason) {
|
|
270
|
+
const p = prepared;
|
|
271
|
+
p.abort.abort();
|
|
272
|
+
if (!p.running)
|
|
273
|
+
return { confirmed: true, method: "kill", elapsedMs: 0 };
|
|
274
|
+
return this.#backend.terminate(p.running, reason);
|
|
275
|
+
}
|
|
276
|
+
async dispose(prepared) {
|
|
277
|
+
prepared.abort.abort();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const FRAME_MARK = "\u001EALISIO-FORGE:";
|
|
2
|
+
/** The longest frame body kept, in characters (a character is at least one byte of the cap). */
|
|
3
|
+
export declare const MAX_FRAME_CHARS: number;
|
|
4
|
+
export type FrameEvent = {
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: "frame";
|
|
9
|
+
json: string;
|
|
10
|
+
}
|
|
11
|
+
/** A frame line longer than the cap: its body was dropped. */
|
|
12
|
+
| {
|
|
13
|
+
type: "oversize";
|
|
14
|
+
};
|
|
15
|
+
export declare const framePrefix: (nonce: string) => string;
|
|
16
|
+
export declare class FrameDecoder {
|
|
17
|
+
#private;
|
|
18
|
+
constructor(nonce: string);
|
|
19
|
+
feed(chunk: string): FrameEvent[];
|
|
20
|
+
/** The stream ended: whatever is held was never completed as a frame line. */
|
|
21
|
+
flush(): FrameEvent[];
|
|
22
|
+
}
|
|
23
|
+
/** `text` without its frame lines (the form of stdout the model and the terminal see). */
|
|
24
|
+
export declare function stripFrames(text: string, nonce: string): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure line framing of the Python stdout transport (Python spec §7). Nothing here does I/O.
|
|
3
|
+
*
|
|
4
|
+
* A frame is a stdout line that STARTS with `\u001eALISIO-FORGE:<nonce>:` followed by JSON. Every
|
|
5
|
+
* other byte of stdout is ordinary script output and is handed back unchanged, in order, as soon as
|
|
6
|
+
* it is known not to be a frame (a plain line streams chunk by chunk; only a line that still looks
|
|
7
|
+
* like the start of a frame is held). The nonce only tells frames from output: it grants nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { MAX_MESSAGE_BYTES } from "../protocol.js";
|
|
10
|
+
export const FRAME_MARK = "\u001eALISIO-FORGE:";
|
|
11
|
+
/** The longest frame body kept, in characters (a character is at least one byte of the cap). */
|
|
12
|
+
export const MAX_FRAME_CHARS = MAX_MESSAGE_BYTES + 512;
|
|
13
|
+
export const framePrefix = (nonce) => `${FRAME_MARK}${nonce}:`;
|
|
14
|
+
export class FrameDecoder {
|
|
15
|
+
#prefix;
|
|
16
|
+
#mode = "start";
|
|
17
|
+
/** Characters of the current line that matched the prefix so far (mode `start`). */
|
|
18
|
+
#held = "";
|
|
19
|
+
#body = "";
|
|
20
|
+
constructor(nonce) {
|
|
21
|
+
this.#prefix = framePrefix(nonce);
|
|
22
|
+
}
|
|
23
|
+
feed(chunk) {
|
|
24
|
+
const events = [];
|
|
25
|
+
let rest = chunk;
|
|
26
|
+
while (rest.length > 0) {
|
|
27
|
+
if (this.#mode === "text") {
|
|
28
|
+
const newline = rest.indexOf("\n");
|
|
29
|
+
if (newline < 0) {
|
|
30
|
+
events.push({ type: "text", text: rest });
|
|
31
|
+
return events;
|
|
32
|
+
}
|
|
33
|
+
events.push({ type: "text", text: rest.slice(0, newline + 1) });
|
|
34
|
+
rest = rest.slice(newline + 1);
|
|
35
|
+
this.#mode = "start";
|
|
36
|
+
}
|
|
37
|
+
else if (this.#mode === "drop") {
|
|
38
|
+
const newline = rest.indexOf("\n");
|
|
39
|
+
if (newline < 0)
|
|
40
|
+
return events;
|
|
41
|
+
rest = rest.slice(newline + 1);
|
|
42
|
+
this.#mode = "start";
|
|
43
|
+
}
|
|
44
|
+
else if (this.#mode === "frame") {
|
|
45
|
+
const newline = rest.indexOf("\n");
|
|
46
|
+
this.#body += newline < 0 ? rest : rest.slice(0, newline);
|
|
47
|
+
if (this.#body.length > MAX_FRAME_CHARS) {
|
|
48
|
+
events.push({ type: "oversize" });
|
|
49
|
+
this.#body = "";
|
|
50
|
+
this.#mode = "drop";
|
|
51
|
+
rest = newline < 0 ? "" : rest.slice(newline);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (newline < 0)
|
|
55
|
+
return events;
|
|
56
|
+
events.push({ type: "frame", json: this.#body.replace(/\r$/, "") });
|
|
57
|
+
this.#body = "";
|
|
58
|
+
this.#mode = "start";
|
|
59
|
+
rest = rest.slice(newline + 1);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
rest = this.#start(rest, events);
|
|
63
|
+
}
|
|
64
|
+
return events;
|
|
65
|
+
}
|
|
66
|
+
/** The stream ended: whatever is held was never completed as a frame line. */
|
|
67
|
+
flush() {
|
|
68
|
+
const events = [];
|
|
69
|
+
if (this.#mode === "start" && this.#held)
|
|
70
|
+
events.push({ type: "text", text: this.#held });
|
|
71
|
+
if (this.#mode === "frame" && this.#body)
|
|
72
|
+
events.push({ type: "frame", json: this.#body.replace(/\r$/, "") });
|
|
73
|
+
this.#mode = "start";
|
|
74
|
+
this.#held = "";
|
|
75
|
+
this.#body = "";
|
|
76
|
+
return events;
|
|
77
|
+
}
|
|
78
|
+
/** Reads the beginning of a line; returns what is left of `rest` for the next mode. */
|
|
79
|
+
#start(rest, events) {
|
|
80
|
+
for (let i = 0; i < rest.length; i++) {
|
|
81
|
+
const ch = rest[i];
|
|
82
|
+
if (ch === this.#prefix[this.#held.length]) {
|
|
83
|
+
this.#held += ch;
|
|
84
|
+
if (this.#held.length === this.#prefix.length) {
|
|
85
|
+
this.#held = "";
|
|
86
|
+
this.#mode = "frame";
|
|
87
|
+
return rest.slice(i + 1);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// The line is ordinary output: release what matched and let the text mode stream the rest.
|
|
92
|
+
if (this.#held)
|
|
93
|
+
events.push({ type: "text", text: this.#held });
|
|
94
|
+
this.#held = "";
|
|
95
|
+
this.#mode = "text";
|
|
96
|
+
return rest.slice(i);
|
|
97
|
+
}
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** `text` without its frame lines (the form of stdout the model and the terminal see). */
|
|
102
|
+
export function stripFrames(text, nonce) {
|
|
103
|
+
const decoder = new FrameDecoder(nonce);
|
|
104
|
+
return [...decoder.feed(text), ...decoder.flush()]
|
|
105
|
+
.flatMap((event) => (event.type === "text" ? [event.text] : []))
|
|
106
|
+
.join("");
|
|
107
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StdoutFrameTransport` (Python spec §7, PD9): turns the frames of a script's stdout into common
|
|
3
|
+
* protocol messages for the kernel. The guest is untrusted: every frame is validated like any
|
|
4
|
+
* guest message (schema, sequence, size, depth, forbidden keys) with tool calls switched off, and a
|
|
5
|
+
* frame the host cannot accept ends the execution with `PROTOCOL_VIOLATION`.
|
|
6
|
+
*
|
|
7
|
+
* Messages reach the kernel one at a time, in the order they were written. Past `MAX_FRAMES`
|
|
8
|
+
* messages the rest is ignored with one warning, and the run goes on. Ordinary output is passed
|
|
9
|
+
* to `onText` exactly as it came.
|
|
10
|
+
*/
|
|
11
|
+
import { ForgeError } from "../errors.ts";
|
|
12
|
+
import { type GuestMessage } from "../protocol.ts";
|
|
13
|
+
export declare const MAX_FRAMES = 1000;
|
|
14
|
+
export interface FrameTransportOptions {
|
|
15
|
+
nonce: string;
|
|
16
|
+
/** The kernel's side. Rejecting with a `ForgeError` ends the execution with that code. */
|
|
17
|
+
receive: (message: GuestMessage) => Promise<unknown>;
|
|
18
|
+
/** Output that is not a frame, in order. */
|
|
19
|
+
onText: (text: string) => void;
|
|
20
|
+
/** Called once, when the execution must end because of what the guest sent. */
|
|
21
|
+
onFailure: (error: ForgeError) => void;
|
|
22
|
+
maxMessages?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class StdoutFrameTransport {
|
|
25
|
+
#private;
|
|
26
|
+
readonly warnings: string[];
|
|
27
|
+
constructor(options: FrameTransportOptions);
|
|
28
|
+
/** The value of the guest's `complete` message, when it sent one. */
|
|
29
|
+
get completion(): {
|
|
30
|
+
value?: unknown;
|
|
31
|
+
} | undefined;
|
|
32
|
+
/** The guest's own `fatal` message, when it sent one. */
|
|
33
|
+
get fatal(): {
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
} | undefined;
|
|
37
|
+
feed(chunk: string): void;
|
|
38
|
+
/** Flushes what is held and resolves once every accepted message was handed to the kernel. */
|
|
39
|
+
end(): Promise<void>;
|
|
40
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `StdoutFrameTransport` (Python spec §7, PD9): turns the frames of a script's stdout into common
|
|
3
|
+
* protocol messages for the kernel. The guest is untrusted: every frame is validated like any
|
|
4
|
+
* guest message (schema, sequence, size, depth, forbidden keys) with tool calls switched off, and a
|
|
5
|
+
* frame the host cannot accept ends the execution with `PROTOCOL_VIOLATION`.
|
|
6
|
+
*
|
|
7
|
+
* Messages reach the kernel one at a time, in the order they were written. Past `MAX_FRAMES`
|
|
8
|
+
* messages the rest is ignored with one warning, and the run goes on. Ordinary output is passed
|
|
9
|
+
* to `onText` exactly as it came.
|
|
10
|
+
*/
|
|
11
|
+
import { ForgeError, isForgeError } from "../errors.js";
|
|
12
|
+
import { GuestMessageValidator } from "../protocol.js";
|
|
13
|
+
import { FrameDecoder } from "./frame-parser.js";
|
|
14
|
+
export const MAX_FRAMES = 1000;
|
|
15
|
+
export class StdoutFrameTransport {
|
|
16
|
+
#options;
|
|
17
|
+
#decoder;
|
|
18
|
+
#validator = new GuestMessageValidator({ toolCalls: false });
|
|
19
|
+
#max;
|
|
20
|
+
#queue = Promise.resolve();
|
|
21
|
+
#seen = 0;
|
|
22
|
+
#failed = false;
|
|
23
|
+
#completion;
|
|
24
|
+
#fatal;
|
|
25
|
+
warnings = [];
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.#options = options;
|
|
28
|
+
this.#decoder = new FrameDecoder(options.nonce);
|
|
29
|
+
this.#max = options.maxMessages ?? MAX_FRAMES;
|
|
30
|
+
}
|
|
31
|
+
/** The value of the guest's `complete` message, when it sent one. */
|
|
32
|
+
get completion() {
|
|
33
|
+
return this.#completion;
|
|
34
|
+
}
|
|
35
|
+
/** The guest's own `fatal` message, when it sent one. */
|
|
36
|
+
get fatal() {
|
|
37
|
+
return this.#fatal;
|
|
38
|
+
}
|
|
39
|
+
feed(chunk) {
|
|
40
|
+
this.#handle(this.#decoder.feed(chunk));
|
|
41
|
+
}
|
|
42
|
+
/** Flushes what is held and resolves once every accepted message was handed to the kernel. */
|
|
43
|
+
async end() {
|
|
44
|
+
this.#handle(this.#decoder.flush());
|
|
45
|
+
await this.#queue;
|
|
46
|
+
}
|
|
47
|
+
#handle(events) {
|
|
48
|
+
for (const event of events) {
|
|
49
|
+
if (event.type === "text")
|
|
50
|
+
this.#options.onText(event.text);
|
|
51
|
+
else
|
|
52
|
+
this.#frame(event);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
#fail(error) {
|
|
56
|
+
if (this.#failed)
|
|
57
|
+
return;
|
|
58
|
+
this.#failed = true;
|
|
59
|
+
this.#options.onFailure(error);
|
|
60
|
+
}
|
|
61
|
+
#frame(event) {
|
|
62
|
+
if (this.#failed)
|
|
63
|
+
return;
|
|
64
|
+
if (++this.#seen > this.#max) {
|
|
65
|
+
if (this.warnings.length === 0)
|
|
66
|
+
this.warnings.push(`the script sent more than ${this.#max} protocol messages; the rest was ignored`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (event.type === "oversize")
|
|
70
|
+
return this.#fail(new ForgeError("PROTOCOL_VIOLATION", "protocol message larger than allowed"));
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = JSON.parse(event.json);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return this.#fail(new ForgeError("PROTOCOL_VIOLATION", "protocol frame is not valid JSON"));
|
|
77
|
+
}
|
|
78
|
+
const checked = this.#validator.accept(raw);
|
|
79
|
+
if (!checked.ok)
|
|
80
|
+
return this.#fail(checked.error);
|
|
81
|
+
const message = checked.message;
|
|
82
|
+
if (message.type === "complete")
|
|
83
|
+
this.#completion = { ...message.payload };
|
|
84
|
+
else if (message.type === "fatal")
|
|
85
|
+
this.#fatal = message.payload;
|
|
86
|
+
if (message.type === "complete" || message.type === "fatal" || message.type === "ready")
|
|
87
|
+
return;
|
|
88
|
+
this.#queue = this.#queue.then(async () => {
|
|
89
|
+
if (this.#failed)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
await this.#options.receive(message);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.#fail(isForgeError(error)
|
|
96
|
+
? error
|
|
97
|
+
: new ForgeError("ISOLATE_FAILURE", error instanceof Error ? error.message : String(error)));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static import scan of a Python script (Python spec §6.2). Pure and interpreter-free: it reads the
|
|
3
|
+
* top-level module names a script imports so the backend selector can tell whether the Pyodide
|
|
4
|
+
* profiles that were downloaded cover it. It is NOT a security control: `__import__`,
|
|
5
|
+
* `importlib` and `exec` are invisible to it, and a wrong guess only means the run fails with
|
|
6
|
+
* `PACKAGE_UNAVAILABLE` and is never retried elsewhere (PD5).
|
|
7
|
+
*/
|
|
8
|
+
import { type PyodideProfile } from "./pyodide-manifest.ts";
|
|
9
|
+
/** Top-level module names imported by `source`, in order of appearance, without repeats. */
|
|
10
|
+
export declare function scanImports(source: string): string[];
|
|
11
|
+
/** Package name for an import name, when a Pyodide profile ships it. */
|
|
12
|
+
export declare const packageOfModule: (module: string) => string | undefined;
|
|
13
|
+
export interface ModuleClassification {
|
|
14
|
+
/** The smallest profile that covers every module the wheels provide. */
|
|
15
|
+
profile: PyodideProfile;
|
|
16
|
+
/** Packages the imports need, without repeats. */
|
|
17
|
+
packages: string[];
|
|
18
|
+
/** Modules neither in the standard library nor in any profile. */
|
|
19
|
+
unknown: string[];
|
|
20
|
+
}
|
|
21
|
+
/** `science` when a package only that profile carries is imported, else `analysis`, else `base`. */
|
|
22
|
+
export declare function classifyModules(modules: readonly string[]): ModuleClassification;
|