@alisio/core 0.1.0-alpha.9 → 0.1.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 (187) hide show
  1. package/README.md +52 -7
  2. package/dist/agents/active.d.ts +99 -0
  3. package/dist/agents/active.js +218 -0
  4. package/dist/agents/capabilities.d.ts +26 -0
  5. package/dist/agents/capabilities.js +120 -0
  6. package/dist/agents/create-agent-skill.d.ts +11 -0
  7. package/dist/agents/create-agent-skill.js +60 -0
  8. package/dist/agents/definitions.d.ts +88 -0
  9. package/dist/agents/definitions.js +404 -0
  10. package/dist/agents/draft.d.ts +41 -0
  11. package/dist/agents/draft.js +133 -0
  12. package/dist/agents/service.d.ts +57 -0
  13. package/dist/agents/service.js +86 -0
  14. package/dist/agents/templates.d.ts +8 -0
  15. package/dist/agents/templates.js +102 -0
  16. package/dist/analysis/capabilities.d.ts +48 -0
  17. package/dist/analysis/capabilities.js +90 -0
  18. package/dist/analysis/data/csv.d.ts +46 -0
  19. package/dist/analysis/data/csv.js +240 -0
  20. package/dist/analysis/data/datasets.d.ts +132 -0
  21. package/dist/analysis/data/datasets.js +441 -0
  22. package/dist/analysis/data/describe.d.ts +27 -0
  23. package/dist/analysis/data/describe.js +103 -0
  24. package/dist/analysis/data/engine-client.d.ts +58 -0
  25. package/dist/analysis/data/engine-client.js +304 -0
  26. package/dist/analysis/data/engine-entry.d.ts +1 -0
  27. package/dist/analysis/data/engine-entry.js +59 -0
  28. package/dist/analysis/data/engine-source.d.ts +4 -0
  29. package/dist/analysis/data/engine-source.js +6 -0
  30. package/dist/analysis/data/engine-types.d.ts +86 -0
  31. package/dist/analysis/data/engine-types.js +7 -0
  32. package/dist/analysis/data/infer.d.ts +42 -0
  33. package/dist/analysis/data/infer.js +118 -0
  34. package/dist/analysis/data/ingest-worker.d.ts +17 -0
  35. package/dist/analysis/data/ingest-worker.js +365 -0
  36. package/dist/analysis/data/json.d.ts +19 -0
  37. package/dist/analysis/data/json.js +44 -0
  38. package/dist/analysis/data/query-worker.d.ts +12 -0
  39. package/dist/analysis/data/query-worker.js +75 -0
  40. package/dist/analysis/data/rows.d.ts +39 -0
  41. package/dist/analysis/data/rows.js +120 -0
  42. package/dist/analysis/data/sql-guard.d.ts +20 -0
  43. package/dist/analysis/data/sql-guard.js +129 -0
  44. package/dist/analysis/data/stats.d.ts +18 -0
  45. package/dist/analysis/data/stats.js +77 -0
  46. package/dist/analysis/install-hints.d.ts +33 -0
  47. package/dist/analysis/install-hints.js +144 -0
  48. package/dist/analysis/janitor.d.ts +60 -0
  49. package/dist/analysis/janitor.js +264 -0
  50. package/dist/analysis/jobs.d.ts +94 -0
  51. package/dist/analysis/jobs.js +165 -0
  52. package/dist/analysis/oci.d.ts +129 -0
  53. package/dist/analysis/oci.js +330 -0
  54. package/dist/analysis/python/sources.d.ts +2 -0
  55. package/dist/analysis/python/sources.js +10 -0
  56. package/dist/analysis/requirements.d.ts +32 -0
  57. package/dist/analysis/requirements.js +73 -0
  58. package/dist/analysis/rerun.d.ts +40 -0
  59. package/dist/analysis/rerun.js +136 -0
  60. package/dist/analysis/runtime-manager.d.ts +146 -0
  61. package/dist/analysis/runtime-manager.js +472 -0
  62. package/dist/analysis/status.d.ts +21 -0
  63. package/dist/analysis/status.js +52 -0
  64. package/dist/application.d.ts +105 -3
  65. package/dist/application.js +446 -18
  66. package/dist/artifacts/kinds.d.ts +35 -0
  67. package/dist/artifacts/kinds.js +177 -0
  68. package/dist/artifacts/publisher.d.ts +7 -0
  69. package/dist/artifacts/publisher.js +29 -0
  70. package/dist/artifacts/store.d.ts +119 -0
  71. package/dist/artifacts/store.js +581 -0
  72. package/dist/background/janitor.d.ts +22 -0
  73. package/dist/background/janitor.js +62 -0
  74. package/dist/background/mirror.d.ts +14 -0
  75. package/dist/background/mirror.js +41 -0
  76. package/dist/background/notify.d.ts +58 -0
  77. package/dist/background/notify.js +172 -0
  78. package/dist/background/output.d.ts +46 -0
  79. package/dist/background/output.js +179 -0
  80. package/dist/background/service.d.ts +137 -0
  81. package/dist/background/service.js +410 -0
  82. package/dist/background/store.d.ts +103 -0
  83. package/dist/background/store.js +195 -0
  84. package/dist/changelog/data.d.ts +6 -0
  85. package/dist/changelog/data.js +293 -0
  86. package/dist/changelog/index.d.ts +5 -0
  87. package/dist/changelog/index.js +7 -0
  88. package/dist/changelog/parse.d.ts +53 -0
  89. package/dist/changelog/parse.js +160 -0
  90. package/dist/commands/builtins.d.ts +10 -0
  91. package/dist/commands/builtins.js +161 -0
  92. package/dist/commands/catalog.d.ts +86 -0
  93. package/dist/commands/catalog.js +304 -0
  94. package/dist/config.d.ts +206 -6
  95. package/dist/config.js +426 -20
  96. package/dist/core/compaction.js +13 -2
  97. package/dist/core/contracts.d.ts +180 -3
  98. package/dist/core/opt-in.d.ts +12 -0
  99. package/dist/core/opt-in.js +12 -0
  100. package/dist/core/output-limit.d.ts +34 -0
  101. package/dist/core/output-limit.js +39 -0
  102. package/dist/core/run-stats.d.ts +16 -0
  103. package/dist/core/run-stats.js +8 -0
  104. package/dist/core/runner.d.ts +134 -4
  105. package/dist/core/runner.js +780 -138
  106. package/dist/core/timeout.d.ts +15 -0
  107. package/dist/core/timeout.js +61 -0
  108. package/dist/core/truncation.d.ts +30 -0
  109. package/dist/core/truncation.js +83 -0
  110. package/dist/goal/blockers.d.ts +45 -0
  111. package/dist/goal/blockers.js +51 -0
  112. package/dist/goal/budget.d.ts +24 -0
  113. package/dist/goal/budget.js +53 -0
  114. package/dist/goal/command.d.ts +36 -0
  115. package/dist/goal/command.js +98 -0
  116. package/dist/goal/format.d.ts +19 -0
  117. package/dist/goal/format.js +102 -0
  118. package/dist/goal/host.d.ts +29 -0
  119. package/dist/goal/host.js +42 -0
  120. package/dist/goal/index.d.ts +9 -0
  121. package/dist/goal/index.js +9 -0
  122. package/dist/goal/machine.d.ts +186 -0
  123. package/dist/goal/machine.js +344 -0
  124. package/dist/goal/prompts.d.ts +10 -0
  125. package/dist/goal/prompts.js +75 -0
  126. package/dist/goal/service.d.ts +113 -0
  127. package/dist/goal/service.js +273 -0
  128. package/dist/goal/store.d.ts +59 -0
  129. package/dist/goal/store.js +184 -0
  130. package/dist/index.d.ts +50 -5
  131. package/dist/index.js +49 -4
  132. package/dist/mcp/connector.js +38 -6
  133. package/dist/mcp/rich.d.ts +41 -0
  134. package/dist/mcp/rich.js +362 -0
  135. package/dist/permissions/modes.d.ts +72 -0
  136. package/dist/permissions/modes.js +98 -0
  137. package/dist/plan/exit-plan.d.ts +25 -0
  138. package/dist/plan/exit-plan.js +130 -0
  139. package/dist/plan/state.d.ts +102 -0
  140. package/dist/plan/state.js +165 -0
  141. package/dist/plugins/host.d.ts +34 -1
  142. package/dist/plugins/host.js +143 -9
  143. package/dist/providers/settings.d.ts +15 -0
  144. package/dist/providers/settings.js +38 -0
  145. package/dist/reload.d.ts +75 -0
  146. package/dist/reload.js +204 -0
  147. package/dist/resources/context.js +6 -1
  148. package/dist/runtime/access.d.ts +62 -0
  149. package/dist/runtime/access.js +115 -0
  150. package/dist/runtime/blobs.d.ts +51 -0
  151. package/dist/runtime/blobs.js +177 -0
  152. package/dist/runtime/diff.d.ts +23 -0
  153. package/dist/runtime/diff.js +154 -0
  154. package/dist/runtime/ids.d.ts +8 -0
  155. package/dist/runtime/ids.js +32 -0
  156. package/dist/runtime/paths.d.ts +11 -0
  157. package/dist/runtime/paths.js +17 -1
  158. package/dist/runtime/process.d.ts +22 -0
  159. package/dist/runtime/process.js +100 -15
  160. package/dist/runtime/sqlite.d.ts +2 -0
  161. package/dist/runtime/sqlite.js +18 -0
  162. package/dist/runtime/store.d.ts +91 -3
  163. package/dist/runtime/store.js +465 -11
  164. package/dist/runtime/zip.d.ts +24 -0
  165. package/dist/runtime/zip.js +126 -0
  166. package/dist/sessions/children.js +17 -1
  167. package/dist/sessions/side-questions.d.ts +57 -0
  168. package/dist/sessions/side-questions.js +161 -0
  169. package/dist/tools/analysis.d.ts +37 -0
  170. package/dist/tools/analysis.js +573 -0
  171. package/dist/tools/artifacts.d.ts +14 -0
  172. package/dist/tools/artifacts.js +143 -0
  173. package/dist/tools/background.d.ts +7 -0
  174. package/dist/tools/background.js +165 -0
  175. package/dist/tools/data.d.ts +7 -0
  176. package/dist/tools/data.js +160 -0
  177. package/dist/tools/execute.d.ts +5 -0
  178. package/dist/tools/execute.js +15 -3
  179. package/dist/tools/goal.d.ts +19 -0
  180. package/dist/tools/goal.js +126 -0
  181. package/dist/tools/search.d.ts +17 -3
  182. package/dist/tools/search.js +153 -0
  183. package/dist/tools/standard.d.ts +4 -1
  184. package/dist/tools/standard.js +99 -18
  185. package/dist/version.d.ts +5 -3
  186. package/dist/version.js +35 -6
  187. package/package.json +2 -2
