@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,214 @@
|
|
|
1
|
+
import { OPT_IN_TOOLS, optInAllows } from "../core/opt-in.js";
|
|
2
|
+
import { decideEffect, effectOf } from "../permissions/decide.js";
|
|
3
|
+
import { isForgeError } from "./errors.js";
|
|
4
|
+
import { forgeCallId } from "./journal.js";
|
|
5
|
+
import { hasScratchEstimator, scratchIncomingBytes } from "./scratch-quota.js";
|
|
6
|
+
/**
|
|
7
|
+
* Tools no script can reach in v1: Forja's own tools and `execute` (no recursion), the guide
|
|
8
|
+
* (a model-facing tool), and the subagent tools (`task`, `task_wait` and `send_message` can wait
|
|
9
|
+
* on a child session that is waiting for this very call: D-29), and `ask_user_question` (the only
|
|
10
|
+
* tool that opens an interactive prompt; approvals and installs are already refused). Opt-in tools (`OPT_IN_TOOLS`) are
|
|
11
|
+
* excluded as well.
|
|
12
|
+
*/
|
|
13
|
+
export const FORGE_EXCLUDED_TOOLS = new Set([
|
|
14
|
+
"js_run",
|
|
15
|
+
"forge_guide",
|
|
16
|
+
"task",
|
|
17
|
+
"task_wait",
|
|
18
|
+
"send_message",
|
|
19
|
+
"execute",
|
|
20
|
+
// Opens an interactive prompt; a script has nobody to ask (D7: never a new approval or UI).
|
|
21
|
+
"ask_user_question",
|
|
22
|
+
]);
|
|
23
|
+
const isPlainObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
25
|
+
export function createNestedDispatcher(deps) {
|
|
26
|
+
let counter = 0;
|
|
27
|
+
/** The tool when this run lets a script reach it, else undefined (excluded, filtered, absent). */
|
|
28
|
+
const reachable = (name) => {
|
|
29
|
+
if (FORGE_EXCLUDED_TOOLS.has(name) || OPT_IN_TOOLS.has(name))
|
|
30
|
+
return undefined;
|
|
31
|
+
let tool;
|
|
32
|
+
try {
|
|
33
|
+
tool = deps.registry.get(name);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (deps.toolFilter && !deps.toolFilter(tool))
|
|
39
|
+
return undefined;
|
|
40
|
+
if (!optInAllows(name, deps.optInTools))
|
|
41
|
+
return undefined;
|
|
42
|
+
return tool;
|
|
43
|
+
};
|
|
44
|
+
const decide = (tool, approvals) => decideEffect(deps.policy, tool, { approvals, capabilityGranted: false });
|
|
45
|
+
const allInScratch = async (targets) => {
|
|
46
|
+
const access = deps.pathAccess;
|
|
47
|
+
if (!access || targets.length === 0)
|
|
48
|
+
return false;
|
|
49
|
+
const inside = await Promise.all(targets.map((t) => access.isScratchPath(t, deps.session)));
|
|
50
|
+
return inside.every(Boolean);
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
knows(name) {
|
|
54
|
+
try {
|
|
55
|
+
deps.registry.get(name);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
catalog() {
|
|
63
|
+
return deps.registry
|
|
64
|
+
.list()
|
|
65
|
+
.filter((tool) => reachable(tool.name) && decide(tool, false).kind === "allow");
|
|
66
|
+
},
|
|
67
|
+
async call(request) {
|
|
68
|
+
const nestedCallId = forgeCallId(deps.parentCallId, ++counter);
|
|
69
|
+
const fail = (code, message, effect) => ({
|
|
70
|
+
ok: false,
|
|
71
|
+
nestedCallId,
|
|
72
|
+
code,
|
|
73
|
+
message,
|
|
74
|
+
...(effect ? { effect } : {}),
|
|
75
|
+
});
|
|
76
|
+
const name = request.name;
|
|
77
|
+
if (typeof name !== "string" || name === "")
|
|
78
|
+
return fail("TOOL_UNAVAILABLE", "tool name must be a non-empty string");
|
|
79
|
+
const tool = reachable(name);
|
|
80
|
+
if (!tool)
|
|
81
|
+
return fail("TOOL_UNAVAILABLE", `Tool "${name}" is not available from js_run`);
|
|
82
|
+
const effect = effectOf(tool);
|
|
83
|
+
const rawInput = request.input === undefined ? {} : request.input;
|
|
84
|
+
if (!isPlainObject(rawInput))
|
|
85
|
+
return fail("TOOL_INPUT_INVALID", "tool input must be a JSON object", effect);
|
|
86
|
+
let input;
|
|
87
|
+
try {
|
|
88
|
+
input = deps.registry.parse(name, JSON.stringify(rawInput));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return fail("TOOL_INPUT_INVALID", messageOf(error), effect);
|
|
92
|
+
}
|
|
93
|
+
const signal = request.signal ? AbortSignal.any([deps.signal, request.signal]) : deps.signal;
|
|
94
|
+
// Declared paths are resolved first, like a direct call, but can never ask for a directory.
|
|
95
|
+
const resolved = new Map();
|
|
96
|
+
const targets = [];
|
|
97
|
+
try {
|
|
98
|
+
signal.throwIfAborted();
|
|
99
|
+
if (deps.pathAccess)
|
|
100
|
+
for (const declared of tool.paths?.(input) ?? [])
|
|
101
|
+
if (!resolved.has(declared)) {
|
|
102
|
+
const target = await deps.pathAccess.resolve(declared, {
|
|
103
|
+
workspace: deps.workspace,
|
|
104
|
+
session: deps.session,
|
|
105
|
+
...(deps.label ? { label: deps.label } : {}),
|
|
106
|
+
signal,
|
|
107
|
+
interactive: false,
|
|
108
|
+
});
|
|
109
|
+
resolved.set(declared, target);
|
|
110
|
+
resolved.set(target, target);
|
|
111
|
+
targets.push(target);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
return fail("CANCELLED", "cancelled", effect);
|
|
117
|
+
return fail("PERMISSION_DENIED", messageOf(error), effect);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
deps.guard?.(effect);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return fail("PERMISSION_DENIED", messageOf(error), effect);
|
|
124
|
+
}
|
|
125
|
+
if (decide(tool, false).kind !== "allow") {
|
|
126
|
+
// Same answer as a direct call: "would ask" is APPROVAL_REQUIRED, "never allowed" is denied.
|
|
127
|
+
const direct = decide(tool, deps.approvals);
|
|
128
|
+
// The scratchpad is an authorized write root: a write confined to it needs no approval,
|
|
129
|
+
// also when nobody could be asked (headless).
|
|
130
|
+
const scratchWrite = deps.scratchWrites !== false &&
|
|
131
|
+
effect === "write" &&
|
|
132
|
+
!tool.capability &&
|
|
133
|
+
hasScratchEstimator(name) &&
|
|
134
|
+
decide(tool, true).kind === "ask" &&
|
|
135
|
+
(await allInScratch(targets));
|
|
136
|
+
if (!scratchWrite)
|
|
137
|
+
return direct.kind === "ask"
|
|
138
|
+
? fail("APPROVAL_REQUIRED", `"${name}" needs approval (effect: ${effect}); a script cannot request one. ` +
|
|
139
|
+
"Call it directly or ask the user to allow it for this session.", effect)
|
|
140
|
+
: fail("PERMISSION_DENIED", `Capability denied: ${tool.capability ?? effect}`, effect);
|
|
141
|
+
}
|
|
142
|
+
// A write confined to the scratchpad is bound by its quota, however it was allowed. The check
|
|
143
|
+
// and the write run under one lock, so parallel writes cannot all pass it (L1).
|
|
144
|
+
let releaseScratch;
|
|
145
|
+
try {
|
|
146
|
+
if (effect === "write" && !tool.capability && (await allInScratch(targets))) {
|
|
147
|
+
releaseScratch = await deps.pathAccess?.lockScratch(deps.session);
|
|
148
|
+
const problem = await deps.pathAccess?.scratchQuotaProblem(deps.session, await scratchIncomingBytes(name, input, targets), targets);
|
|
149
|
+
if (problem)
|
|
150
|
+
return fail("BUDGET_EXCEEDED", problem, effect);
|
|
151
|
+
}
|
|
152
|
+
// From here the tool may leave effects, even if the script is then cancelled.
|
|
153
|
+
if (effect !== "read" && effect !== "internal")
|
|
154
|
+
deps.markEffectful();
|
|
155
|
+
request.onStart?.({ nestedCallId, name, effect, paths: targets });
|
|
156
|
+
const context = {
|
|
157
|
+
signal,
|
|
158
|
+
workspace: deps.workspace,
|
|
159
|
+
emit: () => { },
|
|
160
|
+
session: deps.session,
|
|
161
|
+
callId: deps.parentCallId,
|
|
162
|
+
...(deps.runId ? { runId: deps.runId } : {}),
|
|
163
|
+
...(deps.label ? { label: deps.label } : {}),
|
|
164
|
+
...(deps.artifacts ? { artifacts: deps.artifacts } : {}),
|
|
165
|
+
...(deps.decisions ? { decisions: deps.decisions } : {}),
|
|
166
|
+
// A script can never open the installation prompt.
|
|
167
|
+
approveInstall: async () => "deny",
|
|
168
|
+
...(deps.pathAccess
|
|
169
|
+
? {
|
|
170
|
+
resolvePath: (path) => {
|
|
171
|
+
const known = resolved.get(path);
|
|
172
|
+
return known !== undefined
|
|
173
|
+
? Promise.resolve(known)
|
|
174
|
+
: deps.pathAccess.resolve(path, {
|
|
175
|
+
workspace: deps.workspace,
|
|
176
|
+
session: deps.session,
|
|
177
|
+
...(deps.label ? { label: deps.label } : {}),
|
|
178
|
+
signal,
|
|
179
|
+
interactive: false,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
: {}),
|
|
184
|
+
};
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await tool.execute(input, context);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (signal.aborted)
|
|
191
|
+
return fail("CANCELLED", "cancelled", effect);
|
|
192
|
+
return fail(isForgeError(error) ? error.code : "TOOL_FAILED", messageOf(error), effect);
|
|
193
|
+
}
|
|
194
|
+
const text = result.content
|
|
195
|
+
.filter((part) => part.type === "text")
|
|
196
|
+
.map((part) => part.text)
|
|
197
|
+
.join("\n");
|
|
198
|
+
if (result.isError)
|
|
199
|
+
return fail("TOOL_FAILED", text, effect);
|
|
200
|
+
let value = text;
|
|
201
|
+
try {
|
|
202
|
+
value = JSON.parse(text);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* Not JSON: the text itself is the value. */
|
|
206
|
+
}
|
|
207
|
+
return { ok: true, nestedCallId, effect, text, value, result };
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
releaseScratch?.();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ports of the Forja core (hexagonal). Engines, hosts, transports and stores are adapters; the
|
|
3
|
+
* core never depends on QuickJS, Pyodide, Docker or SQLite. Types only: no runtime code.
|
|
4
|
+
*
|
|
5
|
+
* Later phases add the remaining ports (`IsolateHost`, `Transport`, `ExecutionJournal`,
|
|
6
|
+
* `StateStore`, `GuideProvider`) next to these.
|
|
7
|
+
*/
|
|
8
|
+
import type { EngineDescriptor, EngineId, EngineLanguage, IsolationLevel, PermissionMode } from "@alisio/sdk";
|
|
9
|
+
import type { ForgeErrorCode } from "./errors.ts";
|
|
10
|
+
import type { ForgeLimits } from "./limits.ts";
|
|
11
|
+
import type { GuestMessage, HostMessage } from "./protocol.ts";
|
|
12
|
+
export type { EngineDescriptor, EngineId, EngineLanguage, IsolationLevel };
|
|
13
|
+
export interface ExecutionRequest {
|
|
14
|
+
language: EngineLanguage;
|
|
15
|
+
source: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
/** Can only lower the effective limits. */
|
|
18
|
+
requestedLimits?: Partial<ForgeLimits>;
|
|
19
|
+
/** Configured or forced by the user; never chosen by the model. */
|
|
20
|
+
engineHint?: EngineId;
|
|
21
|
+
/** Per-engine extensions (inputs, extras, rerunOf...). */
|
|
22
|
+
payload?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Built only by the core from the runner's tool context. No field of the model's input can
|
|
26
|
+
* override it.
|
|
27
|
+
*/
|
|
28
|
+
export interface TrustedExecutionContext {
|
|
29
|
+
executionId: string;
|
|
30
|
+
toolCallId: string;
|
|
31
|
+
rootSessionId: string;
|
|
32
|
+
workspace: string;
|
|
33
|
+
permissionMode: PermissionMode;
|
|
34
|
+
allowAnalysis: boolean;
|
|
35
|
+
allowProcess: boolean;
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
limits: ForgeLimits;
|
|
38
|
+
}
|
|
39
|
+
/** An execution ready to run; engines extend it with their own handles. */
|
|
40
|
+
export interface PreparedExecution {
|
|
41
|
+
readonly executionId: string;
|
|
42
|
+
readonly engine: EngineId;
|
|
43
|
+
readonly limits: ForgeLimits;
|
|
44
|
+
/** The `analysis_executions` row this execution created (Python); the journal links to it. */
|
|
45
|
+
readonly analysisExecutionId?: string;
|
|
46
|
+
}
|
|
47
|
+
export type TerminationReason = "cancelled" | "timeout" | "protocol_violation" | "limit_exceeded";
|
|
48
|
+
export interface TerminationReport {
|
|
49
|
+
/** The host checked that the guest no longer runs. */
|
|
50
|
+
confirmed: boolean;
|
|
51
|
+
method: "interrupt" | "terminate" | "kill" | "container-kill";
|
|
52
|
+
elapsedMs: number;
|
|
53
|
+
/** Resolves once the guest is finally gone; lets an unconfirmed termination keep its slot until then. */
|
|
54
|
+
settled?: Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export type EngineOutcome = {
|
|
57
|
+
kind: "completed";
|
|
58
|
+
value?: unknown;
|
|
59
|
+
} | {
|
|
60
|
+
kind: "failed";
|
|
61
|
+
code: ForgeErrorCode;
|
|
62
|
+
message: string;
|
|
63
|
+
};
|
|
64
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
65
|
+
/** A message for the guest without the envelope fields the transport stamps (`protocol`, `seq`). */
|
|
66
|
+
export type HostReply = DistributiveOmit<HostMessage, "protocol" | "seq">;
|
|
67
|
+
/**
|
|
68
|
+
* The kernel's side of an execution, handed to the engine's `run`. The engine owns the transport
|
|
69
|
+
* and the isolate; the kernel decides what each guest message means.
|
|
70
|
+
*/
|
|
71
|
+
export interface KernelChannel {
|
|
72
|
+
/**
|
|
73
|
+
* Called by the engine for every guest message that passed validation (schema, sequence,
|
|
74
|
+
* correlation, size, capability gate), CONCURRENTLY: a slow `call` never blocks the next
|
|
75
|
+
* message. The optional reply goes to the guest. Throwing a `ForgeError` ends the execution with
|
|
76
|
+
* that code.
|
|
77
|
+
*/
|
|
78
|
+
receive(message: GuestMessage): Promise<HostReply | undefined>;
|
|
79
|
+
/** Called once by the engine with the function that sends a message to the guest (credit grants). */
|
|
80
|
+
bind(send: (reply: HostReply) => void): void;
|
|
81
|
+
/** Aborted when the kernel stops the execution (cancellation or deadline). */
|
|
82
|
+
readonly signal: AbortSignal;
|
|
83
|
+
}
|
|
84
|
+
/** Isolation hosts (spec §6.3, D-20): where the VM runs, and how it is stopped. */
|
|
85
|
+
export interface IsolateExit {
|
|
86
|
+
code: number | null;
|
|
87
|
+
signal: string | null;
|
|
88
|
+
/** Spawn or worker error, when the isolate could not run at all. */
|
|
89
|
+
error?: string;
|
|
90
|
+
}
|
|
91
|
+
export interface IsolateSession {
|
|
92
|
+
readonly kind: "worker" | "process";
|
|
93
|
+
/** Raw guest messages; validation is the engine's job. */
|
|
94
|
+
onMessage(handler: (raw: unknown) => void): void;
|
|
95
|
+
/** The stream broke (oversized or malformed frame): a protocol violation. */
|
|
96
|
+
onStreamError(handler: (reason: string) => void): void;
|
|
97
|
+
send(message: unknown): void;
|
|
98
|
+
readonly exited: Promise<IsolateExit>;
|
|
99
|
+
/** Stops the guest by the host's own means and reports whether it is confirmed gone. */
|
|
100
|
+
stop(reason: TerminationReason): Promise<TerminationReport>;
|
|
101
|
+
}
|
|
102
|
+
export interface IsolateHost {
|
|
103
|
+
readonly kind: "worker" | "process";
|
|
104
|
+
/** Starts an isolate. The guest sends `ready` first; nothing runs until the host sends `start`. */
|
|
105
|
+
start(): IsolateSession;
|
|
106
|
+
}
|
|
107
|
+
export interface ExecutionEngine {
|
|
108
|
+
readonly id: EngineId;
|
|
109
|
+
describe(): Promise<EngineDescriptor>;
|
|
110
|
+
prepare(request: ExecutionRequest, ctx: TrustedExecutionContext): Promise<PreparedExecution>;
|
|
111
|
+
run(prepared: PreparedExecution, channel: KernelChannel): Promise<EngineOutcome>;
|
|
112
|
+
terminate(prepared: PreparedExecution, reason: TerminationReason): Promise<TerminationReport>;
|
|
113
|
+
/**
|
|
114
|
+
* Optional step between `run` and the commit of the shared state, for engines whose results must
|
|
115
|
+
* be published first (Python's artifacts). It runs only when the host did not stop the execution,
|
|
116
|
+
* and may turn a completed outcome into a failed one: a failure discards the state delta.
|
|
117
|
+
*/
|
|
118
|
+
collect?(prepared: PreparedExecution, outcome: EngineOutcome): Promise<EngineOutcome>;
|
|
119
|
+
dispose(prepared: PreparedExecution): Promise<void>;
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress coalescing (spec §5.4, D14): at most 4 progress events per second reach the UI, but a
|
|
3
|
+
* terminal event (a finished call, the end of a phase) is never dropped. Non-terminal events that
|
|
4
|
+
* arrive too soon are replaced by the newest one of their category (script progress, a call
|
|
5
|
+
* starting, anything else) and delivered when that category's window reopens, so a burst of
|
|
6
|
+
* calls never hides the script's own progress. Pure but for the injected clock and timer.
|
|
7
|
+
*/
|
|
8
|
+
import type { ForgeProgress } from "@alisio/sdk";
|
|
9
|
+
export declare const PROGRESS_INTERVAL_MS = 250;
|
|
10
|
+
export interface ProgressClock {
|
|
11
|
+
now(): number;
|
|
12
|
+
/** Schedules `run` after `ms` and returns how to cancel it. */
|
|
13
|
+
schedule(run: () => void, ms: number): () => void;
|
|
14
|
+
}
|
|
15
|
+
export declare class ProgressCoalescer {
|
|
16
|
+
#private;
|
|
17
|
+
constructor(sink: (progress: ForgeProgress) => void, clock?: ProgressClock);
|
|
18
|
+
/** A progress event that may be replaced by a newer one of its category. */
|
|
19
|
+
update(progress: ForgeProgress): void;
|
|
20
|
+
/** An event that must reach the UI: delivered now, after anything still waiting. */
|
|
21
|
+
terminal(progress: ForgeProgress): void;
|
|
22
|
+
/** Delivers what is waiting and stops the timers. */
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const PROGRESS_INTERVAL_MS = 250;
|
|
2
|
+
const realClock = {
|
|
3
|
+
now: () => Date.now(),
|
|
4
|
+
schedule(run, ms) {
|
|
5
|
+
const timer = setTimeout(run, ms);
|
|
6
|
+
timer.unref?.();
|
|
7
|
+
return () => clearTimeout(timer);
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
const categoryOf = (progress) => progress.phase === "call"
|
|
11
|
+
? "call"
|
|
12
|
+
: progress.message !== undefined ||
|
|
13
|
+
progress.completed !== undefined ||
|
|
14
|
+
progress.total !== undefined
|
|
15
|
+
? "progress"
|
|
16
|
+
: "other";
|
|
17
|
+
export class ProgressCoalescer {
|
|
18
|
+
#sink;
|
|
19
|
+
#clock;
|
|
20
|
+
#lanes = new Map();
|
|
21
|
+
#order = 0;
|
|
22
|
+
constructor(sink, clock = realClock) {
|
|
23
|
+
this.#sink = sink;
|
|
24
|
+
this.#clock = clock;
|
|
25
|
+
}
|
|
26
|
+
/** A progress event that may be replaced by a newer one of its category. */
|
|
27
|
+
update(progress) {
|
|
28
|
+
const category = categoryOf(progress);
|
|
29
|
+
let lane = this.#lanes.get(category);
|
|
30
|
+
if (!lane) {
|
|
31
|
+
lane = { lastAt: Number.NEGATIVE_INFINITY };
|
|
32
|
+
this.#lanes.set(category, lane);
|
|
33
|
+
}
|
|
34
|
+
const wait = lane.lastAt + PROGRESS_INTERVAL_MS - this.#clock.now();
|
|
35
|
+
if (wait <= 0 && lane.pending === undefined)
|
|
36
|
+
return this.#deliver(lane, progress);
|
|
37
|
+
lane.pending = { progress, order: this.#order++ };
|
|
38
|
+
const owner = lane;
|
|
39
|
+
lane.cancel ??= this.#clock.schedule(() => {
|
|
40
|
+
owner.cancel = undefined;
|
|
41
|
+
this.#flushLane(owner);
|
|
42
|
+
}, Math.max(wait, 0));
|
|
43
|
+
}
|
|
44
|
+
/** An event that must reach the UI: delivered now, after anything still waiting. */
|
|
45
|
+
terminal(progress) {
|
|
46
|
+
this.#flush();
|
|
47
|
+
this.#sink(progress);
|
|
48
|
+
}
|
|
49
|
+
/** Delivers what is waiting and stops the timers. */
|
|
50
|
+
close() {
|
|
51
|
+
this.#flush();
|
|
52
|
+
}
|
|
53
|
+
/** Everything waiting, in arrival order. */
|
|
54
|
+
#flush() {
|
|
55
|
+
const waiting = [];
|
|
56
|
+
for (const lane of this.#lanes.values()) {
|
|
57
|
+
lane.cancel?.();
|
|
58
|
+
lane.cancel = undefined;
|
|
59
|
+
if (lane.pending)
|
|
60
|
+
waiting.push({ lane, ...lane.pending });
|
|
61
|
+
lane.pending = undefined;
|
|
62
|
+
}
|
|
63
|
+
for (const item of waiting.sort((a, b) => a.order - b.order))
|
|
64
|
+
this.#deliver(item.lane, item.progress);
|
|
65
|
+
}
|
|
66
|
+
#flushLane(lane) {
|
|
67
|
+
const pending = lane.pending;
|
|
68
|
+
lane.pending = undefined;
|
|
69
|
+
if (pending)
|
|
70
|
+
this.#deliver(lane, pending.progress);
|
|
71
|
+
}
|
|
72
|
+
#deliver(lane, progress) {
|
|
73
|
+
lane.lastAt = this.#clock.now();
|
|
74
|
+
this.#sink(progress);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The common Forja protocol (spec §5.4, D10): one logical message schema over several transports.
|
|
3
|
+
* This module is the host-side validator of what the guest sends. Shape is checked with Ajv; the
|
|
4
|
+
* stateful rules (sequence, call correlation, terminal closure, capability gate) are plain code.
|
|
5
|
+
* Every rejection is a `PROTOCOL_VIOLATION` and the caller terminates the execution.
|
|
6
|
+
*/
|
|
7
|
+
import type { EngineId } from "@alisio/sdk";
|
|
8
|
+
import { ForgeError, type ForgeErrorCode } from "./errors.ts";
|
|
9
|
+
import type { GuestLimits } from "./limits.ts";
|
|
10
|
+
export declare const MAX_MESSAGE_BYTES: number;
|
|
11
|
+
/** The registry's naming rule (`ToolRegistry.register`); the protocol refuses any other name. */
|
|
12
|
+
export declare const TOOL_NAME_PATTERN = "^[a-zA-Z0-9_-]{1,64}$";
|
|
13
|
+
export declare const MAX_JSON_DEPTH = 32;
|
|
14
|
+
export type ForgeOutputItem = {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "json";
|
|
19
|
+
value: unknown;
|
|
20
|
+
};
|
|
21
|
+
export interface Envelope<T extends string, P> {
|
|
22
|
+
protocol: 1;
|
|
23
|
+
type: T;
|
|
24
|
+
/** Strictly increasing per direction. */
|
|
25
|
+
seq: number;
|
|
26
|
+
payload: P;
|
|
27
|
+
}
|
|
28
|
+
/** Guest to host. */
|
|
29
|
+
export type GuestMessage = Envelope<"ready", {
|
|
30
|
+
engine: EngineId;
|
|
31
|
+
preludeHash: string;
|
|
32
|
+
}> | Envelope<"progress", {
|
|
33
|
+
message: string;
|
|
34
|
+
completed?: number;
|
|
35
|
+
total?: number;
|
|
36
|
+
}> | Envelope<"output", ForgeOutputItem> | Envelope<"state.set", {
|
|
37
|
+
key: string;
|
|
38
|
+
value: unknown;
|
|
39
|
+
}> | Envelope<"state.delete", {
|
|
40
|
+
key: string;
|
|
41
|
+
}>
|
|
42
|
+
/** Only when the engine's `capabilities.toolCalls` is true. */
|
|
43
|
+
| Envelope<"call", {
|
|
44
|
+
callId: number;
|
|
45
|
+
tool: string;
|
|
46
|
+
input: unknown;
|
|
47
|
+
}> | Envelope<"catalog", {
|
|
48
|
+
requestId: number;
|
|
49
|
+
op: "search" | "describe";
|
|
50
|
+
query: string;
|
|
51
|
+
limit?: number;
|
|
52
|
+
}> | Envelope<"complete", {
|
|
53
|
+
value?: unknown;
|
|
54
|
+
}> | Envelope<"fatal", {
|
|
55
|
+
code: ForgeErrorCode;
|
|
56
|
+
message: string;
|
|
57
|
+
}>;
|
|
58
|
+
/** Host to guest. */
|
|
59
|
+
export type HostMessage = Envelope<"start", {
|
|
60
|
+
source: string;
|
|
61
|
+
state: Record<string, unknown>;
|
|
62
|
+
limits: GuestLimits;
|
|
63
|
+
catalogSize: number;
|
|
64
|
+
}> | Envelope<"result", {
|
|
65
|
+
callId: number;
|
|
66
|
+
ok: true;
|
|
67
|
+
value: unknown;
|
|
68
|
+
} | {
|
|
69
|
+
callId: number;
|
|
70
|
+
ok: false;
|
|
71
|
+
error: {
|
|
72
|
+
code: ForgeErrorCode;
|
|
73
|
+
message: string;
|
|
74
|
+
};
|
|
75
|
+
}> | Envelope<"catalog.result", {
|
|
76
|
+
requestId: number;
|
|
77
|
+
entries: unknown[];
|
|
78
|
+
}>
|
|
79
|
+
/** Backpressure credits. */
|
|
80
|
+
| Envelope<"grant", {
|
|
81
|
+
calls: number;
|
|
82
|
+
bytes: number;
|
|
83
|
+
}>;
|
|
84
|
+
export type GuestMessageType = GuestMessage["type"];
|
|
85
|
+
export type GuestValidation = {
|
|
86
|
+
ok: true;
|
|
87
|
+
message: GuestMessage;
|
|
88
|
+
} | {
|
|
89
|
+
ok: false;
|
|
90
|
+
error: ForgeError;
|
|
91
|
+
};
|
|
92
|
+
export type Correlation = {
|
|
93
|
+
ok: true;
|
|
94
|
+
} | {
|
|
95
|
+
ok: false;
|
|
96
|
+
error: ForgeError;
|
|
97
|
+
};
|
|
98
|
+
export declare class GuestMessageValidator {
|
|
99
|
+
#private;
|
|
100
|
+
constructor(options: {
|
|
101
|
+
toolCalls: boolean;
|
|
102
|
+
});
|
|
103
|
+
/** Validates one guest message. Any violation closes the validator for good. */
|
|
104
|
+
accept(raw: unknown): GuestValidation;
|
|
105
|
+
/** Marks a call as answered by the host; unknown or already answered ids are violations. */
|
|
106
|
+
resolveCall(callId: number): Correlation;
|
|
107
|
+
resolveCatalog(requestId: number): Correlation;
|
|
108
|
+
/** The execution reached a terminal state: nothing more is accepted. */
|
|
109
|
+
close(): void;
|
|
110
|
+
}
|