@alisio/core 0.5.2 → 0.7.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 (204) 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 +28 -2
  12. package/dist/application.js +189 -4
  13. package/dist/changelog/data.js +42 -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/eval/canonical.d.ts +14 -0
  21. package/dist/eval/canonical.js +40 -0
  22. package/dist/eval/cassette.d.ts +180 -0
  23. package/dist/eval/cassette.js +424 -0
  24. package/dist/eval/compare.d.ts +147 -0
  25. package/dist/eval/compare.js +436 -0
  26. package/dist/eval/engine.d.ts +144 -0
  27. package/dist/eval/engine.js +728 -0
  28. package/dist/eval/export.d.ts +40 -0
  29. package/dist/eval/export.js +90 -0
  30. package/dist/eval/fingerprint.d.ts +88 -0
  31. package/dist/eval/fingerprint.js +186 -0
  32. package/dist/eval/normalize.d.ts +68 -0
  33. package/dist/eval/normalize.js +138 -0
  34. package/dist/eval/paths.d.ts +11 -0
  35. package/dist/eval/paths.js +50 -0
  36. package/dist/eval/plan.d.ts +26 -0
  37. package/dist/eval/plan.js +44 -0
  38. package/dist/eval/report.d.ts +53 -0
  39. package/dist/eval/report.js +258 -0
  40. package/dist/eval/run-suite.d.ts +68 -0
  41. package/dist/eval/run-suite.js +94 -0
  42. package/dist/eval/secrets.d.ts +12 -0
  43. package/dist/eval/secrets.js +45 -0
  44. package/dist/eval/stats.d.ts +17 -0
  45. package/dist/eval/stats.js +50 -0
  46. package/dist/eval/suite.d.ts +20 -0
  47. package/dist/eval/suite.js +165 -0
  48. package/dist/eval/variants.d.ts +35 -0
  49. package/dist/eval/variants.js +137 -0
  50. package/dist/eval/verifiers.d.ts +49 -0
  51. package/dist/eval/verifiers.js +264 -0
  52. package/dist/forge/broker.d.ts +51 -0
  53. package/dist/forge/broker.js +244 -0
  54. package/dist/forge/budget.d.ts +27 -0
  55. package/dist/forge/budget.js +60 -0
  56. package/dist/forge/catalog-view.d.ts +24 -0
  57. package/dist/forge/catalog-view.js +43 -0
  58. package/dist/forge/errors.d.ts +26 -0
  59. package/dist/forge/errors.js +49 -0
  60. package/dist/forge/guides/javascript.d.ts +25 -0
  61. package/dist/forge/guides/javascript.js +90 -0
  62. package/dist/forge/guides/provider.d.ts +42 -0
  63. package/dist/forge/guides/provider.js +96 -0
  64. package/dist/forge/guides/python-environment.d.ts +15 -0
  65. package/dist/forge/guides/python-environment.js +81 -0
  66. package/dist/forge/guides/python.d.ts +38 -0
  67. package/dist/forge/guides/python.js +170 -0
  68. package/dist/forge/javascript/engine.d.ts +44 -0
  69. package/dist/forge/javascript/engine.js +229 -0
  70. package/dist/forge/javascript/hosts/factory.d.ts +25 -0
  71. package/dist/forge/javascript/hosts/factory.js +21 -0
  72. package/dist/forge/javascript/hosts/framing.d.ts +17 -0
  73. package/dist/forge/javascript/hosts/framing.js +49 -0
  74. package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
  75. package/dist/forge/javascript/hosts/isolate-main.js +63 -0
  76. package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
  77. package/dist/forge/javascript/hosts/process-entry.js +3 -0
  78. package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
  79. package/dist/forge/javascript/hosts/process-host.js +160 -0
  80. package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
  81. package/dist/forge/javascript/hosts/worker-entry.js +3 -0
  82. package/dist/forge/javascript/hosts/worker-host.d.ts +27 -0
  83. package/dist/forge/javascript/hosts/worker-host.js +116 -0
  84. package/dist/forge/javascript/prelude.d.ts +2 -0
  85. package/dist/forge/javascript/prelude.js +333 -0
  86. package/dist/forge/javascript/probe.d.ts +2 -0
  87. package/dist/forge/javascript/probe.js +53 -0
  88. package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
  89. package/dist/forge/javascript/runtime-manifest.js +14 -0
  90. package/dist/forge/javascript/vm-runner.d.ts +21 -0
  91. package/dist/forge/javascript/vm-runner.js +301 -0
  92. package/dist/forge/javascript/wasm-asset.d.ts +8 -0
  93. package/dist/forge/javascript/wasm-asset.js +8 -0
  94. package/dist/forge/javascript/wasm.d.ts +15 -0
  95. package/dist/forge/javascript/wasm.js +54 -0
  96. package/dist/forge/journal.d.ts +105 -0
  97. package/dist/forge/journal.js +174 -0
  98. package/dist/forge/kernel.d.ts +76 -0
  99. package/dist/forge/kernel.js +339 -0
  100. package/dist/forge/limits.d.ts +38 -0
  101. package/dist/forge/limits.js +73 -0
  102. package/dist/forge/nested.d.ts +96 -0
  103. package/dist/forge/nested.js +214 -0
  104. package/dist/forge/ports.d.ts +120 -0
  105. package/dist/forge/ports.js +1 -0
  106. package/dist/forge/progress.d.ts +24 -0
  107. package/dist/forge/progress.js +76 -0
  108. package/dist/forge/protocol.d.ts +110 -0
  109. package/dist/forge/protocol.js +171 -0
  110. package/dist/forge/python/backend-selector.d.ts +38 -0
  111. package/dist/forge/python/backend-selector.js +111 -0
  112. package/dist/forge/python/backend.d.ts +89 -0
  113. package/dist/forge/python/backend.js +1 -0
  114. package/dist/forge/python/engine.d.ts +84 -0
  115. package/dist/forge/python/engine.js +279 -0
  116. package/dist/forge/python/frame-parser.d.ts +24 -0
  117. package/dist/forge/python/frame-parser.js +107 -0
  118. package/dist/forge/python/frame-transport.d.ts +40 -0
  119. package/dist/forge/python/frame-transport.js +101 -0
  120. package/dist/forge/python/import-scan.d.ts +22 -0
  121. package/dist/forge/python/import-scan.js +133 -0
  122. package/dist/forge/python/managed-backend.d.ts +30 -0
  123. package/dist/forge/python/managed-backend.js +111 -0
  124. package/dist/forge/python/oci-backend.d.ts +27 -0
  125. package/dist/forge/python/oci-backend.js +84 -0
  126. package/dist/forge/python/pyodide-backend.d.ts +40 -0
  127. package/dist/forge/python/pyodide-backend.js +289 -0
  128. package/dist/forge/python/pyodide-install.d.ts +41 -0
  129. package/dist/forge/python/pyodide-install.js +167 -0
  130. package/dist/forge/python/pyodide-manifest.d.ts +42 -0
  131. package/dist/forge/python/pyodide-manifest.js +376 -0
  132. package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
  133. package/dist/forge/python/pyodide-runner-source.js +3 -0
  134. package/dist/forge/python/rss.d.ts +2 -0
  135. package/dist/forge/python/rss.js +33 -0
  136. package/dist/forge/python/scratch-copy.d.ts +6 -0
  137. package/dist/forge/python/scratch-copy.js +61 -0
  138. package/dist/forge/python/settle.d.ts +6 -0
  139. package/dist/forge/python/settle.js +21 -0
  140. package/dist/forge/python/status.d.ts +8 -0
  141. package/dist/forge/python/status.js +15 -0
  142. package/dist/forge/python/wasm-node.d.ts +59 -0
  143. package/dist/forge/python/wasm-node.js +220 -0
  144. package/dist/forge/python/wiring.d.ts +39 -0
  145. package/dist/forge/python/wiring.js +47 -0
  146. package/dist/forge/registry.d.ts +26 -0
  147. package/dist/forge/registry.js +83 -0
  148. package/dist/forge/result.d.ts +58 -0
  149. package/dist/forge/result.js +86 -0
  150. package/dist/forge/routing-rule.d.ts +19 -0
  151. package/dist/forge/routing-rule.js +31 -0
  152. package/dist/forge/runtime.d.ts +64 -0
  153. package/dist/forge/runtime.js +88 -0
  154. package/dist/forge/scratch-files.d.ts +36 -0
  155. package/dist/forge/scratch-files.js +94 -0
  156. package/dist/forge/scratch-provider.d.ts +41 -0
  157. package/dist/forge/scratch-provider.js +79 -0
  158. package/dist/forge/scratch-quota.d.ts +8 -0
  159. package/dist/forge/scratch-quota.js +25 -0
  160. package/dist/forge/selector.d.ts +35 -0
  161. package/dist/forge/selector.js +51 -0
  162. package/dist/forge/session-dir.d.ts +59 -0
  163. package/dist/forge/session-dir.js +239 -0
  164. package/dist/forge/session-path.d.ts +31 -0
  165. package/dist/forge/session-path.js +61 -0
  166. package/dist/forge/slots.d.ts +16 -0
  167. package/dist/forge/slots.js +47 -0
  168. package/dist/forge/state-delta.d.ts +54 -0
  169. package/dist/forge/state-delta.js +99 -0
  170. package/dist/forge/state-machine.d.ts +20 -0
  171. package/dist/forge/state-machine.js +40 -0
  172. package/dist/forge/state-store.d.ts +25 -0
  173. package/dist/forge/state-store.js +50 -0
  174. package/dist/forge/task-log.d.ts +9 -0
  175. package/dist/forge/task-log.js +42 -0
  176. package/dist/forge/verdict.d.ts +20 -0
  177. package/dist/forge/verdict.js +28 -0
  178. package/dist/forge-isolate.d.ts +6 -0
  179. package/dist/forge-isolate.js +6 -0
  180. package/dist/index.d.ts +23 -1
  181. package/dist/index.js +23 -1
  182. package/dist/permissions/decide.d.ts +42 -0
  183. package/dist/permissions/decide.js +24 -0
  184. package/dist/plan/exit-plan.js +2 -1
  185. package/dist/resources/context.d.ts +2 -1
  186. package/dist/resources/context.js +2 -1
  187. package/dist/runtime/access.d.ts +34 -0
  188. package/dist/runtime/access.js +80 -0
  189. package/dist/runtime/ids.d.ts +21 -0
  190. package/dist/runtime/ids.js +41 -1
  191. package/dist/runtime/process.d.ts +2 -1
  192. package/dist/runtime/process.js +5 -5
  193. package/dist/runtime/store.d.ts +11 -0
  194. package/dist/runtime/store.js +106 -2
  195. package/dist/tools/analysis.d.ts +25 -2
  196. package/dist/tools/analysis.js +315 -267
  197. package/dist/tools/execute.js +6 -7
  198. package/dist/tools/forge-guide.d.ts +6 -0
  199. package/dist/tools/forge-guide.js +60 -0
  200. package/dist/tools/js-run.d.ts +19 -0
  201. package/dist/tools/js-run.js +126 -0
  202. package/dist/tools/standard.d.ts +7 -1
  203. package/dist/tools/standard.js +44 -43
  204. package/package.json +7 -2