@@ -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
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Internal job folders of `python_run`, outside the repository and outside the artifact store:
3
+ * `<root>/analysis/jobs/<workspaceKey>/<root session>/<executionId>/` with `job.json`,
4
+ * `script/main.py` (+ the `alisio_runtime` package), `input/`, `work/`, `staging/` and `logs/`.
5
+ * Nothing here is published automatically: only `staging/` is read by the artifact publisher.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { constants } from "node:fs";
9
+ import { copyFile, lstat, mkdir, readFile, rename, writeFile } from "node:fs/promises";
10
+ import { basename, isAbsolute, join, relative, sep } from "node:path";
11
+ import { newId } from "../runtime/ids.js";
12
+ import { workspaceKey } from "../runtime/paths.js";
13
+ import { ALISIO_RUNTIME_FILES } from "./python/sources.js";
14
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
15
+ /** Executions of `python_run` (`analysis_executions`) and their job folders. */
16
+ export class AnalysisJobs {
17
+ options;
18
+ constructor(options) {
19
+ this.options = options;
20
+ }
21
+ now() {
22
+ return (this.options.now ?? Date.now)();
23
+ }
24
+ /** Creates the job folder (script, runtime helpers, empty input/work/staging/logs) and its row. */
25
+ async create(input) {
26
+ const id = newId("exec", this.now());
27
+ const root = join(this.options.root, "analysis", "jobs", workspaceKey(input.workspace), input.rootSessionId, id);
28
+ const paths = {
29
+ id,
30
+ root,
31
+ script: join(root, "script"),
32
+ main: join(root, "script", "main.py"),
33
+ input: join(root, "input"),
34
+ work: join(root, "work"),
35
+ staging: join(root, "staging"),
36
+ logs: join(root, "logs"),
37
+ };
38
+ for (const dir of [paths.script, paths.input, paths.work, paths.staging, paths.logs])
39
+ await mkdir(dir, { recursive: true });
40
+ await writeFile(paths.main, input.code, { flag: "wx" });
41
+ const runtimeDir = join(paths.script, "alisio_runtime");
42
+ await mkdir(runtimeDir);
43
+ for (const [name, text] of Object.entries(ALISIO_RUNTIME_FILES))
44
+ await writeFile(join(runtimeDir, name), text, { flag: "wx" });
45
+ const scriptSha256 = sha256(input.code);
46
+ const createdAt = this.now();
47
+ await writeFile(join(root, "job.json"), `${JSON.stringify({
48
+ schemaVersion: 1,
49
+ id,
50
+ sessionId: input.sessionId,
51
+ rootSessionId: input.rootSessionId,
52
+ runId: input.runId,
53
+ callId: input.callId,
54
+ runtime: input.runtime,
55
+ scriptSha256,
56
+ ...(input.rerunOf ? { rerunOf: input.rerunOf } : {}),
57
+ createdAt,
58
+ }, null, 2)}\n`);
59
+ this.options.db
60
+ .prepare(`INSERT INTO analysis_executions(id,session,root_session,workspace,run_id,call_id,runtime,
61
+ status,script_sha256,rel_dir,rerun_of,created_at) VALUES(?,?,?,?,?,?,?,'running',?,?,?,?)`)
62
+ .run(id, input.sessionId, input.rootSessionId, input.workspace, input.runId ?? null, input.callId ?? null, input.runtime, scriptSha256, relative(this.options.root, root), input.rerunOf ?? null, createdAt);
63
+ return { ...paths, scriptSha256 };
64
+ }
65
+ /**
66
+ * Copies an input file (already resolved by the path policy) into `input/`, as `name` (default:
67
+ * its own name). Copy-on-write clones are used where the file system has them; the copy is
68
+ * never a link, so a script cannot alter the original.
69
+ */
70
+ async addInput(job, source, preferred) {
71
+ const info = await lstat(source);
72
+ if (!info.isFile())
73
+ throw new Error(`Input ${source} is not a regular file`);
74
+ const wanted = preferred ?? basename(source);
75
+ let name = wanted;
76
+ for (let n = 2; await exists(join(job.input, name)); n++)
77
+ name = wanted.replace(/(\.[^.]*)?$/, (ext) => `-${n}${ext}`);
78
+ const target = join(job.input, name);
79
+ await copyFile(source, target, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
80
+ const bytes = await readFile(target);
81
+ return { name, sha256: sha256(bytes), bytes: bytes.byteLength };
82
+ }
83
+ /** `input/inputs.json`: what each input is (the script reads it with `alisio_runtime.datasets.inputs()`). */
84
+ async writeInputsManifest(job, entries) {
85
+ await writeFile(join(job.input, "inputs.json"), `${JSON.stringify(entries, null, 2)}\n`);
86
+ }
87
+ /** Adds the inputs to `job.json` (atomically), once they are copied into `input/`. */
88
+ async recordInputs(job, recorded) {
89
+ const file = join(job.root, "job.json");
90
+ const current = JSON.parse(await readFile(file, "utf8"));
91
+ const temporary = `${file}.${process.pid}.tmp`;
92
+ await writeFile(temporary, `${JSON.stringify({ ...current, ...recorded }, null, 2)}\n`);
93
+ await rename(temporary, file);
94
+ }
95
+ /** The inputs a finished job recorded; `undefined` when `job.json` is missing or has none. */
96
+ async recordedInputs(relDir) {
97
+ try {
98
+ const value = JSON.parse(await readFile(join(this.options.root, relDir, "job.json"), "utf8"));
99
+ return Array.isArray(value.inputs)
100
+ ? {
101
+ inputs: value.inputs,
102
+ manifest: Array.isArray(value.manifest) ? value.manifest : [],
103
+ ...(Array.isArray(value.extras) ? { extras: value.extras } : {}),
104
+ }
105
+ : undefined;
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ }
111
+ /** Absolute folder of an execution (`rel_dir` is always relative to the state root). */
112
+ folder(relDir) {
113
+ const folder = join(this.options.root, relDir);
114
+ const inside = relative(this.options.root, folder);
115
+ if (!inside || inside.startsWith("..") || isAbsolute(inside) || inside.split(sep)[0] === "..")
116
+ throw new Error("The execution folder is outside the state folder");
117
+ return folder;
118
+ }
119
+ /** The whole row of an execution. */
120
+ details(id) {
121
+ const row = this.options.db.prepare("SELECT * FROM analysis_executions WHERE id=?").get(id);
122
+ if (!row)
123
+ return undefined;
124
+ return {
125
+ id: String(row.id),
126
+ sessionId: String(row.session),
127
+ rootSessionId: String(row.root_session),
128
+ workspace: String(row.workspace),
129
+ runtime: String(row.runtime),
130
+ status: String(row.status),
131
+ scriptSha256: String(row.script_sha256),
132
+ ...(row.exit_code != null ? { exitCode: Number(row.exit_code) } : {}),
133
+ relDir: String(row.rel_dir),
134
+ ...(row.rerun_of != null ? { rerunOf: String(row.rerun_of) } : {}),
135
+ createdAt: Number(row.created_at),
136
+ ...(row.ended_at != null ? { endedAt: Number(row.ended_at) } : {}),
137
+ };
138
+ }
139
+ finish(id, status, details = {}) {
140
+ this.options.db
141
+ .prepare("UPDATE analysis_executions SET status=?, exit_code=?, error=?, ended_at=? WHERE id=?")
142
+ .run(status, details.exitCode ?? null, details.error ?? null, this.now(), id);
143
+ }
144
+ get(id) {
145
+ const row = this.options.db
146
+ .prepare("SELECT status, exit_code, rel_dir FROM analysis_executions WHERE id=?")
147
+ .get(id);
148
+ return row
149
+ ? {
150
+ status: row.status,
151
+ ...(row.exit_code != null ? { exitCode: row.exit_code } : {}),
152
+ relDir: row.rel_dir,
153
+ }
154
+ : undefined;
155
+ }
156
+ }
157
+ async function exists(path) {
158
+ try {
159
+ await lstat(path);
160
+ return true;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
@@ -0,0 +1,129 @@
1
+ import { type ProcessResult } from "../runtime/process.ts";
2
+ export type OciEngine = "docker" | "podman";
3
+ export interface OciSettings {
4
+ engine: OciEngine;
5
+ /** `name@sha256:<digest>`; required to run. */
6
+ image?: string;
7
+ memoryMb: number;
8
+ cpus: number;
9
+ }
10
+ /** The job folders mounted into the container (host paths). */
11
+ export interface OciMounts {
12
+ script: string;
13
+ input: string;
14
+ work: string;
15
+ staging: string;
16
+ }
17
+ /** Paths inside the container (spec §8.3). */
18
+ export declare const CONTAINER: {
19
+ readonly script: "/job/script";
20
+ readonly input: "/job/input";
21
+ readonly work: "/job/work";
22
+ readonly out: "/job/out";
23
+ };
24
+ export declare const PIDS_LIMIT = 256;
25
+ export declare const TMPFS_SIZE = "256m";
26
+ /** `alisio-exec_01J…`: the container of one execution. */
27
+ export declare function containerName(executionId: string): string;
28
+ export interface OciHost {
29
+ platform: NodeJS.Platform;
30
+ uid?: number;
31
+ gid?: number;
32
+ /** The engine runs without root privileges (rootless Docker or Podman). */
33
+ rootless?: boolean;
34
+ /** SELinux is enforcing: bind mounts need a relabel option on the host. */
35
+ selinux?: boolean;
36
+ }
37
+ /** A host path that can be passed to `-v`; throws when the engine would misparse it. */
38
+ export declare function mountSource(path: string, platform: NodeJS.Platform): string;
39
+ /**
40
+ * `<engine> run` arguments for one execution (spec §8.3). The container name is
41
+ * `alisio-<executionId>` so a cancellation can `kill` it by name.
42
+ */
43
+ export declare function ociRunArgs(input: {
44
+ settings: OciSettings & {
45
+ image: string;
46
+ };
47
+ executionId: string;
48
+ mounts: OciMounts;
49
+ host: OciHost;
50
+ /** Extra environment variables for the script (the ALISIO_* ones are always set). */
51
+ env?: Record<string, string>;
52
+ }): string[];
53
+ /** Collaborators of the runtime, replaceable in tests (a fake container CLI). */
54
+ export interface OciDeps {
55
+ /** Resolves the engine executable; `undefined` when it is not installed. */
56
+ locate(engine: OciEngine): Promise<string | undefined>;
57
+ /** Arguments placed before the engine's own (test launchers only). */
58
+ prefixArgs?: string[];
59
+ host(): Promise<OciHost>;
60
+ /** Runs one engine command to completion. */
61
+ exec(command: string, args: string[], options: {
62
+ signal: AbortSignal;
63
+ timeoutMs: number;
64
+ }): Promise<{
65
+ stdout: string;
66
+ stderr: string;
67
+ exitCode: number;
68
+ }>;
69
+ }
70
+ export declare function defaultOciDeps(): OciDeps;
71
+ export type OciStatus = {
72
+ ok: true;
73
+ engine: OciEngine;
74
+ path: string;
75
+ version: string;
76
+ rootless: boolean;
77
+ } | {
78
+ ok: false;
79
+ engine: OciEngine;
80
+ reason: string;
81
+ /** The other engine is installed: switching `analysis.oci.engine` is a way out. */
82
+ alternative?: OciEngine;
83
+ };
84
+ /** Container runtime of `python_run`; all engine calls go through `OciDeps`. */
85
+ export declare class OciRuntime {
86
+ readonly settings: OciSettings;
87
+ readonly deps: OciDeps;
88
+ constructor(settings: OciSettings, deps?: Partial<OciDeps>);
89
+ private command;
90
+ private call;
91
+ /**
92
+ * Is the engine installed and its daemon reachable? Optional by design: a missing engine is a
93
+ * status, not an error of Alisio. Also reports rootless mode, which changes the `--user` flags.
94
+ */
95
+ status(signal?: AbortSignal): Promise<OciStatus>;
96
+ /** The reason a run cannot start (`undefined` = ready), checked before any job is created. */
97
+ unavailable(signal?: AbortSignal): Promise<string | undefined>;
98
+ /**
99
+ * Runs the job's script in a container. Cancelling or timing out `kills` the container by name
100
+ * (killing the client process does not always stop it) and no `alisio-*` container is left.
101
+ */
102
+ run(input: {
103
+ executionId: string;
104
+ mounts: OciMounts;
105
+ signal: AbortSignal;
106
+ timeoutMs: number;
107
+ maxBytes: number;
108
+ onData?: (chunk: string) => void;
109
+ logFiles: {
110
+ stdout: string;
111
+ stderr: string;
112
+ };
113
+ maxLogBytes: number;
114
+ }): Promise<ProcessResult & {
115
+ containerName: string;
116
+ }>;
117
+ /** `<engine> kill <name>`; a container that is already gone is not an error. */
118
+ kill(name: string): Promise<void>;
119
+ /** Names of the `alisio-*` containers that still exist (running or not). */
120
+ leftovers(): Promise<string[]>;
121
+ /** Pulls the image (once, from `alisio analysis setup --oci`); a digest reference verifies itself. */
122
+ pull(image: string, signal: AbortSignal): Promise<{
123
+ digest?: string;
124
+ }>;
125
+ /** True when the image is already in the local store (so a run never needs the network). */
126
+ hasImage(image: string, signal?: AbortSignal): Promise<boolean>;
127
+ }
128
+ /** `["repo@sha256:…"]` → `["repo@sha256:…"]` (JSON from `--format {{json .RepoDigests}}`). */
129
+ export declare function parseDigests(output: string): string[];