@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.
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 +28 -2
  12. package/dist/application.js +189 -4
  13. package/dist/changelog/data.js +26 -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
@@ -58,3 +58,51 @@ export declare class AnalysisJanitor {
58
58
  private pruneEmptyParents;
59
59
  private dropDatasets;
60
60
  }
61
+ export interface ScratchSweepReport {
62
+ /** Why nothing ran: swept less than 24 h ago, or another process is sweeping. */
63
+ skipped?: "recent" | "locked";
64
+ sessionsDeleted: number;
65
+ bytesFreed: number;
66
+ errors: number;
67
+ }
68
+ /**
69
+ * Retention of the volatile Forja session directories (spec §9.4): every `<root>/<workspace>/
70
+ * <rootSessionId>` folder goes when its root session no longer exists or its last activity
71
+ * (newest file in it) is older than `retentionDays`; `0` disables the deletion.
72
+ *
73
+ * Before deleting, the folder must be a real directory (never a symlink) under a real directory
74
+ * under a verified root, its `.alisio-session` marker must name the same session as the folder,
75
+ * and its real path must stay inside the real root. `rm` never follows a symlink it meets inside.
76
+ * Same schedule as the analysis janitor: at most once every 24 h (mark under `<state>/forge/`),
77
+ * with a lock between processes; deletions are best effort and independent.
78
+ */
79
+ export declare class ScratchJanitor {
80
+ private options;
81
+ private timer?;
82
+ private stopped;
83
+ constructor(options: {
84
+ /** State root; holds the sweep mark and lock under `forge/`. */
85
+ stateRoot: string;
86
+ /** The scratch roots to sweep: `<tmp>/alisio-<uid>` and `<state>/scratch`. */
87
+ roots: () => string[];
88
+ /** Whether the root session still exists in the store. */
89
+ sessionExists: (rootSessionId: string) => boolean;
90
+ /** Read at every sweep, so a changed setting applies without a restart. */
91
+ retentionDays: () => number;
92
+ now?: () => number;
93
+ initialDelayMs?: number;
94
+ });
95
+ private now;
96
+ private get dir();
97
+ lastSweep(): Promise<number | undefined>;
98
+ /** Launches the sweep in the background; never delays the caller and never keeps Node alive. */
99
+ start(): void;
100
+ stop(): void;
101
+ private arm;
102
+ sweep(options?: {
103
+ force?: boolean;
104
+ }): Promise<ScratchSweepReport>;
105
+ private sweepRoot;
106
+ /** Whether `folder` is a verified session folder that is due; adds its size to the report. */
107
+ private expired;
108
+ }
@@ -13,8 +13,9 @@
13
13
  *
14
14
  * Deletions are best effort and independent: one that fails is counted and the sweep goes on.
15
15
  */
16
- import { mkdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
17
- import { dirname, isAbsolute, join, relative } from "node:path";
16
+ import { lstat, mkdir, readdir, readFile, realpath, rm, rmdir, stat, writeFile, } from "node:fs/promises";
17
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
18
+ import { readSessionMarker } from "../forge/session-dir.js";
18
19
  const DAY = 86_400_000;
19
20
  export const SWEEP_INTERVAL_MS = DAY;
20
21
  /** A lock older than this belongs to a dead process. */
@@ -57,6 +58,27 @@ async function exists(path) {
57
58
  return false;
58
59
  }
59
60
  }