@@ -0,0 +1,170 @@
1
+ /** Python spec §4.3, one row per backend; the guide never states more than this. */
2
+ const GUARANTEES = {
3
+ managed: [
4
+ "Isolation: none. The script runs with your user permissions: it can read your files and use the network.",
5
+ "Termination: the process is killed on timeout. Memory: no hard limit.",
6
+ ],
7
+ oci: [
8
+ "Isolation: os-container. Network off (`--network=none`); only the job folders are mounted (the scratchpad read-only).",
9
+ "Termination: the container is killed on timeout. Memory: hard limit (cgroup).",
10
+ ],
11
+ wasm: [
12
+ "Isolation: wasm-process (Pyodide in a restricted Node process). It reads only the Pyodide distribution and the job, and writes only its work/ and out/ folders.",
13
+ "Network: NOT blocked by the OS on Node 22/24; do not rely on it being off. Memory: soft limit (RSS watchdog). Termination: the process is killed on timeout.",
14
+ ],
15
+ };
16
+ const SETUP_NOTE = "Setup commands are run by the user, never by you: `alisio analysis setup --wasm [--profile analysis|science]` or `alisio analysis setup --oci`.";
17
+ const has = (env, pkg) => {
18
+ if (!env.available)
19
+ return false;
20
+ if (!env.packages.known)
21
+ return undefined;
22
+ return env.packages.packages.includes(pkg);
23
+ };
24
+ /** What the model should know about one package group right now. */
25
+ function availability(env, pkgs) {
26
+ const states = pkgs.map((pkg) => has(env, pkg));
27
+ if (states.every((s) => s === true))
28
+ return "available";
29
+ if (states.every((s) => s === undefined))
30
+ return "depends on the container image (not listed here)";
31
+ const missing = pkgs.filter((_, i) => states[i] === false);
32
+ return `NOT available (${missing.join(", ")} missing)`;
33
+ }
34
+ function installAdvice(env, profile) {
35
+ if (env.backend === "wasm")
36
+ return `ask the user to run \`alisio analysis setup --wasm --profile ${profile}\``;
37
+ if (env.backend === "managed")
38
+ return `pass \`extras: ["${profile}"]\` (the user approves the download once)`;
39
+ return "use an image that includes them (`analysis.oci.image`)";
40
+ }
41
+ export function pythonRuntimeGuide(env) {
42
+ return [
43
+ "# python_run runtime",
44
+ "",
45
+ "Your script runs once in a job folder outside the repository, with `main.py` as the entry. The helper package `alisio_runtime` (standard library only) is importable.",
46
+ "",
47
+ "## Folders (environment variables)",
48
+ "- `ALISIO_OUTPUT_DIR`: everything written here is published as a downloadable artifact only if the script exits 0 (`publishOnError` keeps partial output). Publication is validated by the host: paths, sizes and types. An `outputs.json` limits publishing to the files it declares.",
49
+ "- `ALISIO_INPUT_DIR`: read-only copies of `inputs`. `ALISIO_WORK_DIR`: scratch, also the current directory.",
50
+ "- `ALISIO_SCRATCH_DIR`: the session scratchpad, READ-ONLY (write scripts there with the file tools, run them with `codePath`).",
51
+ "- `ALISIO_EXECUTION_ID` identifies the run. `ALISIO_FORGE_*` belong to the protocol: never print them or fake their lines.",
52
+ "",
53
+ "## alisio_runtime",
54
+ "- `output_dir()`, `input_dir()`, `work_dir()`, `execution_id()`; `outputs.path(name)` makes a new output path, `outputs.declare(name, title)` registers it.",
55
+ "- `forge.progress(message, completed=, total=)`, `forge.text(value)` (explicit output for you), `forge.result(value)` (the final JSON value), `forge.state.get/set/delete` (small JSON shared with js_run; kept only if the run succeeds).",
56
+ "- `datasets`, `html`, `charts`, `svg`: see `python.data` and `python.dashboards`.",
57
+ "",
58
+ "## Rules",
59
+ "- Print little: stdout and stderr return only their last 4 KB. Use `forge.text`/`forge.result` for what you need to read.",
60
+ "- No nested tool calls and no JavaScript from Python. Chain tools yourself, or compose with `js_run` first.",
61
+ "- Keep each script short; save long ones to the scratchpad. Rerun an earlier analysis unchanged with `rerunOf`.",
62
+ `- Packages depend on the backend: see \`python.environment\` (now: ${env.available ? env.backend : "no backend can run"}).`,
63
+ ].join("\n");
64
+ }
65
+ export function pythonDataGuide(env) {
66
+ const pandas = availability(env, ["pandas"]);
67
+ return [
68
+ "# Data in python_run",
69
+ "",
70
+ "## Which tool",
71
+ "- A SQL question on a loaded dataset: `data_query` (no code, read-only). Use `data_inspect` first for tables and columns.",
72
+ "- Joins across files, statistics, models, exports, or anything SQL cannot do: `python_run`.",
73
+ "",
74
+ "## Inputs",
75
+ "- `inputs: [{ datasetId }]` copies a dataset to `$ALISIO_INPUT_DIR/<name>.sqlite`; `inputs: [{ path }]` copies a workspace file.",
76
+ "- `from alisio_runtime import datasets`: `datasets.inputs()` lists what was placed; `datasets.open(name)` returns a read-only `sqlite3` connection (table `data`, or `s_<sheet>` for workbooks); `datasets.tables(name)` and `datasets.columns(name, table)` describe them.",
77
+ "- XLSX files are converted to SQLite by `alisio_runtime/xlsx_to_sqlite.py` (standard library `sqlite3`); you rarely need to call it yourself.",
78
+ "",
79
+ "## pandas",
80
+ `- pandas is ${pandas} on the backend that would run now.`,
81
+ "- Without pandas use `sqlite3`, `csv`, `statistics` and `json` from the standard library.",
82
+ '- `pandas.read_sql_query("SELECT * FROM data", datasets.open(name))` loads a dataset.',
83
+ ...(env.available && env.backend === "wasm"
84
+ ? ["- The wasm backend limits the total input size (`analysis.wasm.maxInputBytes`)."]
85
+ : []),
86
+ ].join("\n");
87
+ }
88
+ export function pythonMlGuide(env) {
89
+ const science = availability(env, ["scipy", "statsmodels", "scikit-learn"]);
90
+ const where = env.available
91
+ ? `Backend that would run now: ${env.backend}.`
92
+ : "No Python backend can run right now (see `python.environment`).";
93
+ return [
94
+ "# Statistics and machine learning",
95
+ "",
96
+ where,
97
+ `- scikit-learn, statsmodels and scipy are ${science}.`,
98
+ ...(env.available && has(env, "scikit-learn") === false
99
+ ? [`- To get them: ${installAdvice(env, "science")}.`]
100
+ : []),
101
+ "",
102
+ "## Patterns",
103
+ "- Split first: `train_test_split(X, y, test_size=0.2, random_state=0)`; fix `random_state` so a rerun gives the same numbers.",
104
+ "- Tabular baseline: `sklearn.ensemble.RandomForestClassifier` or `sklearn.linear_model.LogisticRegression`; report accuracy AND a baseline (majority class) with `forge.result({...})`.",
105
+ "- Inference and tests: `scipy.stats.ttest_ind`, `chi2_contingency`; regression with coefficients and p-values: `statsmodels.api.OLS(y, sm.add_constant(X)).fit().summary()`.",
106
+ '- Return numbers, not objects: `forge.result({"r2": float(r2), "n": int(n)})`. JSON cannot hold NaN or Infinity; convert them first.',
107
+ "- Keep a time budget in mind: heavy training can exceed the call's timeout. Subsample first, then scale up.",
108
+ "- Pandas first, then models; without the science packages use the standard library `statistics` for descriptive work.",
109
+ ].join("\n");
110
+ }
111
+ export function pythonDashboardsGuide(env) {
112
+ const plotly = availability(env, ["plotly"]);
113
+ const matplotlib = availability(env, ["matplotlib"]);
114
+ return [
115
+ "# Charts and dashboards",
116
+ "",
117
+ ...(env.smartDashboard
118
+ ? [
119
+ "- A standard dashboard from a dataset: `dashboard_generate` (no code). Use `python_run` only for visuals its catalog does not cover.",
120
+ ]
121
+ : ["- `dashboard_generate` is not enabled here: build dashboards with `alisio_runtime`."]),
122
+ "",
123
+ "## alisio_runtime (no extra packages)",
124
+ "- `charts`: Chart.js is bundled. `charts.pie/donut/bar/hbar/line/area/scatter(labels, values, title=, fmt='currency:USD', locale=)` return HTML; compose with `charts.card(title, chart)`, `charts.grid(*cards)`, `charts.kpis((label, value), ...)`, then `charts.write('dashboard.html', 'Title', body)`.",
125
+ "- `html`: `html.page/table/write` for plain reports. `svg`: static SVG, no script.",
126
+ "- Never hand-write SVG arcs, never load a CDN: HTML is shown offline, so inline every script and style. One chart per card; pie only for 2 to 5 parts.",
127
+ "",
128
+ "## Static images",
129
+ `- matplotlib is ${matplotlib}. It runs with the Agg backend: save a PNG with \`fig.savefig(output_dir() / 'chart.png', dpi=150)\`; \`plt.show()\` does nothing.`,
130
+ `- plotly is ${plotly}. Write a self-contained file with \`fig.write_html(path, include_plotlyjs=True)\` (several MB); prefer \`charts\` when it is enough.`,
131
+ "",
132
+ "A folder under the output directory with an `index.html` is published as one multi-file dashboard.",
133
+ ].join("\n");
134
+ }
135
+ export function pythonEnvironmentGuide(env) {
136
+ if (!env.available)
137
+ return [
138
+ "# Python environment",
139
+ "",
140
+ "No Python backend can run a script right now.",
141
+ "",
142
+ env.reason,
143
+ "",
144
+ SETUP_NOTE,
145
+ "Until one is ready, use `js_run` or the dataset tools (`data_inspect`, `data_query`).",
146
+ ].join("\n");
147
+ const packages = env.packages.known
148
+ ? env.packages.packages.length
149
+ ? env.packages.packages.join(", ")
150
+ : "none beyond the standard library"
151
+ : "those baked into the container image (not listed here)";
152
+ return [
153
+ "# Python environment",
154
+ "",
155
+ `Backend that runs your next script: ${env.backend} (${env.reason}).`,
156
+ "",
157
+ "## Guarantees of this backend",
158
+ ...GUARANTEES[env.backend].map((line) => `- ${line}`),
159
+ "",
160
+ "## Packages here",
161
+ `- Standard library and \`alisio_runtime\` always. ${env.packages.detail}`,
162
+ `- Installed: ${packages}.`,
163
+ "- Anything else fails with `PACKAGE_UNAVAILABLE`/`ModuleNotFoundError` and is never retried on another backend.",
164
+ ...(env.packages.known && !env.packages.packages.includes("scikit-learn")
165
+ ? ["", `For scikit-learn, statsmodels and scipy: ${installAdvice(env, "science")}.`]
166
+ : []),
167
+ "",
168
+ SETUP_NOTE,
169
+ ].join("\n");
170
+ }
@@ -0,0 +1,44 @@
1
+ import type { EngineDescriptor } from "@alisio/sdk";
2
+ import type { EngineOutcome, ExecutionEngine, ExecutionRequest, IsolateHost, IsolateSession, KernelChannel, PreparedExecution, TerminationReason, TerminationReport, TrustedExecutionContext } from "../ports.ts";
3
+ import type { IsolateHostFactory } from "./hosts/factory.ts";
4
+ export interface PreparedJavaScript extends PreparedExecution {
5
+ readonly source: string;
6
+ /** The state snapshot the script starts with. */
7
+ readonly state: Record<string, unknown>;
8
+ readonly catalogSize: number;
9
+ readonly host: IsolateHost;
10
+ session?: IsolateSession;
11
+ /** Ends the run with a host verdict; set by `run`, used when a stop cannot be confirmed. */
12
+ forceEnd?: () => void;
13
+ }
14
+ export interface JavaScriptEngineOptions {
15
+ hosts: IsolateHostFactory;
16
+ /** The verified module; defaults to the process-wide cache. */
17
+ loadModule?: () => Promise<WebAssembly.Module>;
18
+ }
19
+ export declare class JavaScriptEngine implements ExecutionEngine {
20
+ #private;
21
+ readonly id: "javascript-quickjs";
22
+ constructor(options: JavaScriptEngineOptions);
23
+ /**
24
+ * Cheap self-check (runs at startup): the WASM matches its pinned hash and compiles, and the
25
+ * files the chosen host starts exist. The full spin-up of an isolate is `probe()`.
26
+ */
27
+ describe(): Promise<EngineDescriptor>;
28
+ /** The kind of host that runs the isolates (`forge status`). */
29
+ get hostKind(): "worker" | "process";
30
+ prepare(request: ExecutionRequest, ctx: TrustedExecutionContext): Promise<PreparedJavaScript>;
31
+ run(prepared: PreparedExecution, channel: KernelChannel): Promise<EngineOutcome>;
32
+ terminate(prepared: PreparedExecution, reason: TerminationReason): Promise<TerminationReport>;
33
+ dispose(prepared: PreparedExecution): Promise<void>;
34
+ /**
35
+ * Full self-test (`alisio forge status`): starts a real isolate, runs `return 1` and reports how
36
+ * long it took. Nothing here is on the hot path of a normal execution.
37
+ */
38
+ probe(): Promise<{
39
+ ok: boolean;
40
+ reason?: string;
41
+ bootMs?: number;
42
+ host: "worker" | "process";
43
+ }>;
44
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `JavaScriptEngine` (spec §6, D1-D5): the `ExecutionEngine` strategy that runs a script in a
3
+ * QuickJS-WASM VM inside an isolate host. It owns the transport and the lifecycle of the isolate;
4
+ * what the guest's messages MEAN (tool calls, state, output, budget) is the kernel's broker.
5
+ *
6
+ * Host-side checks, whatever the guest says: every message passes `GuestMessageValidator`
7
+ * (schema, sequence, correlation, size, depth, forbidden keys, capability gate); a violation or a
8
+ * handler error terminates the isolate and fails the execution with the matching code.
9
+ */
10
+ import { access } from "node:fs/promises";
11
+ import { isForgeError } from "../errors.js";
12
+ import { GuestMessageValidator } from "../protocol.js";
13
+ import { resolveIsolateCommand } from "./hosts/process-host.js";
14
+ import { workerEntryUrl } from "./hosts/worker-host.js";
15
+ import { PRELUDE_HASH } from "./prelude.js";
16
+ import { FORGE_PROTOCOL_VERSION, JAVASCRIPT_ENGINE_VERSION } from "./runtime-manifest.js";
17
+ import { loadQuickJsModule } from "./wasm.js";
18
+ const GUEST_LIMIT_KEYS = [
19
+ "timeoutMs",
20
+ "memoryBytes",
21
+ "stackBytes",
22
+ "maxToolCalls",
23
+ "maxConcurrency",
24
+ "maxCallInputBytes",
25
+ "maxCallResponseBytes",
26
+ "maxRetainedOutputBytes",
27
+ ];
28
+ const guestLimits = (limits) => Object.fromEntries(GUEST_LIMIT_KEYS.map((key) => [key, limits[key]]));
29
+ export class JavaScriptEngine {
30
+ id = "javascript-quickjs";
31
+ #options;
32
+ constructor(options) {
33
+ this.#options = options;
34
+ }
35
+ #module() {
36
+ return (this.#options.loadModule ?? loadQuickJsModule)();
37
+ }
38
+ /**
39
+ * Cheap self-check (runs at startup): the WASM matches its pinned hash and compiles, and the
40
+ * files the chosen host starts exist. The full spin-up of an isolate is `probe()`.
41
+ */
42
+ async describe() {
43
+ const base = {
44
+ id: this.id,
45
+ language: "javascript",
46
+ version: JAVASCRIPT_ENGINE_VERSION,
47
+ isolation: "wasm-isolate",
48
+ ready: true,
49
+ capabilities: {
50
+ cooperativeInterrupt: true,
51
+ hardTermination: true,
52
+ hardMemoryLimit: false,
53
+ networkDeniedByOS: false,
54
+ toolCalls: true,
55
+ },
56
+ };
57
+ const unavailable = (reason) => ({ ...base, ready: false, reason });
58
+ try {
59
+ await this.#module();
60
+ }
61
+ catch (error) {
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ return unavailable(`the QuickJS runtime failed verification (${message}); reinstall @alisio/core`);
64
+ }
65
+ try {
66
+ if (this.#options.hosts.kind === "worker")
67
+ await access(workerEntryUrl());
68
+ else {
69
+ const command = resolveIsolateCommand();
70
+ await access(command.execPath);
71
+ }
72
+ }
73
+ catch (error) {
74
+ const message = error instanceof Error ? error.message : String(error);
75
+ return unavailable(`the ${this.#options.hosts.kind} isolate host cannot start (${message})`);
76
+ }
77
+ return base;
78
+ }
79
+ /** The kind of host that runs the isolates (`forge status`). */
80
+ get hostKind() {
81
+ return this.#options.hosts.kind;
82
+ }
83
+ async prepare(request, ctx) {
84
+ await this.#module();
85
+ const payload = request.payload ?? {};
86
+ return {
87
+ executionId: ctx.executionId,
88
+ engine: this.id,
89
+ limits: ctx.limits,
90
+ source: request.source,
91
+ state: payload.state ?? {},
92
+ catalogSize: typeof payload.catalogSize === "number" ? payload.catalogSize : 0,
93
+ host: await this.#options.hosts.create(),
94
+ };
95
+ }
96
+ async run(prepared, channel) {
97
+ const job = prepared;
98
+ const session = job.host.start();
99
+ job.session = session;
100
+ const validator = new GuestMessageValidator({ toolCalls: true });
101
+ let hostSeq = 0;
102
+ let settled = false;
103
+ return new Promise((resolve) => {
104
+ const send = (reply) => {
105
+ if (settled)
106
+ return;
107
+ // Answering a call or a lookup closes its correlation (a second answer is a bug).
108
+ if (reply.type === "result")
109
+ validator.resolveCall(reply.payload.callId);
110
+ else if (reply.type === "catalog.result")
111
+ validator.resolveCatalog(reply.payload.requestId);
112
+ session.send({ protocol: FORGE_PROTOCOL_VERSION, seq: hostSeq++, ...reply });
113
+ };
114
+ const finish = (outcome, stop) => {
115
+ if (settled)
116
+ return;
117
+ settled = true;
118
+ validator.close();
119
+ // The guest ends itself after its terminal message; a failure stops it from here.
120
+ if (stop)
121
+ void session.stop(stop);
122
+ resolve(outcome);
123
+ };
124
+ const fail = (code, message, stop) => finish({ kind: "failed", code, message }, stop);
125
+ // The kernel stopped the run (deadline or cancellation) and the isolate never confirmed it
126
+ // is gone: no terminal message and no exit will come, so the host ends the run itself. The
127
+ // kernel's own verdict decides the status; the unconfirmed report is journaled from there.
128
+ job.forceEnd = () => finish({
129
+ kind: "failed",
130
+ code: "ISOLATE_FAILURE",
131
+ message: "the isolate did not confirm that it stopped",
132
+ });
133
+ session.onStreamError((reason) => fail("PROTOCOL_VIOLATION", reason, "protocol_violation"));
134
+ session.exited.then((exit) => {
135
+ if (settled)
136
+ return;
137
+ const detail = exit.error ?? `code ${String(exit.code)}${exit.signal ? `, signal ${exit.signal}` : ""}`;
138
+ finish({
139
+ kind: "failed",
140
+ code: "ISOLATE_FAILURE",
141
+ message: `the isolate exited unexpectedly (${detail})`,
142
+ });
143
+ });
144
+ session.onMessage((raw) => {
145
+ if (settled)
146
+ return;
147
+ const checked = validator.accept(raw);
148
+ if (!checked.ok)
149
+ return fail("PROTOCOL_VIOLATION", checked.error.message, "protocol_violation");
150
+ const message = checked.message;
151
+ if (message.type === "complete") {
152
+ const value = message.payload.value;
153
+ return finish({ kind: "completed", ...(value !== undefined ? { value } : {}) });
154
+ }
155
+ if (message.type === "fatal") {
156
+ return finish({
157
+ kind: "failed",
158
+ code: message.payload.code,
159
+ message: message.payload.message,
160
+ });
161
+ }
162
+ if (message.type === "ready") {
163
+ if (message.payload.engine !== this.id || message.payload.preludeHash !== PRELUDE_HASH)
164
+ return fail("PROTOCOL_VIOLATION", "the isolate runs an unexpected prelude", "protocol_violation");
165
+ send({
166
+ type: "start",
167
+ payload: {
168
+ source: job.source,
169
+ state: job.state,
170
+ limits: guestLimits(job.limits),
171
+ catalogSize: job.catalogSize,
172
+ },
173
+ });
174
+ }
175
+ Promise.resolve()
176
+ .then(() => channel.receive(message))
177
+ .then((reply) => {
178
+ if (reply)
179
+ send(reply);
180
+ })
181
+ .catch((error) => {
182
+ if (isForgeError(error))
183
+ return fail(error.code, error.message, "limit_exceeded");
184
+ fail("ISOLATE_FAILURE", error instanceof Error ? error.message : String(error), "limit_exceeded");
185
+ });
186
+ });
187
+ channel.bind(send);
188
+ });
189
+ }
190
+ async terminate(prepared, reason) {
191
+ const job = prepared;
192
+ const session = job.session;
193
+ if (!session)
194
+ return { confirmed: true, method: "interrupt", elapsedMs: 0 };
195
+ const report = await session.stop(reason);
196
+ if (!report.confirmed)
197
+ job.forceEnd?.();
198
+ return report;
199
+ }
200
+ async dispose(prepared) {
201
+ const session = prepared.session;
202
+ if (!session)
203
+ return;
204
+ // A normal run ends with the guest leaving by itself; anything still alive is stopped now.
205
+ const gone = await Promise.race([
206
+ session.exited.then(() => true),
207
+ new Promise((resolve) => setTimeout(() => resolve(false), 250).unref?.()),
208
+ ]);
209
+ if (!gone)
210
+ await session.stop("limit_exceeded");
211
+ }
212
+ /**
213
+ * Full self-test (`alisio forge status`): starts a real isolate, runs `return 1` and reports how
214
+ * long it took. Nothing here is on the hot path of a normal execution.
215
+ */
216
+ async probe() {
217
+ const host = this.#options.hosts.kind;
218
+ const started = performance.now();
219
+ try {
220
+ const { probeIsolate } = await import("./probe.js");
221
+ const module = await this.#module();
222
+ await probeIsolate(await this.#options.hosts.create(), module);
223
+ return { ok: true, host, bootMs: Math.round(performance.now() - started) };
224
+ }
225
+ catch (error) {
226
+ return { ok: false, host, reason: error instanceof Error ? error.message : String(error) };
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Chooses the isolate host (spec §6.3, D-20). Node always gets the worker; Bun (directly or as a
3
+ * compiled binary) gets the child process, because `worker.terminate()` does not reliably stop a
4
+ * WASM loop there. `forge.javascript.host` can force either one for diagnosis. The only runtime
5
+ * detection in Forja lives here and reads `process.versions.bun`, not a Bun global.
6
+ */
7
+ import type { IsolateHost } from "../../ports.ts";
8
+ import { type ProcessHostOptions } from "./process-host.ts";
9
+ import { type WorkerHostOptions } from "./worker-host.ts";
10
+ export type HostChoice = "auto" | "worker" | "process";
11
+ export declare function chooseHostKind(choice: HostChoice, bun?: boolean): "worker" | "process";
12
+ export interface IsolateHostFactoryOptions {
13
+ choice: HostChoice;
14
+ /** The verified, compiled module (the worker receives it; the child compiles its own copy). */
15
+ module: () => Promise<WebAssembly.Module>;
16
+ worker?: Omit<WorkerHostOptions, "module">;
17
+ process?: ProcessHostOptions;
18
+ bun?: boolean;
19
+ }
20
+ export declare class IsolateHostFactory {
21
+ #private;
22
+ constructor(options: IsolateHostFactoryOptions);
23
+ get kind(): "worker" | "process";
24
+ create(): Promise<IsolateHost>;
25
+ }
@@ -0,0 +1,21 @@
1
+ import { ProcessIsolateHost } from "./process-host.js";
2
+ import { WorkerIsolateHost } from "./worker-host.js";
3
+ export function chooseHostKind(choice, bun = !!process.versions.bun) {
4
+ if (choice !== "auto")
5
+ return choice;
6
+ return bun ? "process" : "worker";
7
+ }
8
+ export class IsolateHostFactory {
9
+ #options;
10
+ constructor(options) {
11
+ this.#options = options;
12
+ }
13
+ get kind() {
14
+ return chooseHostKind(this.#options.choice, this.#options.bun);
15
+ }
16
+ async create() {
17
+ if (this.kind === "process")
18
+ return new ProcessIsolateHost(this.#options.process);
19
+ return new WorkerIsolateHost({ ...this.#options.worker, module: await this.#options.module() });
20
+ }
21
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Length-prefixed JSON framing of the process host (spec §6.3, D10): a big-endian u32 byte count
3
+ * followed by that many bytes of UTF-8 JSON. Pure. A frame over the limit or a body that is not
4
+ * JSON breaks the stream: the caller treats it as a protocol violation and kills the child.
5
+ *
6
+ * Erasable TypeScript only (D-33): the isolate process loads this file under type stripping.
7
+ */
8
+ export declare function encodeFrame(message: unknown): Buffer;
9
+ export declare class FrameStreamError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ export declare class FrameDecoder {
13
+ #private;
14
+ constructor(maxFrameBytes: number);
15
+ /** Adds bytes and returns every message completed by them. Throws `FrameStreamError`. */
16
+ push(chunk: Uint8Array): unknown[];
17
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Length-prefixed JSON framing of the process host (spec §6.3, D10): a big-endian u32 byte count
3
+ * followed by that many bytes of UTF-8 JSON. Pure. A frame over the limit or a body that is not
4
+ * JSON breaks the stream: the caller treats it as a protocol violation and kills the child.
5
+ *
6
+ * Erasable TypeScript only (D-33): the isolate process loads this file under type stripping.
7
+ */
8
+ export function encodeFrame(message) {
9
+ const body = Buffer.from(JSON.stringify(message), "utf8");
10
+ const header = Buffer.allocUnsafe(4);
11
+ header.writeUInt32BE(body.length, 0);
12
+ return Buffer.concat([header, body]);
13
+ }
14
+ export class FrameStreamError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "FrameStreamError";
18
+ }
19
+ }
20
+ export class FrameDecoder {
21
+ #buffer = Buffer.alloc(0);
22
+ #maxFrameBytes;
23
+ constructor(maxFrameBytes) {
24
+ this.#maxFrameBytes = maxFrameBytes;
25
+ }
26
+ /** Adds bytes and returns every message completed by them. Throws `FrameStreamError`. */
27
+ push(chunk) {
28
+ this.#buffer =
29
+ this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]);
30
+ const messages = [];
31
+ while (this.#buffer.length >= 4) {
32
+ const size = this.#buffer.readUInt32BE(0);
33
+ // The size is checked before the body is buffered: a hostile length never allocates.
34
+ if (size > this.#maxFrameBytes)
35
+ throw new FrameStreamError(`frame of ${size} bytes exceeds the ${this.#maxFrameBytes} limit`);
36
+ if (this.#buffer.length < 4 + size)
37
+ break;
38
+ const body = this.#buffer.subarray(4, 4 + size).toString("utf8");
39
+ this.#buffer = this.#buffer.subarray(4 + size);
40
+ try {
41
+ messages.push(JSON.parse(body));
42
+ }
43
+ catch {
44
+ throw new FrameStreamError("frame is not valid JSON");
45
+ }
46
+ }
47
+ return messages;
48
+ }
49
+ }
@@ -0,0 +1,9 @@
1
+ export interface WorkerIsolateData {
2
+ forgeIsolate: 1;
3
+ module: WebAssembly.Module;
4
+ flag: SharedArrayBuffer;
5
+ }
6
+ /** Body of a worker thread started by `WorkerIsolateHost`. */
7
+ export declare function runWorkerIsolate(): Promise<void>;
8
+ /** Body of the child process started by `ProcessIsolateHost` (or by `alisio __forge-isolate`). */
9
+ export declare function runProcessIsolate(env?: Record<string, string | undefined>): Promise<void>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What runs inside an isolate (spec §6.3, D-20, D-21): the worker thread body and the child
3
+ * process body. Both build a `GuestTransport` over their channel and hand it to `runGuest`.
4
+ *
5
+ * The process variant refuses to run unless `ALISIO_FORGE_ISOLATE=1` is set (the host sets it
6
+ * with an otherwise empty environment), so the hidden `__forge-isolate` command cannot be
7
+ * mistaken for something a user runs. It talks only through stdin/stdout frames and exits when
8
+ * the execution reaches its terminal message.
9
+ *
10
+ * Erasable TypeScript only (D-33).
11
+ */
12
+ import { isMainThread, parentPort, workerData } from "node:worker_threads";
13
+ import { runGuest } from "../vm-runner.js";
14
+ import { loadQuickJsModule } from "../wasm.js";
15
+ import { encodeFrame, FrameDecoder } from "./framing.js";
16
+ /** Body of a worker thread started by `WorkerIsolateHost`. */
17
+ export async function runWorkerIsolate() {
18
+ const data = workerData;
19
+ if (isMainThread || !parentPort || data?.forgeIsolate !== 1)
20
+ return;
21
+ const port = parentPort;
22
+ await runGuest({
23
+ module: data.module,
24
+ flag: new Int32Array(data.flag),
25
+ transport: {
26
+ send: (message) => port.postMessage(message),
27
+ onMessage: (handler) => port.on("message", handler),
28
+ },
29
+ });
30
+ // The terminal message is posted; leaving the thread lets the parent observe a clean `exit`.
31
+ process.exit(0);
32
+ }
33
+ /** Body of the child process started by `ProcessIsolateHost` (or by `alisio __forge-isolate`). */
34
+ export async function runProcessIsolate(env = process.env) {
35
+ if (env.ALISIO_FORGE_ISOLATE !== "1") {
36
+ process.stderr.write("__forge-isolate is internal and is started by the Forja process host\n");
37
+ process.exit(2);
38
+ }
39
+ const module = await loadQuickJsModule();
40
+ const decoder = new FrameDecoder(8 * 1024 * 1024);
41
+ let handler = () => { };
42
+ process.stdin.on("data", (chunk) => {
43
+ try {
44
+ for (const message of decoder.push(chunk))
45
+ handler(message);
46
+ }
47
+ catch {
48
+ process.exit(3);
49
+ }
50
+ });
51
+ process.stdin.on("end", () => process.exit(0));
52
+ await runGuest({
53
+ module,
54
+ transport: {
55
+ send: (message) => process.stdout.write(encodeFrame(message)),
56
+ onMessage: (next) => {
57
+ handler = next;
58
+ },
59
+ },
60
+ });
61
+ // Exit once the last frame is flushed.
62
+ process.stdout.write("", () => process.exit(0));
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ /** Entry file of the isolate child process when run from a file (spec D-33: erasable TypeScript only). */
2
+ import { runProcessIsolate } from "./isolate-main.js";
3
+ await runProcessIsolate();