@alisio/core 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +28 -2
- package/dist/application.js +189 -4
- package/dist/changelog/data.js +26 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
- package/dist/forge/javascript/hosts/worker-host.js +101 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +338 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +103 -0
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The volatile session directory (spec §9.4, D19): a private folder per root session under the
|
|
3
|
+
* system temporary directory with `scratchpad/` (scripts and intermediates the model writes) and
|
|
4
|
+
* `tasks/` (live output of Forja executions), plus the helpers that keep what goes into it
|
|
5
|
+
* confined and bounded.
|
|
6
|
+
*
|
|
7
|
+
* Creation never trusts a folder it did not make private itself: every level is created `0700`
|
|
8
|
+
* and then checked with `lstat` (a directory, not a symlink, owned by this user, no group or other
|
|
9
|
+
* bits). If the shared root fails the check it is neither used, changed (`chmod`) nor followed:
|
|
10
|
+
* the session falls back to `<stateHome>/scratch/<workspaceKey>/<rootSessionId>/` and reports a
|
|
11
|
+
* diagnostic. The POSIX checks are skipped on Windows, where `%TEMP%` is already per user. This is
|
|
12
|
+
* a mediated policy, not protection against a hostile process racing the creation.
|
|
13
|
+
*/
|
|
14
|
+
import { lstat, mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join, sep } from "node:path";
|
|
17
|
+
import { ForgeError } from "./errors.js";
|
|
18
|
+
import { isSafeSegment, privateDirProblem, resolveWithin, scratchBaseName, tmpRootChoice, workspaceSlug, } from "./session-path.js";
|
|
19
|
+
export const SESSION_MARKER = ".alisio-session";
|
|
20
|
+
const isErrno = (error, code) => !!error && typeof error === "object" && error.code === code;
|
|
21
|
+
/** Creates one level `0700` if missing and checks it is private to this user (POSIX). */
|
|
22
|
+
async function privateDir(path, uid) {
|
|
23
|
+
try {
|
|
24
|
+
await mkdir(path, { mode: 0o700 });
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!isErrno(error, "EEXIST"))
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
if (uid === undefined)
|
|
31
|
+
return undefined;
|
|
32
|
+
const info = await lstat(path);
|
|
33
|
+
const problem = privateDirProblem({
|
|
34
|
+
isDirectory: info.isDirectory(),
|
|
35
|
+
isSymbolicLink: info.isSymbolicLink(),
|
|
36
|
+
uid: info.uid,
|
|
37
|
+
mode: info.mode,
|
|
38
|
+
}, uid);
|
|
39
|
+
return problem ? `${path}: ${problem}` : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** Creates `parent/…/leaf` level by level, each private. The first problem found, if any. */
|
|
42
|
+
async function privateChain(parent, names, uid) {
|
|
43
|
+
let current = parent;
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
current = join(current, name);
|
|
46
|
+
const problem = await privateDir(current, uid);
|
|
47
|
+
if (problem)
|
|
48
|
+
return problem;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
async function writeMarker(root, marker) {
|
|
53
|
+
try {
|
|
54
|
+
await writeFile(join(root, SESSION_MARKER), `${JSON.stringify(marker)}\n`, {
|
|
55
|
+
flag: "wx",
|
|
56
|
+
mode: 0o600,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (!isErrno(error, "EEXIST"))
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function ensureSessionDir(options) {
|
|
65
|
+
if (!isSafeSegment(options.rootSessionId))
|
|
66
|
+
throw new ForgeError("INVALID_INPUT", "root session id is not a safe folder name");
|
|
67
|
+
const platform = options.platform ?? process.platform;
|
|
68
|
+
const posix = platform !== "win32";
|
|
69
|
+
const uid = posix ? (options.uid ?? process.getuid?.()) : undefined;
|
|
70
|
+
const env = options.env ?? process.env;
|
|
71
|
+
const marker = {
|
|
72
|
+
sessionId: options.rootSessionId,
|
|
73
|
+
workspaceKey: options.workspaceKey,
|
|
74
|
+
createdAt: (options.now ?? Date.now)(),
|
|
75
|
+
};
|
|
76
|
+
const finish = async (root, extra) => {
|
|
77
|
+
await writeMarker(root, marker);
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
scratchpad: join(root, "scratchpad"),
|
|
81
|
+
tasks: join(root, "tasks"),
|
|
82
|
+
fallback: false,
|
|
83
|
+
...extra,
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
const build = async (parent, names) => (await privateChain(parent, [...names, "scratchpad"], uid)) ??
|
|
87
|
+
(await privateChain(join(parent, ...names), ["tasks"], uid));
|
|
88
|
+
let problem;
|
|
89
|
+
try {
|
|
90
|
+
let base = tmpRootChoice({
|
|
91
|
+
env: env.ALISIO_TMPDIR,
|
|
92
|
+
config: options.configRoot,
|
|
93
|
+
os: options.osTmpdir ?? tmpdir(),
|
|
94
|
+
});
|
|
95
|
+
await mkdir(base, { recursive: true, mode: 0o700 });
|
|
96
|
+
// macOS: /tmp and /var are symlinks into /private; compare and create on the real path.
|
|
97
|
+
if (platform === "darwin")
|
|
98
|
+
base = await realpath(base);
|
|
99
|
+
const names = [
|
|
100
|
+
scratchBaseName(platform, uid),
|
|
101
|
+
workspaceSlug(options.workspace, options.workspaceKey),
|
|
102
|
+
options.rootSessionId,
|
|
103
|
+
];
|
|
104
|
+
problem = await build(base, names);
|
|
105
|
+
if (!problem)
|
|
106
|
+
return await finish(join(base, ...names), {});
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
problem = `${error.message}`;
|
|
110
|
+
}
|
|
111
|
+
// Untrusted or unusable shared root: never chmod it, never follow it.
|
|
112
|
+
const fallbackNames = ["scratch", options.workspaceKey, options.rootSessionId];
|
|
113
|
+
await mkdir(options.stateHome, { recursive: true, mode: 0o700 });
|
|
114
|
+
const failure = await build(options.stateHome, fallbackNames);
|
|
115
|
+
if (failure)
|
|
116
|
+
throw new ForgeError("INVALID_INPUT", `no usable scratch directory: ${failure}`);
|
|
117
|
+
const root = join(options.stateHome, ...fallbackNames);
|
|
118
|
+
return finish(root, {
|
|
119
|
+
fallback: true,
|
|
120
|
+
diagnostic: `Shared scratch root not used (${problem}); using ${root}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The scratchpad folder of a session if it already exists and is a private directory, else
|
|
125
|
+
* undefined. Unlike `ensureSessionDir` it never creates anything, so a read-only look (the HTTP
|
|
126
|
+
* listing) leaves no trace. Tries the shared temporary root first, then the state fallback.
|
|
127
|
+
*/
|
|
128
|
+
export async function findSessionScratchpad(options) {
|
|
129
|
+
if (!isSafeSegment(options.rootSessionId))
|
|
130
|
+
return undefined;
|
|
131
|
+
const platform = options.platform ?? process.platform;
|
|
132
|
+
const uid = platform === "win32" ? undefined : (options.uid ?? process.getuid?.());
|
|
133
|
+
const env = options.env ?? process.env;
|
|
134
|
+
const usable = async (path) => {
|
|
135
|
+
const info = await lstat(path).catch(() => undefined);
|
|
136
|
+
if (!info)
|
|
137
|
+
return false;
|
|
138
|
+
if (uid === undefined)
|
|
139
|
+
return info.isDirectory() && !info.isSymbolicLink();
|
|
140
|
+
return (privateDirProblem({
|
|
141
|
+
isDirectory: info.isDirectory(),
|
|
142
|
+
isSymbolicLink: info.isSymbolicLink(),
|
|
143
|
+
uid: info.uid,
|
|
144
|
+
mode: info.mode,
|
|
145
|
+
}, uid) === undefined);
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
let base = tmpRootChoice({
|
|
149
|
+
env: env.ALISIO_TMPDIR,
|
|
150
|
+
config: options.configRoot,
|
|
151
|
+
os: options.osTmpdir ?? tmpdir(),
|
|
152
|
+
});
|
|
153
|
+
if (platform === "darwin")
|
|
154
|
+
base = await realpath(base).catch(() => base);
|
|
155
|
+
const primary = join(base, scratchBaseName(platform, uid), workspaceSlug(options.workspace, options.workspaceKey), options.rootSessionId, "scratchpad");
|
|
156
|
+
if (await usable(primary))
|
|
157
|
+
return primary;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// fall through to the state folder
|
|
161
|
+
}
|
|
162
|
+
const fallback = join(options.stateHome, "scratch", options.workspaceKey, options.rootSessionId, "scratchpad");
|
|
163
|
+
return (await usable(fallback)) ? fallback : undefined;
|
|
164
|
+
}
|
|
165
|
+
/** The marker of a session folder, or undefined when it is missing or not valid. */
|
|
166
|
+
export async function readSessionMarker(root) {
|
|
167
|
+
try {
|
|
168
|
+
const info = await lstat(join(root, SESSION_MARKER));
|
|
169
|
+
if (!info.isFile())
|
|
170
|
+
return undefined;
|
|
171
|
+
const value = JSON.parse(await readFile(join(root, SESSION_MARKER), "utf8"));
|
|
172
|
+
if (typeof value.sessionId !== "string" || typeof value.workspaceKey !== "string")
|
|
173
|
+
return undefined;
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const inside = (root, path) => path === root || path.startsWith(root + sep);
|
|
181
|
+
/**
|
|
182
|
+
* A scratchpad path the model gave (relative, or absolute inside), checked lexically and then
|
|
183
|
+
* against the real file system: the deepest existing ancestor must resolve inside the real
|
|
184
|
+
* scratchpad, so a symlink that points out is refused. Throws `INVALID_INPUT`.
|
|
185
|
+
*/
|
|
186
|
+
export async function resolveScratchPath(scratchpad, input) {
|
|
187
|
+
const target = resolveWithin(scratchpad, input);
|
|
188
|
+
const realBase = await realpath(scratchpad);
|
|
189
|
+
let probe = target;
|
|
190
|
+
for (;;) {
|
|
191
|
+
try {
|
|
192
|
+
const real = await realpath(probe);
|
|
193
|
+
if (!inside(realBase, real))
|
|
194
|
+
throw new ForgeError("INVALID_INPUT", `path escapes the scratchpad: ${input}`);
|
|
195
|
+
return target;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error instanceof ForgeError)
|
|
199
|
+
throw error;
|
|
200
|
+
// A dangling symlink is not "missing": it could point out and be created through.
|
|
201
|
+
if (!isErrno(error, "ENOENT"))
|
|
202
|
+
throw error;
|
|
203
|
+
const link = await lstat(probe).catch(() => undefined);
|
|
204
|
+
if (link?.isSymbolicLink())
|
|
205
|
+
throw new ForgeError("INVALID_INPUT", `path is a dangling symlink: ${input}`);
|
|
206
|
+
const parent = dirname(probe);
|
|
207
|
+
if (parent === probe)
|
|
208
|
+
throw error;
|
|
209
|
+
probe = parent;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Bytes held by the files of a folder (symlinks are counted as links, never followed). */
|
|
214
|
+
export async function scratchUsage(dir) {
|
|
215
|
+
let total = 0;
|
|
216
|
+
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
217
|
+
const path = join(dir, entry.name);
|
|
218
|
+
if (entry.isDirectory())
|
|
219
|
+
total += await scratchUsage(path);
|
|
220
|
+
else
|
|
221
|
+
total += (await lstat(path).catch(() => undefined))?.size ?? 0;
|
|
222
|
+
}
|
|
223
|
+
return total;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Writes a file into the scratchpad with mode `0600`, refusing paths that escape it and writes
|
|
227
|
+
* that would take the scratchpad past `maxBytes`. Nothing is ever deleted to make room.
|
|
228
|
+
*/
|
|
229
|
+
export async function writeScratchFile(scratchpad, path, data, options) {
|
|
230
|
+
const target = await resolveScratchPath(scratchpad, path);
|
|
231
|
+
const incoming = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
|
|
232
|
+
const replaced = (await lstat(target).catch(() => undefined))?.size ?? 0;
|
|
233
|
+
const used = await scratchUsage(scratchpad);
|
|
234
|
+
if (used - replaced + incoming > options.maxBytes)
|
|
235
|
+
throw new ForgeError("BUDGET_EXCEEDED", `scratchpad is full: ${used} of ${options.maxBytes} bytes used; this write needs ${incoming}. Nothing was deleted.`);
|
|
236
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
237
|
+
await writeFile(target, data, { mode: 0o600 });
|
|
238
|
+
return target;
|
|
239
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const MAX_SLUG_CHARS = 80;
|
|
2
|
+
/** `ALISIO_TMPDIR`, else `forge.scratch.root`, else the system temporary directory. */
|
|
3
|
+
export declare function tmpRootChoice(input: {
|
|
4
|
+
env: string | undefined;
|
|
5
|
+
config: string | null | undefined;
|
|
6
|
+
os: string;
|
|
7
|
+
}): string;
|
|
8
|
+
/** The per-user folder under the temporary root; Windows `%TEMP%` is already per user. */
|
|
9
|
+
export declare function scratchBaseName(platform: NodeJS.Platform, uid: number | undefined): string;
|
|
10
|
+
/**
|
|
11
|
+
* The workspace path with every non `[A-Za-z0-9]` character as `-`. Past 80 characters it is cut
|
|
12
|
+
* and followed by `-` and the first 8 hex characters of the workspace key, so long paths neither
|
|
13
|
+
* collide nor hit path-length limits.
|
|
14
|
+
*/
|
|
15
|
+
export declare function workspaceSlug(workspace: string, workspaceKey: string): string;
|
|
16
|
+
/** Whether `id` can be one folder name without escaping its parent. */
|
|
17
|
+
export declare function isSafeSegment(id: string): boolean;
|
|
18
|
+
export interface DirFacts {
|
|
19
|
+
isDirectory: boolean;
|
|
20
|
+
isSymbolicLink: boolean;
|
|
21
|
+
uid: number;
|
|
22
|
+
mode: number;
|
|
23
|
+
}
|
|
24
|
+
/** Why an existing folder is not private to this user, or undefined when it is. */
|
|
25
|
+
export declare function privateDirProblem(facts: DirFacts, uid: number): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Resolves `input` (relative to `base`, or absolute) and refuses anything that is not strictly
|
|
28
|
+
* inside `base`: `..`, absolute escapes, `base` itself and null bytes. Lexical only; the caller
|
|
29
|
+
* adds the symlink check on the real file system.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveWithin(base: string, input: string): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure rules of the session directory (spec §9.4, D19). No I/O: `session-dir.ts` applies them
|
|
3
|
+
* to the file system.
|
|
4
|
+
*/
|
|
5
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { ForgeError } from "./errors.js";
|
|
7
|
+
export const MAX_SLUG_CHARS = 80;
|
|
8
|
+
const HASH_CHARS = 8;
|
|
9
|
+
/** `ALISIO_TMPDIR`, else `forge.scratch.root`, else the system temporary directory. */
|
|
10
|
+
export function tmpRootChoice(input) {
|
|
11
|
+
if (input.env)
|
|
12
|
+
return input.env;
|
|
13
|
+
if (input.config)
|
|
14
|
+
return input.config;
|
|
15
|
+
return input.os;
|
|
16
|
+
}
|
|
17
|
+
/** The per-user folder under the temporary root; Windows `%TEMP%` is already per user. */
|
|
18
|
+
export function scratchBaseName(platform, uid) {
|
|
19
|
+
return platform === "win32" ? "alisio" : `alisio-${uid}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The workspace path with every non `[A-Za-z0-9]` character as `-`. Past 80 characters it is cut
|
|
23
|
+
* and followed by `-` and the first 8 hex characters of the workspace key, so long paths neither
|
|
24
|
+
* collide nor hit path-length limits.
|
|
25
|
+
*/
|
|
26
|
+
export function workspaceSlug(workspace, workspaceKey) {
|
|
27
|
+
const slug = workspace.replace(/[^A-Za-z0-9]/g, "-");
|
|
28
|
+
if (slug.length <= MAX_SLUG_CHARS)
|
|
29
|
+
return slug;
|
|
30
|
+
return `${slug.slice(0, MAX_SLUG_CHARS)}-${workspaceKey.slice(0, HASH_CHARS)}`;
|
|
31
|
+
}
|
|
32
|
+
/** Whether `id` can be one folder name without escaping its parent. */
|
|
33
|
+
export function isSafeSegment(id) {
|
|
34
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id);
|
|
35
|
+
}
|
|
36
|
+
/** Why an existing folder is not private to this user, or undefined when it is. */
|
|
37
|
+
export function privateDirProblem(facts, uid) {
|
|
38
|
+
if (facts.isSymbolicLink)
|
|
39
|
+
return "it is a symlink";
|
|
40
|
+
if (!facts.isDirectory)
|
|
41
|
+
return "it is not a directory";
|
|
42
|
+
if (facts.uid !== uid)
|
|
43
|
+
return "it belongs to another owner";
|
|
44
|
+
if ((facts.mode & 0o077) !== 0)
|
|
45
|
+
return "it grants permissions to group or other";
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolves `input` (relative to `base`, or absolute) and refuses anything that is not strictly
|
|
50
|
+
* inside `base`: `..`, absolute escapes, `base` itself and null bytes. Lexical only; the caller
|
|
51
|
+
* adds the symlink check on the real file system.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveWithin(base, input) {
|
|
54
|
+
if (input.length === 0 || input.includes("\0"))
|
|
55
|
+
throw new ForgeError("INVALID_INPUT", "path must be a non-empty string without null bytes");
|
|
56
|
+
const target = resolve(base, input);
|
|
57
|
+
const rel = relative(base, target);
|
|
58
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
59
|
+
throw new ForgeError("INVALID_INPUT", `path is outside the scratchpad: ${input}`);
|
|
60
|
+
return target;
|
|
61
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface SlotLimits {
|
|
2
|
+
perSession: number;
|
|
3
|
+
perProcess: number;
|
|
4
|
+
}
|
|
5
|
+
export declare const DEFAULT_SLOT_LIMITS: Readonly<SlotLimits>;
|
|
6
|
+
export declare class SlotTable {
|
|
7
|
+
#private;
|
|
8
|
+
constructor(limits?: SlotLimits);
|
|
9
|
+
get active(): number;
|
|
10
|
+
activeIn(session: string): number;
|
|
11
|
+
/**
|
|
12
|
+
* Takes a slot or throws `BUDGET_EXCEEDED` with the reason. The returned release is idempotent,
|
|
13
|
+
* so a cleanup path can call it more than once without freeing a slot twice.
|
|
14
|
+
*/
|
|
15
|
+
acquire(session: string): () => void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency slots of the JavaScript engine (spec D-29, §6.5): one active execution per SESSION
|
|
3
|
+
* and a few per process. The session (not the root session) is the unit because a subagent's
|
|
4
|
+
* `js_run` must not wait on its parent's. Pure bookkeeping: no I/O, no timers.
|
|
5
|
+
*/
|
|
6
|
+
import { ForgeError } from "./errors.js";
|
|
7
|
+
export const DEFAULT_SLOT_LIMITS = { perSession: 1, perProcess: 4 };
|
|
8
|
+
export class SlotTable {
|
|
9
|
+
#limits;
|
|
10
|
+
#perSession = new Map();
|
|
11
|
+
#total = 0;
|
|
12
|
+
constructor(limits = DEFAULT_SLOT_LIMITS) {
|
|
13
|
+
this.#limits = limits;
|
|
14
|
+
}
|
|
15
|
+
get active() {
|
|
16
|
+
return this.#total;
|
|
17
|
+
}
|
|
18
|
+
activeIn(session) {
|
|
19
|
+
return this.#perSession.get(session) ?? 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Takes a slot or throws `BUDGET_EXCEEDED` with the reason. The returned release is idempotent,
|
|
23
|
+
* so a cleanup path can call it more than once without freeing a slot twice.
|
|
24
|
+
*/
|
|
25
|
+
acquire(session) {
|
|
26
|
+
if (this.activeIn(session) >= this.#limits.perSession) {
|
|
27
|
+
throw new ForgeError("BUDGET_EXCEEDED", "Another js_run is still running in this session; wait for it to finish.");
|
|
28
|
+
}
|
|
29
|
+
if (this.#total >= this.#limits.perProcess) {
|
|
30
|
+
throw new ForgeError("BUDGET_EXCEEDED", `Too many js_run executions are active in this process (${this.#limits.perProcess}); try again shortly.`);
|
|
31
|
+
}
|
|
32
|
+
this.#perSession.set(session, this.activeIn(session) + 1);
|
|
33
|
+
this.#total++;
|
|
34
|
+
let released = false;
|
|
35
|
+
return () => {
|
|
36
|
+
if (released)
|
|
37
|
+
return;
|
|
38
|
+
released = true;
|
|
39
|
+
const next = this.activeIn(session) - 1;
|
|
40
|
+
if (next <= 0)
|
|
41
|
+
this.#perSession.delete(session);
|
|
42
|
+
else
|
|
43
|
+
this.#perSession.set(session, next);
|
|
44
|
+
this.#total--;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const MAX_STATE_KEY_CHARS = 128;
|
|
2
|
+
export declare const MAX_STATE_VALUE_BYTES: number;
|
|
3
|
+
export declare const DEFAULT_STATE_TOTAL_BYTES: number;
|
|
4
|
+
/** What `start.state` carries (values) plus the revisions the host keeps to compare later. */
|
|
5
|
+
export interface StateSnapshot {
|
|
6
|
+
values: Record<string, unknown>;
|
|
7
|
+
/** Includes tombstoned keys; a key absent here was never written (revision 0). */
|
|
8
|
+
revisions: Record<string, number>;
|
|
9
|
+
}
|
|
10
|
+
/** A stored row. An empty `valueJson` is a tombstone. */
|
|
11
|
+
export interface StateEntry {
|
|
12
|
+
valueJson: string;
|
|
13
|
+
revision: number;
|
|
14
|
+
}
|
|
15
|
+
export type StateOp = {
|
|
16
|
+
op: "set";
|
|
17
|
+
key: string;
|
|
18
|
+
value: unknown;
|
|
19
|
+
} | {
|
|
20
|
+
op: "delete";
|
|
21
|
+
key: string;
|
|
22
|
+
};
|
|
23
|
+
export interface StateWrite {
|
|
24
|
+
key: string;
|
|
25
|
+
/** Empty for a tombstone. */
|
|
26
|
+
valueJson: string;
|
|
27
|
+
revision: number;
|
|
28
|
+
}
|
|
29
|
+
/** The local copy a running script reads and writes. Writes accumulate; nothing is persisted. */
|
|
30
|
+
export declare class StateDelta {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(snapshot: StateSnapshot);
|
|
33
|
+
get(key: string): unknown;
|
|
34
|
+
set(key: string, value: unknown): void;
|
|
35
|
+
delete(key: string): void;
|
|
36
|
+
/** The last operation per key, in the order each key was first touched. */
|
|
37
|
+
ops(): StateOp[];
|
|
38
|
+
}
|
|
39
|
+
export interface CommitPlan {
|
|
40
|
+
writes: StateWrite[];
|
|
41
|
+
/** Live bytes (keys plus values) of the root session after the writes. */
|
|
42
|
+
totalBytes: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decides what a delta writes. Throws `STATE_CONFLICT` when a touched key changed since the
|
|
46
|
+
* snapshot (nothing is applied, nothing retried) and `BUDGET_EXCEEDED` when the live total would
|
|
47
|
+
* pass `maxTotalBytes`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function planCommit(input: {
|
|
50
|
+
current: ReadonlyMap<string, StateEntry>;
|
|
51
|
+
snapshot: StateSnapshot;
|
|
52
|
+
ops: readonly StateOp[];
|
|
53
|
+
maxTotalBytes: number;
|
|
54
|
+
}): CommitPlan;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure rules of the state shared across executions (spec §9.2, D11): a script gets a snapshot,
|
|
3
|
+
* its writes accumulate into a delta, and the delta is applied only if every key it touches still
|
|
4
|
+
* has the revision the script saw. No I/O: the SQLite adapter lives in `state-store.ts`.
|
|
5
|
+
*
|
|
6
|
+
* A delete leaves a tombstone (empty `valueJson`) that keeps counting revisions, so a key that is
|
|
7
|
+
* deleted and re-created by another execution never repeats a revision a stale snapshot holds.
|
|
8
|
+
*/
|
|
9
|
+
import { ForgeError } from "./errors.js";
|
|
10
|
+
export const MAX_STATE_KEY_CHARS = 128;
|
|
11
|
+
export const MAX_STATE_VALUE_BYTES = 64 * 1024;
|
|
12
|
+
export const DEFAULT_STATE_TOTAL_BYTES = 512 * 1024;
|
|
13
|
+
const bytes = (text) => Buffer.byteLength(text, "utf8");
|
|
14
|
+
function checkKey(key) {
|
|
15
|
+
if (key.length === 0 || key.length > MAX_STATE_KEY_CHARS)
|
|
16
|
+
throw new ForgeError("INVALID_INPUT", `state key must be 1 to ${MAX_STATE_KEY_CHARS} characters`);
|
|
17
|
+
}
|
|
18
|
+
/** The JSON of a value, or INVALID_INPUT when it is not JSON or is over the per-value limit. */
|
|
19
|
+
function encodeValue(key, value) {
|
|
20
|
+
let json;
|
|
21
|
+
try {
|
|
22
|
+
json = JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
json = undefined;
|
|
26
|
+
}
|
|
27
|
+
if (json === undefined)
|
|
28
|
+
throw new ForgeError("INVALID_INPUT", `state value for "${key}" is not JSON-serializable`);
|
|
29
|
+
if (bytes(json) > MAX_STATE_VALUE_BYTES)
|
|
30
|
+
throw new ForgeError("INVALID_INPUT", `state value for "${key}" exceeds ${MAX_STATE_VALUE_BYTES} bytes`);
|
|
31
|
+
return json;
|
|
32
|
+
}
|
|
33
|
+
/** The local copy a running script reads and writes. Writes accumulate; nothing is persisted. */
|
|
34
|
+
export class StateDelta {
|
|
35
|
+
#values;
|
|
36
|
+
#ops = new Map();
|
|
37
|
+
constructor(snapshot) {
|
|
38
|
+
this.#values = new Map(Object.entries(snapshot.values));
|
|
39
|
+
}
|
|
40
|
+
get(key) {
|
|
41
|
+
return this.#values.get(key);
|
|
42
|
+
}
|
|
43
|
+
set(key, value) {
|
|
44
|
+
checkKey(key);
|
|
45
|
+
const json = encodeValue(key, value);
|
|
46
|
+
const stored = JSON.parse(json);
|
|
47
|
+
this.#values.set(key, stored);
|
|
48
|
+
this.#record({ op: "set", key, value: stored });
|
|
49
|
+
}
|
|
50
|
+
delete(key) {
|
|
51
|
+
checkKey(key);
|
|
52
|
+
this.#values.delete(key);
|
|
53
|
+
this.#record({ op: "delete", key });
|
|
54
|
+
}
|
|
55
|
+
/** The last operation per key, in the order each key was first touched. */
|
|
56
|
+
ops() {
|
|
57
|
+
return [...this.#ops.values()];
|
|
58
|
+
}
|
|
59
|
+
#record(op) {
|
|
60
|
+
this.#ops.set(op.key, op);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Decides what a delta writes. Throws `STATE_CONFLICT` when a touched key changed since the
|
|
65
|
+
* snapshot (nothing is applied, nothing retried) and `BUDGET_EXCEEDED` when the live total would
|
|
66
|
+
* pass `maxTotalBytes`.
|
|
67
|
+
*/
|
|
68
|
+
export function planCommit(input) {
|
|
69
|
+
const writes = [];
|
|
70
|
+
const next = new Map(input.current);
|
|
71
|
+
for (const op of input.ops) {
|
|
72
|
+
const existing = input.current.get(op.key);
|
|
73
|
+
const seen = input.snapshot.revisions[op.key] ?? 0;
|
|
74
|
+
if (seen !== (existing?.revision ?? 0))
|
|
75
|
+
throw new ForgeError("STATE_CONFLICT", `state key "${op.key}" changed since this execution started; nothing was applied`);
|
|
76
|
+
if (op.op === "delete") {
|
|
77
|
+
if (!existing || existing.valueJson === "")
|
|
78
|
+
continue;
|
|
79
|
+
const write = { key: op.key, valueJson: "", revision: existing.revision + 1 };
|
|
80
|
+
writes.push(write);
|
|
81
|
+
next.set(op.key, { valueJson: write.valueJson, revision: write.revision });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const write = {
|
|
85
|
+
key: op.key,
|
|
86
|
+
valueJson: encodeValue(op.key, op.value),
|
|
87
|
+
revision: (existing?.revision ?? 0) + 1,
|
|
88
|
+
};
|
|
89
|
+
writes.push(write);
|
|
90
|
+
next.set(op.key, { valueJson: write.valueJson, revision: write.revision });
|
|
91
|
+
}
|
|
92
|
+
let totalBytes = 0;
|
|
93
|
+
for (const [key, entry] of next)
|
|
94
|
+
if (entry.valueJson !== "")
|
|
95
|
+
totalBytes += bytes(key) + bytes(entry.valueJson);
|
|
96
|
+
if (totalBytes > input.maxTotalBytes)
|
|
97
|
+
throw new ForgeError("BUDGET_EXCEEDED", `state would use ${totalBytes} bytes; the limit per session is ${input.maxTotalBytes}`);
|
|
98
|
+
return { writes, totalBytes };
|
|
99
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Forja execution lifecycle (spec §5.2). Pure: no I/O.
|
|
3
|
+
*
|
|
4
|
+
* `cancelling` and `timed_out_pending` exist because a terminal state is only reached once the
|
|
5
|
+
* host has confirmed the guest stopped; a terminal state is reached exactly once.
|
|
6
|
+
*/
|
|
7
|
+
export type ExecutionState = "validating" | "preparing" | "running" | "cancelling" | "timed_out_pending" | "collecting" | "succeeded" | "failed" | "cancelled" | "timed_out";
|
|
8
|
+
export declare class InvalidTransitionError extends Error {
|
|
9
|
+
readonly from: ExecutionState;
|
|
10
|
+
readonly to: ExecutionState;
|
|
11
|
+
constructor(from: ExecutionState, to: ExecutionState);
|
|
12
|
+
}
|
|
13
|
+
export declare function canTransition(from: ExecutionState, to: ExecutionState): boolean;
|
|
14
|
+
export declare class ExecutionStateMachine {
|
|
15
|
+
#private;
|
|
16
|
+
get state(): ExecutionState;
|
|
17
|
+
get isTerminal(): boolean;
|
|
18
|
+
/** Moves to `to`, or throws without changing state when the diagram does not allow it. */
|
|
19
|
+
transition(to: ExecutionState): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const TRANSITIONS = {
|
|
2
|
+
validating: ["preparing", "failed"],
|
|
3
|
+
preparing: ["running", "failed", "cancelled"],
|
|
4
|
+
running: ["collecting", "failed", "cancelling", "timed_out_pending"],
|
|
5
|
+
cancelling: ["cancelled"],
|
|
6
|
+
timed_out_pending: ["timed_out"],
|
|
7
|
+
collecting: ["succeeded", "failed"],
|
|
8
|
+
succeeded: [],
|
|
9
|
+
failed: [],
|
|
10
|
+
cancelled: [],
|
|
11
|
+
timed_out: [],
|
|
12
|
+
};
|
|
13
|
+
export class InvalidTransitionError extends Error {
|
|
14
|
+
from;
|
|
15
|
+
to;
|
|
16
|
+
constructor(from, to) {
|
|
17
|
+
super(`Invalid execution transition: ${from} -> ${to}`);
|
|
18
|
+
this.from = from;
|
|
19
|
+
this.to = to;
|
|
20
|
+
this.name = "InvalidTransitionError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function canTransition(from, to) {
|
|
24
|
+
return TRANSITIONS[from].includes(to);
|
|
25
|
+
}
|
|
26
|
+
export class ExecutionStateMachine {
|
|
27
|
+
#state = "validating";
|
|
28
|
+
get state() {
|
|
29
|
+
return this.#state;
|
|
30
|
+
}
|
|
31
|
+
get isTerminal() {
|
|
32
|
+
return TRANSITIONS[this.#state].length === 0;
|
|
33
|
+
}
|
|
34
|
+
/** Moves to `to`, or throws without changing state when the diagram does not allow it. */
|
|
35
|
+
transition(to) {
|
|
36
|
+
if (!canTransition(this.#state, to))
|
|
37
|
+
throw new InvalidTransitionError(this.#state, to);
|
|
38
|
+
this.#state = to;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQLite adapter of the cross-execution state (spec §9.2). The rules (limits, revisions,
|
|
3
|
+
* conflicts) are the pure functions of `state-delta.ts`; this file only reads the rows and applies
|
|
4
|
+
* the plan in one immediate transaction, so a conflicting delta writes nothing.
|
|
5
|
+
*
|
|
6
|
+
* The host takes `snapshot()` when an execution starts, feeds the script's `state.set` and
|
|
7
|
+
* `state.delete` into a `StateDelta`, and calls `commit()` only when the execution succeeds. A
|
|
8
|
+
* failed execution simply never commits: its delta is dropped (FJ-AC-17).
|
|
9
|
+
*/
|
|
10
|
+
import type { SqlDatabase } from "@alisio/sdk";
|
|
11
|
+
import { type StateOp, type StateSnapshot } from "./state-delta.ts";
|
|
12
|
+
export declare class SqliteStateStore {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(db: SqlDatabase, options?: {
|
|
15
|
+
maxTotalBytes?: number | (() => number);
|
|
16
|
+
now?: () => number;
|
|
17
|
+
});
|
|
18
|
+
/** Live values and every revision (tombstones included) of a root session. */
|
|
19
|
+
snapshot(rootSessionId: string): StateSnapshot;
|
|
20
|
+
/**
|
|
21
|
+
* Applies a delta against the snapshot it was built from. Throws `STATE_CONFLICT` or
|
|
22
|
+
* `BUDGET_EXCEEDED` without writing anything. Returns the number of rows written.
|
|
23
|
+
*/
|
|
24
|
+
commit(rootSessionId: string, snapshot: StateSnapshot, ops: readonly StateOp[]): number;
|
|
25
|
+
}
|