61
+ /**
62
+ * Exclusive lock file `<dir>/<name>` between processes sharing the state folder; undefined when a
63
+ * live holder has it. A lock older than `STALE_LOCK_MS` belongs to a dead process and is replaced.
64
+ */
65
+ async function acquireLock(dir, name) {
66
+ await mkdir(dir, { recursive: true });
67
+ const file = join(dir, name);
68
+ for (let attempt = 0; attempt < 2; attempt++)
69
+ try {
70
+ await writeFile(file, `${process.pid}\n`, { flag: "wx" });
71
+ return () => rm(file, { force: true });
72
+ }
73
+ catch {
74
+ const info = await stat(file).catch(() => undefined);
75
+ // Real time on purpose: the lock belongs to a live process, not to the injected clock.
76
+ if (info && Date.now() - info.mtimeMs < STALE_LOCK_MS)
77
+ return undefined;
78
+ await rm(file, { force: true });
79
+ }
80
+ return undefined;
81
+ }
60
82
  export class AnalysisJanitor {
61
83
  options;
62
84
  timer;
@@ -125,22 +147,8 @@ export class AnalysisJanitor {
125
147
  return report;
126
148
  }
127
149
  /** Exclusive lock between processes sharing the state folder; undefined when someone holds it. */
128
- async lock() {
129
- await mkdir(this.dir, { recursive: true });
130
- const file = join(this.dir, ".sweep.lock");
131
- for (let attempt = 0; attempt < 2; attempt++)
132
- try {
133
- await writeFile(file, `${process.pid}\n`, { flag: "wx" });
134
- return () => rm(file, { force: true });
135
- }
136
- catch {
137
- const info = await stat(file).catch(() => undefined);
138
- // Real time on purpose: the lock belongs to a live process, not to the injected clock.
139
- if (info && Date.now() - info.mtimeMs < STALE_LOCK_MS)
140
- return undefined;
141
- await rm(file, { force: true });
142
- }
143
- return undefined;
150
+ lock() {
151
+ return acquireLock(this.dir, ".sweep.lock");
144
152
  }
145
153
  async expireArtifacts(policy, now, report) {
146
154
  if (policy.artifactsDays <= 0)
@@ -262,3 +270,147 @@ export class AnalysisJanitor {
262
270
  }
263
271
  }
264
272
  }
273
+ /** Newest modification time and total size under `dir`, without following symlinks. */
274
+ async function treeFacts(dir) {
275
+ const info = await lstat(dir);
276
+ let newest = info.mtimeMs;
277
+ let bytes = 0;
278
+ if (!info.isDirectory())
279
+ return { newest, bytes: info.size };
280
+ for (const entry of await readdir(dir)) {
281
+ const facts = await treeFacts(join(dir, entry)).catch(() => undefined);
282
+ if (!facts)
283
+ continue;
284
+ newest = Math.max(newest, facts.newest);
285
+ bytes += facts.bytes;
286
+ }
287
+ return { newest, bytes };
288
+ }
289
+ /** A directory that is really one (not a symlink to one). */
290
+ async function realDirectory(path) {
291
+ const info = await lstat(path).catch(() => undefined);
292
+ return !!info && info.isDirectory() && !info.isSymbolicLink();
293
+ }
294
+ /**
295
+ * Retention of the volatile Forja session directories (spec §9.4): every `<root>/<workspace>/
296
+ * <rootSessionId>` folder goes when its root session no longer exists or its last activity
297
+ * (newest file in it) is older than `retentionDays`; `0` disables the deletion.
298
+ *
299
+ * Before deleting, the folder must be a real directory (never a symlink) under a real directory
300
+ * under a verified root, its `.alisio-session` marker must name the same session as the folder,
301
+ * and its real path must stay inside the real root. `rm` never follows a symlink it meets inside.
302
+ * Same schedule as the analysis janitor: at most once every 24 h (mark under `<state>/forge/`),
303
+ * with a lock between processes; deletions are best effort and independent.
304
+ */
305
+ export class ScratchJanitor {
306
+ options;
307
+ timer;
308
+ stopped = false;
309
+ constructor(options) {
310
+ this.options = options;
311
+ }
312
+ now() {
313
+ return (this.options.now ?? Date.now)();
314
+ }
315
+ get dir() {
316
+ return join(this.options.stateRoot, "forge");
317
+ }
318
+ async lastSweep() {
319
+ try {
320
+ const value = Number((await readFile(join(this.dir, ".last-scratch-sweep"), "utf8")).trim());
321
+ return Number.isFinite(value) ? value : undefined;
322
+ }
323
+ catch {
324
+ return undefined;
325
+ }
326
+ }
327
+ /** Launches the sweep in the background; never delays the caller and never keeps Node alive. */
328
+ start() {
329
+ this.stopped = false;
330
+ this.arm(this.options.initialDelayMs ?? 30_000);
331
+ }
332
+ stop() {
333
+ this.stopped = true;
334
+ if (this.timer)
335
+ clearTimeout(this.timer);
336
+ this.timer = undefined;
337
+ }
338
+ arm(delay) {
339
+ if (this.stopped)
340
+ return;
341
+ this.timer = setTimeout(() => {
342
+ void this.sweep()
343
+ .catch(() => undefined)
344
+ .finally(() => this.arm(SWEEP_INTERVAL_MS));
345
+ }, delay);
346
+ this.timer.unref();
347
+ }
348
+ async sweep(options = {}) {
349
+ const report = { sessionsDeleted: 0, bytesFreed: 0, errors: 0 };
350
+ const now = this.now();
351
+ const last = await this.lastSweep();
352
+ if (!options.force && last !== undefined && now - last < SWEEP_INTERVAL_MS)
353
+ return { ...report, skipped: "recent" };
354
+ const release = await acquireLock(this.dir, ".scratch-sweep.lock");
355
+ if (!release)
356
+ return { ...report, skipped: "locked" };
357
+ try {
358
+ await writeFile(join(this.dir, ".last-scratch-sweep"), `${now}\n`);
359
+ const days = this.options.retentionDays();
360
+ if (days <= 0)
361
+ return report;
362
+ for (const root of this.options.roots())
363
+ await this.sweepRoot(root, days, now, report);
364
+ }
365
+ finally {
366
+ await release();
367
+ }
368
+ return report;
369
+ }
370
+ async sweepRoot(root, days, now, report) {
371
+ if (!(await realDirectory(root)))
372
+ return;
373
+ let realRoot;
374
+ try {
375
+ realRoot = await realpath(root);
376
+ }
377
+ catch {
378
+ return;
379
+ }
380
+ for (const workspace of await readdir(root).catch(() => [])) {
381
+ const workspaceDir = join(root, workspace);
382
+ if (!(await realDirectory(workspaceDir)))
383
+ continue;
384
+ for (const id of await readdir(workspaceDir).catch(() => [])) {
385
+ const folder = join(workspaceDir, id);
386
+ try {
387
+ if (await this.expired(folder, id, realRoot, days, now, report)) {
388
+ await rm(folder, { recursive: true, force: true });
389
+ report.sessionsDeleted++;
390
+ }
391
+ }
392
+ catch {
393
+ report.errors++;
394
+ }
395
+ }
396
+ // Drop the workspace folder when its last session went (rmdir only removes an empty one).
397
+ await rmdir(workspaceDir).catch(() => undefined);
398
+ }
399
+ }
400
+ /** Whether `folder` is a verified session folder that is due; adds its size to the report. */
401
+ async expired(folder, id, realRoot, days, now, report) {
402
+ if (!(await realDirectory(folder)))
403
+ return false;
404
+ const marker = await readSessionMarker(folder);
405
+ if (!marker || marker.sessionId !== basename(folder) || marker.sessionId !== id)
406
+ return false;
407
+ const real = await realpath(folder);
408
+ if (!real.startsWith(realRoot + sep))
409
+ return false;
410
+ const facts = await treeFacts(folder);
411
+ const due = !this.options.sessionExists(id) || now - facts.newest > days * DAY;
412
+ if (due)
413
+ report.bytesFreed += facts.bytes;
414
+ return due;
415
+ }
416
+ }
@@ -1,4 +1,6 @@
1
1
  import type { SqlDatabase } from "@alisio/sdk";
2
+ /** Where an execution ran (`analysis_executions.runtime`). */
3
+ export type AnalysisBackendId = "managed" | "oci" | "wasm";
2
4
  export type ExecutionStatus = "running" | "completed" | "failed" | "cancelled" | "timed_out";
3
5
  export interface JobPaths {
4
6
  id: string;
@@ -34,7 +36,7 @@ export interface ExecutionDetails {
34
36
  sessionId: string;
35
37
  rootSessionId: string;
36
38
  workspace: string;
37
- runtime: "managed" | "oci";
39
+ runtime: AnalysisBackendId;
38
40
  status: ExecutionStatus;
39
41
  scriptSha256: string;
40
42
  exitCode?: number;
@@ -52,6 +54,8 @@ export declare class AnalysisJobs {
52
54
  now?: () => number;
53
55
  });
54
56
  private now;
57
+ /** The store's database (Forja's journal and state live in it too). */
58
+ get database(): SqlDatabase;
55
59
  /** Creates the job folder (script, runtime helpers, empty input/work/staging/logs) and its row. */
56
60
  create(input: {
57
61
  sessionId: string;
@@ -60,7 +64,7 @@ export declare class AnalysisJobs {
60
64
  runId?: string;
61
65
  callId?: string;
62
66
  code: string;
63
- runtime: "managed" | "oci";
67
+ runtime: AnalysisBackendId;
64
68
  /** The execution this one repeats (a rerun); recorded in the row and in `job.json`. */
65
69
  rerunOf?: string;
66
70
  }): Promise<JobPaths & {
@@ -74,6 +78,11 @@ export declare class AnalysisJobs {
74
78
  addInput(job: JobPaths, source: string, preferred?: string): Promise<JobInput>;
75
79
  /** `input/inputs.json`: what each input is (the script reads it with `alisio_runtime.datasets.inputs()`). */
76
80
  writeInputsManifest(job: JobPaths, entries: unknown[]): Promise<void>;
81
+ /**
82
+ * `work/.forge/state.json`: the Forja state snapshot a script reads with `forge.state.get`. A
83
+ * copy, written fresh for every run; changes travel back as protocol messages, never through it.
84
+ */
85
+ writeForgeState(job: JobPaths, values: Record<string, unknown>): Promise<void>;
77
86
  /** Adds the inputs to `job.json` (atomically), once they are copied into `input/`. */
78
87
  recordInputs(job: JobPaths, recorded: RecordedInputs): Promise<void>;
79
88
  /** The inputs a finished job recorded; `undefined` when `job.json` is missing or has none. */
@@ -21,6 +21,10 @@ export class AnalysisJobs {
21
21
  now() {
22
22
  return (this.options.now ?? Date.now)();
23
23
  }
24
+ /** The store's database (Forja's journal and state live in it too). */
25
+ get database() {
26
+ return this.options.db;
27
+ }
24
28
  /** Creates the job folder (script, runtime helpers, empty input/work/staging/logs) and its row. */
25
29
  async create(input) {
26
30
  const id = newId("exec", this.now());
@@ -84,6 +88,15 @@ export class AnalysisJobs {
84
88
  async writeInputsManifest(job, entries) {
85
89
  await writeFile(join(job.input, "inputs.json"), `${JSON.stringify(entries, null, 2)}\n`);
86
90
  }
91
+ /**
92
+ * `work/.forge/state.json`: the Forja state snapshot a script reads with `forge.state.get`. A
93
+ * copy, written fresh for every run; changes travel back as protocol messages, never through it.
94
+ */
95
+ async writeForgeState(job, values) {
96
+ const dir = join(job.work, ".forge");
97
+ await mkdir(dir, { recursive: true });
98
+ await writeFile(join(dir, "state.json"), JSON.stringify(values));
99
+ }
87
100
  /** Adds the inputs to `job.json` (atomically), once they are copied into `input/`. */
88
101
  async recordInputs(job, recorded) {
89
102
  const file = join(job.root, "job.json");
@@ -13,6 +13,8 @@ export interface OciMounts {
13
13
  input: string;
14
14
  work: string;
15
15
  staging: string;
16
+ /** The Forja scratchpad, mounted read-only at `/job/scratch` (Forja spec §9.4). Optional. */
17
+ scratch?: string;
16
18
  }
17
19
  /** Paths inside the container (spec §8.3). */
18
20
  export declare const CONTAINER: {
@@ -20,6 +22,7 @@ export declare const CONTAINER: {
20
22
  readonly input: "/job/input";
21
23
  readonly work: "/job/work";
22
24
  readonly out: "/job/out";
25
+ readonly scratch: "/job/scratch";
23
26
  };
24
27
  export declare const PIDS_LIMIT = 256;
25
28
  export declare const TMPFS_SIZE = "256m";
@@ -94,7 +97,11 @@ export declare class OciRuntime {
94
97
  */
95
98
  status(signal?: AbortSignal): Promise<OciStatus>;
96
99
  /** The reason a run cannot start (`undefined` = ready), checked before any job is created. */
97
- unavailable(signal?: AbortSignal): Promise<string | undefined>;
100
+ unavailable(signal?: AbortSignal,
101
+ /** `auto` chooses the runtime itself, so the messages must not talk about `analysis.runtime: "oci"`. */
102
+ options?: {
103
+ auto?: boolean;
104
+ }): Promise<string | undefined>;
98
105
  /**
99
106
  * Runs the job's script in a container. Cancelling or timing out `kills` the container by name
100
107
  * (killing the client process does not always stop it) and no `alisio-*` container is left.
@@ -105,12 +112,14 @@ export declare class OciRuntime {
105
112
  signal: AbortSignal;
106
113
  timeoutMs: number;
107
114
  maxBytes: number;
108
- onData?: (chunk: string) => void;
115
+ onData?: (chunk: string, stream: "stdout" | "stderr") => void;
109
116
  logFiles: {
110
117
  stdout: string;
111
118
  stderr: string;
112
119
  };
113
120
  maxLogBytes: number;
121
+ /** Extra variables for the script (Forja: the frame nonce). */
122
+ env?: Record<string, string>;
114
123
  }): Promise<ProcessResult & {
115
124
  containerName: string;
116
125
  }>;
@@ -19,6 +19,7 @@ export const CONTAINER = {
19
19
  input: "/job/input",
20
20
  work: "/job/work",
21
21
  out: "/job/out",
22
+ scratch: "/job/scratch",
22
23
  };
23
24
  export const PIDS_LIMIT = 256;
24
25
  export const TMPFS_SIZE = "256m";
@@ -69,7 +70,10 @@ export function ociRunArgs(input) {
69
70
  else if (!host.rootless && host.uid !== 0)
70
71
  args.push("--user", `${host.uid}:${host.gid}`);
71
72
  }
72
- args.push("-v", mount(input.mounts.script, CONTAINER.script, "ro"), "-v", mount(input.mounts.input, CONTAINER.input, "ro"), "-v", mount(input.mounts.work, CONTAINER.work, "rw"), "-v", mount(input.mounts.staging, CONTAINER.out, "rw"), "--tmpfs", `/tmp:size=${TMPFS_SIZE}`);
73
+ args.push("-v", mount(input.mounts.script, CONTAINER.script, "ro"), "-v", mount(input.mounts.input, CONTAINER.input, "ro"), "-v", mount(input.mounts.work, CONTAINER.work, "rw"), "-v", mount(input.mounts.staging, CONTAINER.out, "rw"));
74
+ if (input.mounts.scratch)
75
+ args.push("-v", mount(input.mounts.scratch, CONTAINER.scratch, "ro"));
76
+ args.push("--tmpfs", `/tmp:size=${TMPFS_SIZE}`);
73
77
  const env = {
74
78
  ALISIO_INPUT_DIR: CONTAINER.input,
75
79
  ALISIO_OUTPUT_DIR: CONTAINER.out,
@@ -83,6 +87,7 @@ export function ociRunArgs(input) {
83
87
  PYTHONUNBUFFERED: "1",
84
88
  PYTHONNOUSERSITE: "1",
85
89
  PYTHONIOENCODING: "utf-8",
90
+ ...(input.mounts.scratch ? { ALISIO_SCRATCH_DIR: CONTAINER.scratch } : {}),
86
91
  ...input.env,
87
92
  };
88
93
  for (const [key, value] of Object.entries(env))
@@ -191,16 +196,26 @@ export class OciRuntime {
191
196
  }
192
197
  }
193
198
  /** The reason a run cannot start (`undefined` = ready), checked before any job is created. */
194
- async unavailable(signal) {
199
+ async unavailable(signal,
200
+ /** `auto` chooses the runtime itself, so the messages must not talk about `analysis.runtime: "oci"`. */
201
+ options = {}) {
195
202
  const { image } = this.settings;
203
+ if (!image && options.auto)
204
+ return "no container image is set (analysis.oci.image); `alisio analysis setup --oci --image <name>` prints the value";
196
205
  if (!image)
197
206
  return ('analysis.runtime is "oci" but analysis.oci.image is not set. Put a digest-pinned image ' +
198
207
  "(name@sha256:…) in your user configuration; `alisio analysis setup --oci --image <name>` prints the value.");
199
208
  if (!OCI_IMAGE_PATTERN.test(image))
200
209
  return "analysis.oci.image must be pinned by digest";
201
210
  const status = await this.status(signal);
202
- if (!status.ok)
203
- return `${status.reason}${status.alternative ? `; ${status.alternative} is installed (set analysis.oci.engine to "${status.alternative}")` : ""}. The container runtime is optional: remove analysis.runtime "oci" to use managed Python.`;
211
+ if (!status.ok) {
212
+ const alternative = status.alternative
213
+ ? `; ${status.alternative} is installed (set analysis.oci.engine to "${status.alternative}")`
214
+ : "";
215
+ return options.auto
216
+ ? `${status.reason}${alternative}`
217
+ : `${status.reason}${alternative}. The container runtime is optional: remove analysis.runtime "oci" to use managed Python.`;
218
+ }
204
219
  return undefined;
205
220
  }
206
221
  /**
@@ -221,6 +236,7 @@ export class OciRuntime {
221
236
  executionId: input.executionId,
222
237
  mounts: input.mounts,
223
238
  host: { ...host, rootless: status.ok ? status.rootless : false },
239
+ ...(input.env ? { env: input.env } : {}),
224
240
  });
225
241
  const name = containerName(input.executionId);
226
242
  const stop = AbortSignal.any([input.signal, AbortSignal.timeout(input.timeoutMs)]);