@alisio/core 0.1.0-alpha.20 → 0.1.0-alpha.22

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 (104) hide show
  1. package/dist/agents/active.d.ts +19 -0
  2. package/dist/agents/active.js +41 -0
  3. package/dist/agents/capabilities.d.ts +26 -0
  4. package/dist/agents/capabilities.js +120 -0
  5. package/dist/agents/create-agent-skill.d.ts +11 -0
  6. package/dist/agents/create-agent-skill.js +60 -0
  7. package/dist/agents/definitions.d.ts +88 -0
  8. package/dist/agents/definitions.js +404 -0
  9. package/dist/agents/draft.d.ts +41 -0
  10. package/dist/agents/draft.js +133 -0
  11. package/dist/agents/service.d.ts +57 -0
  12. package/dist/agents/service.js +86 -0
  13. package/dist/agents/templates.d.ts +8 -0
  14. package/dist/agents/templates.js +102 -0
  15. package/dist/analysis/capabilities.d.ts +48 -0
  16. package/dist/analysis/capabilities.js +90 -0
  17. package/dist/analysis/data/csv.d.ts +46 -0
  18. package/dist/analysis/data/csv.js +240 -0
  19. package/dist/analysis/data/datasets.d.ts +132 -0
  20. package/dist/analysis/data/datasets.js +441 -0
  21. package/dist/analysis/data/describe.d.ts +27 -0
  22. package/dist/analysis/data/describe.js +103 -0
  23. package/dist/analysis/data/engine-client.d.ts +58 -0
  24. package/dist/analysis/data/engine-client.js +304 -0
  25. package/dist/analysis/data/engine-entry.d.ts +1 -0
  26. package/dist/analysis/data/engine-entry.js +59 -0
  27. package/dist/analysis/data/engine-source.d.ts +4 -0
  28. package/dist/analysis/data/engine-source.js +6 -0
  29. package/dist/analysis/data/engine-types.d.ts +86 -0
  30. package/dist/analysis/data/engine-types.js +7 -0
  31. package/dist/analysis/data/infer.d.ts +42 -0
  32. package/dist/analysis/data/infer.js +118 -0
  33. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  34. package/dist/analysis/data/ingest-worker.js +365 -0
  35. package/dist/analysis/data/json.d.ts +19 -0
  36. package/dist/analysis/data/json.js +44 -0
  37. package/dist/analysis/data/query-worker.d.ts +12 -0
  38. package/dist/analysis/data/query-worker.js +75 -0
  39. package/dist/analysis/data/rows.d.ts +39 -0
  40. package/dist/analysis/data/rows.js +120 -0
  41. package/dist/analysis/data/sql-guard.d.ts +20 -0
  42. package/dist/analysis/data/sql-guard.js +129 -0
  43. package/dist/analysis/data/stats.d.ts +18 -0
  44. package/dist/analysis/data/stats.js +77 -0
  45. package/dist/analysis/install-hints.d.ts +33 -0
  46. package/dist/analysis/install-hints.js +144 -0
  47. package/dist/analysis/janitor.d.ts +60 -0
  48. package/dist/analysis/janitor.js +264 -0
  49. package/dist/analysis/jobs.d.ts +94 -0
  50. package/dist/analysis/jobs.js +165 -0
  51. package/dist/analysis/oci.d.ts +129 -0
  52. package/dist/analysis/oci.js +330 -0
  53. package/dist/analysis/python/sources.d.ts +2 -0
  54. package/dist/analysis/python/sources.js +10 -0
  55. package/dist/analysis/requirements.d.ts +32 -0
  56. package/dist/analysis/requirements.js +73 -0
  57. package/dist/analysis/rerun.d.ts +40 -0
  58. package/dist/analysis/rerun.js +136 -0
  59. package/dist/analysis/runtime-manager.d.ts +146 -0
  60. package/dist/analysis/runtime-manager.js +472 -0
  61. package/dist/analysis/status.d.ts +21 -0
  62. package/dist/analysis/status.js +52 -0
  63. package/dist/application.d.ts +48 -0
  64. package/dist/application.js +228 -0
  65. package/dist/artifacts/kinds.d.ts +35 -0
  66. package/dist/artifacts/kinds.js +177 -0
  67. package/dist/artifacts/publisher.d.ts +7 -0
  68. package/dist/artifacts/publisher.js +29 -0
  69. package/dist/artifacts/store.d.ts +119 -0
  70. package/dist/artifacts/store.js +581 -0
  71. package/dist/commands/builtins.js +13 -0
  72. package/dist/commands/catalog.js +13 -1
  73. package/dist/config.d.ts +102 -0
  74. package/dist/config.js +167 -6
  75. package/dist/core/contracts.d.ts +69 -1
  76. package/dist/core/runner.d.ts +34 -4
  77. package/dist/core/runner.js +388 -130
  78. package/dist/index.d.ts +30 -5
  79. package/dist/index.js +29 -4
  80. package/dist/mcp/rich.js +4 -0
  81. package/dist/plugins/host.js +14 -6
  82. package/dist/runtime/blobs.d.ts +22 -0
  83. package/dist/runtime/blobs.js +84 -1
  84. package/dist/runtime/ids.d.ts +8 -0
  85. package/dist/runtime/ids.js +32 -0
  86. package/dist/runtime/paths.d.ts +5 -0
  87. package/dist/runtime/paths.js +6 -0
  88. package/dist/runtime/process.d.ts +12 -0
  89. package/dist/runtime/process.js +64 -4
  90. package/dist/runtime/sqlite.d.ts +2 -0
  91. package/dist/runtime/sqlite.js +18 -0
  92. package/dist/runtime/store.d.ts +5 -0
  93. package/dist/runtime/store.js +75 -0
  94. package/dist/runtime/zip.d.ts +24 -0
  95. package/dist/runtime/zip.js +126 -0
  96. package/dist/sessions/children.js +4 -0
  97. package/dist/tools/analysis.d.ts +37 -0
  98. package/dist/tools/analysis.js +572 -0
  99. package/dist/tools/artifacts.d.ts +14 -0
  100. package/dist/tools/artifacts.js +143 -0
  101. package/dist/tools/data.d.ts +7 -0
  102. package/dist/tools/data.js +160 -0
  103. package/dist/tools/execute.js +6 -2
  104. package/package.json +2 -2
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Install instructions for Python ≥ 3.10 on the machine Alisio runs on. A pure function of the
3
+ * detected host (injected in tests); the host detection reads `/etc/os-release` and looks for
4
+ * `uv`, `winget` and `brew` on PATH without launching any process. Commands are shown, never run.
5
+ */
6
+ import { readFile } from "node:fs/promises";
7
+ import { which } from "../runtime/fs.js";
8
+ const UV = "uv python install 3.12";
9
+ const PYTHON_ORG = "Download the installer from https://www.python.org/downloads/";
10
+ /** Parses `KEY=value` / `KEY="value"` lines of an os-release file. */
11
+ export function parseOsRelease(text) {
12
+ const out = {};
13
+ for (const line of text.split(/\r?\n/)) {
14
+ const match = /^([A-Z_]+)=(.*)$/.exec(line.trim());
15
+ if (match?.[1])
16
+ out[match[1]] = (match[2] ?? "").replace(/^["']|["']$/g, "");
17
+ }
18
+ return out;
19
+ }
20
+ function systemLabel(host) {
21
+ const os = host.platform === "win32"
22
+ ? "Windows"
23
+ : host.platform === "darwin"
24
+ ? "macOS"
25
+ : host.platform === "linux"
26
+ ? "Linux"
27
+ : host.platform;
28
+ const distro = host.osRelease?.PRETTY_NAME;
29
+ return [os, distro, host.arch].filter(Boolean).join(" · ");
30
+ }
31
+ export function pythonInstallHints(host) {
32
+ const system = systemLabel(host);
33
+ const heading = host.found
34
+ ? host.found.version
35
+ ? `Found Python ${host.found.version} at ${host.found.path}; Alisio needs 3.10 or newer.`
36
+ : `The Python at ${host.found.path} is not usable${host.found.reason ? ` (${host.found.reason})` : ""}.`
37
+ : undefined;
38
+ const notes = [];
39
+ if (host.found)
40
+ notes.push("If another Python 3.10+ is installed, start Alisio with --python <path>.");
41
+ let primary;
42
+ let alternatives = [];
43
+ if (host.platform === "win32") {
44
+ primary = "winget install Python.Python.3.12";
45
+ alternatives = [`${PYTHON_ORG} (check "Add python.exe to PATH")`];
46
+ if (host.hasUv)
47
+ alternatives.push(UV);
48
+ notes.push("The Microsoft Store alias (WindowsApps\\python.exe) does not count as an installed Python.", "Open a new terminal after installing so the updated PATH is visible.");
49
+ }
50
+ else if (host.platform === "darwin") {
51
+ if (host.hasBrew) {
52
+ primary = "brew install python@3.12";
53
+ alternatives = [
54
+ "Download the macOS installer from https://www.python.org/downloads/macos/",
55
+ UV,
56
+ ];
57
+ }
58
+ else {
59
+ primary = "Download the macOS installer from https://www.python.org/downloads/macos/";
60
+ alternatives = [
61
+ UV,
62
+ "xcode-select --install (Command Line Tools Python; it may be older than 3.10)",
63
+ ];
64
+ }
65
+ }
66
+ else if (host.platform === "linux") {
67
+ const ids = `${host.osRelease?.ID ?? ""} ${host.osRelease?.ID_LIKE ?? ""}`.toLowerCase();
68
+ const has = (id) => ids.split(/\s+/).includes(id);
69
+ if (has("debian") || has("ubuntu")) {
70
+ primary = "sudo apt install python3";
71
+ alternatives = [`If your distribution ships a version older than 3.10: ${UV}`];
72
+ }
73
+ else if (has("fedora") || has("rhel") || has("centos")) {
74
+ primary = "sudo dnf install python3";
75
+ alternatives = [UV];
76
+ }
77
+ else if (has("arch")) {
78
+ primary = "sudo pacman -S python";
79
+ }
80
+ else if (ids.includes("suse")) {
81
+ primary = "sudo zypper install python3";
82
+ }
83
+ else if (has("alpine")) {
84
+ primary = "sudo apk add python3";
85
+ }
86
+ else {
87
+ primary = "Install python3 with your system package manager";
88
+ alternatives = [UV];
89
+ }
90
+ }
91
+ else {
92
+ primary = "Install python3 with your system package manager";
93
+ alternatives = [UV];
94
+ }
95
+ return { system, ...(heading ? { heading } : {}), primary, alternatives, notes };
96
+ }
97
+ /** One paragraph for the model and terminals (English, like every tool message). */
98
+ export function installHintsText(hints) {
99
+ const lines = [
100
+ ...(hints.heading ? [hints.heading] : []),
101
+ `Python 3.10+ was not found on this machine (${hints.system}). To install it: ${hints.primary}`,
102
+ ...hints.alternatives.map((alternative) => `Alternatively: ${alternative}`),
103
+ ...hints.notes,
104
+ "After installing, ask again; no Alisio restart is needed. To use a specific interpreter, start Alisio with --python <path>.",
105
+ ];
106
+ return lines.join("\n");
107
+ }
108
+ /** The same guidance as Markdown (web and TUI render it as a `markdown` block). */
109
+ export function installHintsMarkdown(hints) {
110
+ return [
111
+ "**Python 3.10+ is not available**",
112
+ "",
113
+ ...(hints.heading ? [hints.heading, ""] : []),
114
+ `Detected system: ${hints.system}`,
115
+ "",
116
+ "```sh",
117
+ hints.primary,
118
+ "```",
119
+ ...(hints.alternatives.length
120
+ ? ["", "Alternatives:", ...hints.alternatives.map((a) => `- ${a}`)]
121
+ : []),
122
+ ...(hints.notes.length ? ["", ...hints.notes.map((n) => `- ${n}`)] : []),
123
+ "",
124
+ "After installing, ask again; no restart is needed. Start Alisio with `--python <path>` to pin an interpreter.",
125
+ ].join("\n");
126
+ }
127
+ /** Detects the host for `pythonInstallHints` (reads files and PATH only; launches nothing). */
128
+ export async function detectHintHost(found) {
129
+ let osRelease;
130
+ if (process.platform === "linux")
131
+ osRelease = await readFile("/etc/os-release", "utf8")
132
+ .then(parseOsRelease)
133
+ .catch(() => undefined);
134
+ const [uv, winget, brew] = await Promise.all([which("uv"), which("winget"), which("brew")]);
135
+ return {
136
+ platform: process.platform,
137
+ arch: process.arch,
138
+ ...(osRelease ? { osRelease } : {}),
139
+ hasUv: !!uv,
140
+ hasWinget: !!winget,
141
+ hasBrew: !!brew,
142
+ ...(found ? { found } : {}),
143
+ };
144
+ }
@@ -0,0 +1,60 @@
1
+ import type { SqlDatabase } from "@alisio/sdk";
2
+ import type { ArtifactStore } from "../artifacts/store.ts";
3
+ import type { BlobStore } from "../runtime/blobs.ts";
4
+ import type { DatasetService } from "./data/datasets.ts";
5
+ export declare const SWEEP_INTERVAL_MS = 86400000;
6
+ export interface RetentionPolicy {
7
+ jobsDays: number;
8
+ intermediateDays: number;
9
+ artifactsDays: number;
10
+ }
11
+ export interface SweepReport {
12
+ /** Why nothing ran: swept less than 24 h ago, or another process is sweeping. */
13
+ skipped?: "recent" | "locked";
14
+ workDeleted: number;
15
+ jobsTrimmed: number;
16
+ jobsDeleted: number;
17
+ artifactsExpired: number;
18
+ datasetsDeleted: number;
19
+ uploadsDeleted: number;
20
+ bytesFreed: number;
21
+ errors: number;
22
+ }
23
+ export declare class AnalysisJanitor {
24
+ private options;
25
+ private timer?;
26
+ private stopped;
27
+ constructor(options: {
28
+ /** State root (`stateHome()` or the folder of `--db`). */
29
+ root: string;
30
+ db: SqlDatabase;
31
+ artifacts: ArtifactStore;
32
+ datasets?: DatasetService;
33
+ blobs?: BlobStore;
34
+ /** Read at every sweep, so a changed setting applies without a restart. */
35
+ retention: () => RetentionPolicy;
36
+ /** The clock (injected in tests). */
37
+ now?: () => number;
38
+ /** Delay before the first background sweep after `start()` (default 30 s). */
39
+ initialDelayMs?: number;
40
+ });
41
+ private now;
42
+ private get dir();
43
+ /** When the last sweep started (epoch ms), if there was one. */
44
+ lastSweep(): Promise<number | undefined>;
45
+ /** Launches the sweep in the background; never delays the caller and never keeps Node alive. */
46
+ start(): void;
47
+ stop(): void;
48
+ private arm;
49
+ /** One sweep (see the module comment). `force` ignores the 24 h mark. */
50
+ sweep(options?: {
51
+ force?: boolean;
52
+ }): Promise<SweepReport>;
53
+ /** Exclusive lock between processes sharing the state folder; undefined when someone holds it. */
54
+ private lock;
55
+ private expireArtifacts;
56
+ private trimJobs;
57
+ /** Removes the empty session and workspace folders a deleted job leaves behind. */
58
+ private pruneEmptyParents;
59
+ private dropDatasets;
60
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Retention of analysis data (spec §19): `AnalysisJanitor.sweep()` runs at most once every 24 hours
3
+ * (the mark is `<state>/analysis/.last-sweep`), launched in the background shortly after startup
4
+ * with an unreferenced timer, so it never delays the start nor keeps a process alive.
5
+ *
6
+ * - `work/` of a job goes after `intermediateDays`; `logs/` and `staging/` after `jobsDays`.
7
+ * - `script/`, `input/` and `job.json` stay while any artifact of that execution is `ready`
8
+ * (a rerun needs them); once none is, the whole job folder goes after `jobsDays`.
9
+ * - With `artifactsDays > 0`, older artifacts become `expired` (folder deleted, row kept).
10
+ * - Datasets unused for `jobsDays` are deleted with their uploaded original in `blobs/`; an
11
+ * upload without a dataset (a failed ingestion) goes after the same time.
12
+ * - `0` disables the deletion of that class. Running executions are never touched.
13
+ *
14
+ * Deletions are best effort and independent: one that fails is counted and the sweep goes on.
15
+ */
16
+ import { mkdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
17
+ import { dirname, isAbsolute, join, relative } from "node:path";
18
+ const DAY = 86_400_000;
19
+ export const SWEEP_INTERVAL_MS = DAY;
20
+ /** A lock older than this belongs to a dead process. */
21
+ const STALE_LOCK_MS = 60 * 60_000;
22
+ /** A job still `running` after this long was left by a crashed process. */
23
+ const STALE_RUNNING_MS = DAY;
24
+ const empty = () => ({
25
+ workDeleted: 0,
26
+ jobsTrimmed: 0,
27
+ jobsDeleted: 0,
28
+ artifactsExpired: 0,
29
+ datasetsDeleted: 0,
30
+ uploadsDeleted: 0,
31
+ bytesFreed: 0,
32
+ errors: 0,
33
+ });
34
+ /** Size in bytes of a file or folder (0 when it does not exist). */
35
+ async function sizeOf(path) {
36
+ let info;
37
+ try {
38
+ info = await stat(path);
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ if (!info.isDirectory())
44
+ return info.size;
45
+ const { readdir } = await import("node:fs/promises");
46
+ let total = 0;
47
+ for (const entry of await readdir(path).catch(() => []))
48
+ total += await sizeOf(join(path, entry));
49
+ return total;
50
+ }
51
+ async function exists(path) {
52
+ try {
53
+ await stat(path);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ export class AnalysisJanitor {
61
+ options;
62
+ timer;
63
+ stopped = false;
64
+ constructor(options) {
65
+ this.options = options;
66
+ }
67
+ now() {
68
+ return (this.options.now ?? Date.now)();
69
+ }
70
+ get dir() {
71
+ return join(this.options.root, "analysis");
72
+ }
73
+ /** When the last sweep started (epoch ms), if there was one. */
74
+ async lastSweep() {
75
+ try {
76
+ const value = Number((await readFile(join(this.dir, ".last-sweep"), "utf8")).trim());
77
+ return Number.isFinite(value) ? value : undefined;
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ /** Launches the sweep in the background; never delays the caller and never keeps Node alive. */
84
+ start() {
85
+ this.stopped = false;
86
+ this.arm(this.options.initialDelayMs ?? 30_000);
87
+ }
88
+ stop() {
89
+ this.stopped = true;
90
+ if (this.timer)
91
+ clearTimeout(this.timer);
92
+ this.timer = undefined;
93
+ }
94
+ arm(delay) {
95
+ if (this.stopped)
96
+ return;
97
+ this.timer = setTimeout(() => {
98
+ void this.sweep()
99
+ .catch(() => undefined)
100
+ .finally(() => this.arm(SWEEP_INTERVAL_MS));
101
+ }, delay);
102
+ this.timer.unref();
103
+ }
104
+ /** One sweep (see the module comment). `force` ignores the 24 h mark. */
105
+ async sweep(options = {}) {
106
+ const report = empty();
107
+ const now = this.now();
108
+ const last = await this.lastSweep();
109
+ if (!options.force && last !== undefined && now - last < SWEEP_INTERVAL_MS)
110
+ return { ...report, skipped: "recent" };
111
+ const release = await this.lock();
112
+ if (!release)
113
+ return { ...report, skipped: "locked" };
114
+ try {
115
+ // The mark is written first: a crash mid-sweep does not make every start retry at once.
116
+ await writeFile(join(this.dir, ".last-sweep"), `${now}\n`);
117
+ const policy = this.options.retention();
118
+ await this.expireArtifacts(policy, now, report);
119
+ await this.trimJobs(policy, now, report);
120
+ await this.dropDatasets(policy, now, report);
121
+ }
122
+ finally {
123
+ await release();
124
+ }
125
+ return report;
126
+ }
127
+ /** 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;
144
+ }
145
+ async expireArtifacts(policy, now, report) {
146
+ if (policy.artifactsDays <= 0)
147
+ return;
148
+ for (const record of this.options.artifacts.readyBefore(now - policy.artifactsDays * DAY))
149
+ try {
150
+ const bytes = await sizeOf(this.options.artifacts.folder(record));
151
+ if (await this.options.artifacts.markExpired(record.id)) {
152
+ report.artifactsExpired++;
153
+ report.bytesFreed += bytes;
154
+ }
155
+ }
156
+ catch {
157
+ report.errors++;
158
+ }
159
+ }
160
+ async trimJobs(policy, now, report) {
161
+ if (policy.jobsDays <= 0 && policy.intermediateDays <= 0)
162
+ return;
163
+ const rows = this.options.db
164
+ .prepare("SELECT id, status, rel_dir, created_at, ended_at FROM analysis_executions")
165
+ .all();
166
+ const ready = this.options.db.prepare("SELECT count(*) AS n FROM artifacts WHERE execution_id=? AND status='ready'");
167
+ for (const row of rows) {
168
+ const stamp = row.ended_at ?? row.created_at;
169
+ if (row.status === "running" && now - row.created_at < STALE_RUNNING_MS)
170
+ continue;
171
+ const age = now - stamp;
172
+ const folder = join(this.options.root, row.rel_dir);
173
+ // Only folders under analysis/jobs are ever deleted, whatever a row says.
174
+ const inside = relative(join(this.options.root, "analysis", "jobs"), folder);
175
+ if (!inside || inside.startsWith("..") || isAbsolute(inside))
176
+ continue;
177
+ if (!(await exists(folder)))
178
+ continue;
179
+ try {
180
+ if (policy.intermediateDays > 0 && age > policy.intermediateDays * DAY) {
181
+ const target = join(folder, "work");
182
+ if (await exists(target)) {
183
+ report.bytesFreed += await sizeOf(target);
184
+ await rm(target, { recursive: true, force: true });
185
+ report.workDeleted++;
186
+ }
187
+ }
188
+ if (policy.jobsDays > 0 && age > policy.jobsDays * DAY) {
189
+ for (const name of ["logs", "staging"]) {
190
+ const target = join(folder, name);
191
+ if (await exists(target)) {
192
+ report.bytesFreed += await sizeOf(target);
193
+ await rm(target, { recursive: true, force: true });
194
+ }
195
+ }
196
+ const { n } = ready.get(row.id);
197
+ if (n === 0) {
198
+ report.bytesFreed += await sizeOf(folder);
199
+ await rm(folder, { recursive: true, force: true });
200
+ report.jobsDeleted++;
201
+ await this.pruneEmptyParents(folder);
202
+ }
203
+ else
204
+ report.jobsTrimmed++;
205
+ }
206
+ }
207
+ catch {
208
+ report.errors++;
209
+ }
210
+ }
211
+ }
212
+ /** Removes the empty session and workspace folders a deleted job leaves behind. */
213
+ async pruneEmptyParents(folder) {
214
+ const stop = join(this.options.root, "analysis", "jobs");
215
+ let dir = dirname(folder);
216
+ while (dir.startsWith(stop) && dir !== stop)
217
+ try {
218
+ await rmdir(dir);
219
+ dir = dirname(dir);
220
+ }
221
+ catch {
222
+ return; // not empty (or gone): done
223
+ }
224
+ }
225
+ async dropDatasets(policy, now, report) {
226
+ if (policy.jobsDays <= 0)
227
+ return;
228
+ const { datasets, blobs } = this.options;
229
+ const cutoff = now - policy.jobsDays * DAY;
230
+ if (datasets)
231
+ for (const record of datasets.all())
232
+ try {
233
+ const info = await stat(datasets.file(record)).catch(() => undefined);
234
+ // The file's modification time moves when the dataset is used (DatasetService.touch).
235
+ const lastUse = info ? Math.max(record.createdAt, info.mtimeMs) : record.createdAt;
236
+ if (lastUse >= cutoff)
237
+ continue;
238
+ const bytes = info?.size ?? 0;
239
+ await datasets.remove(record);
240
+ report.datasetsDeleted++;
241
+ report.bytesFreed += bytes;
242
+ }
243
+ catch {
244
+ report.errors++;
245
+ }
246
+ if (!blobs)
247
+ return;
248
+ // Uploaded originals: those no dataset refers to any more (and failed ingestions) go too.
249
+ const referenced = new Set(this.options.db
250
+ .prepare("SELECT blob_hash FROM datasets WHERE blob_hash IS NOT NULL")
251
+ .all().map((r) => r.blob_hash));
252
+ for (const hash of blobs.uploadsBefore(cutoff))
253
+ try {
254
+ if (referenced.has(hash))
255
+ continue;
256
+ const freed = blobs.removeUpload(hash);
257
+ report.uploadsDeleted++;
258
+ report.bytesFreed += freed;
259
+ }
260
+ catch {
261
+ report.errors++;
262
+ }
263
+ }
264
+ }
@@ -0,0 +1,94 @@
1
+ import type { SqlDatabase } from "@alisio/sdk";
2
+ export type ExecutionStatus = "running" | "completed" | "failed" | "cancelled" | "timed_out";
3
+ export interface JobPaths {
4
+ id: string;
5
+ root: string;
6
+ script: string;
7
+ main: string;
8
+ input: string;
9
+ work: string;
10
+ staging: string;
11
+ logs: string;
12
+ }
13
+ export interface JobInput {
14
+ /** Name inside `input/`. */
15
+ name: string;
16
+ /** Hash of the content named in the provenance (for a dataset: the original file). */
17
+ sha256: string;
18
+ bytes: number;
19
+ /** Set for a dataset (the `.sqlite` file of §17): its id and the hash of the original file. */
20
+ datasetId?: string;
21
+ /** Hash of the copy inside `input/` (differs from `sha256` for a dataset's SQLite file). */
22
+ copySha256?: string;
23
+ }
24
+ /** What `job.json` records about the inputs, so a rerun can verify them (internal; never public). */
25
+ export interface RecordedInputs {
26
+ inputs: JobInput[];
27
+ /** The `input/inputs.json` entries the script reads. */
28
+ manifest: unknown[];
29
+ /** Extras the run asked for (`python_run { extras }`), so a rerun asks for the same. */
30
+ extras?: string[];
31
+ }
32
+ export interface ExecutionDetails {
33
+ id: string;
34
+ sessionId: string;
35
+ rootSessionId: string;
36
+ workspace: string;
37
+ runtime: "managed" | "oci";
38
+ status: ExecutionStatus;
39
+ scriptSha256: string;
40
+ exitCode?: number;
41
+ relDir: string;
42
+ rerunOf?: string;
43
+ createdAt: number;
44
+ endedAt?: number;
45
+ }
46
+ /** Executions of `python_run` (`analysis_executions`) and their job folders. */
47
+ export declare class AnalysisJobs {
48
+ private options;
49
+ constructor(options: {
50
+ root: string;
51
+ db: SqlDatabase;
52
+ now?: () => number;
53
+ });
54
+ private now;
55
+ /** Creates the job folder (script, runtime helpers, empty input/work/staging/logs) and its row. */
56
+ create(input: {
57
+ sessionId: string;
58
+ rootSessionId: string;
59
+ workspace: string;
60
+ runId?: string;
61
+ callId?: string;
62
+ code: string;
63
+ runtime: "managed" | "oci";
64
+ /** The execution this one repeats (a rerun); recorded in the row and in `job.json`. */
65
+ rerunOf?: string;
66
+ }): Promise<JobPaths & {
67
+ scriptSha256: string;
68
+ }>;
69
+ /**
70
+ * Copies an input file (already resolved by the path policy) into `input/`, as `name` (default:
71
+ * its own name). Copy-on-write clones are used where the file system has them; the copy is
72
+ * never a link, so a script cannot alter the original.
73
+ */
74
+ addInput(job: JobPaths, source: string, preferred?: string): Promise<JobInput>;
75
+ /** `input/inputs.json`: what each input is (the script reads it with `alisio_runtime.datasets.inputs()`). */
76
+ writeInputsManifest(job: JobPaths, entries: unknown[]): Promise<void>;
77
+ /** Adds the inputs to `job.json` (atomically), once they are copied into `input/`. */
78
+ recordInputs(job: JobPaths, recorded: RecordedInputs): Promise<void>;
79
+ /** The inputs a finished job recorded; `undefined` when `job.json` is missing or has none. */
80
+ recordedInputs(relDir: string): Promise<RecordedInputs | undefined>;
81
+ /** Absolute folder of an execution (`rel_dir` is always relative to the state root). */
82
+ folder(relDir: string): string;
83
+ /** The whole row of an execution. */
84
+ details(id: string): ExecutionDetails | undefined;
85
+ finish(id: string, status: ExecutionStatus, details?: {
86
+ exitCode?: number;
87
+ error?: string;
88
+ }): void;
89
+ get(id: string): {
90
+ status: ExecutionStatus;
91
+ exitCode?: number;
92
+ relDir: string;
93
+ } | undefined;
94
+ }