@alisio/core 0.5.2 → 0.7.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 (204) hide show
  1. package/dist/analysis/janitor.d.ts +48 -0
  2. package/dist/analysis/janitor.js +170 -18
  3. package/dist/analysis/jobs.d.ts +11 -2
  4. package/dist/analysis/jobs.js +13 -0
  5. package/dist/analysis/oci.d.ts +11 -2
  6. package/dist/analysis/oci.js +20 -4
  7. package/dist/analysis/python/sources.js +2 -1
  8. package/dist/analysis/rerun.d.ts +1 -1
  9. package/dist/analysis/status.d.ts +3 -1
  10. package/dist/analysis/status.js +3 -0
  11. package/dist/application.d.ts +28 -2
  12. package/dist/application.js +189 -4
  13. package/dist/changelog/data.js +42 -0
  14. package/dist/config.d.ts +110 -2
  15. package/dist/config.js +188 -6
  16. package/dist/core/call-internals.d.ts +22 -0
  17. package/dist/core/call-internals.js +9 -0
  18. package/dist/core/contracts.d.ts +2 -2
  19. package/dist/core/runner.js +111 -38
  20. package/dist/eval/canonical.d.ts +14 -0
  21. package/dist/eval/canonical.js +40 -0
  22. package/dist/eval/cassette.d.ts +180 -0
  23. package/dist/eval/cassette.js +424 -0
  24. package/dist/eval/compare.d.ts +147 -0
  25. package/dist/eval/compare.js +436 -0
  26. package/dist/eval/engine.d.ts +144 -0
  27. package/dist/eval/engine.js +728 -0
  28. package/dist/eval/export.d.ts +40 -0
  29. package/dist/eval/export.js +90 -0
  30. package/dist/eval/fingerprint.d.ts +88 -0
  31. package/dist/eval/fingerprint.js +186 -0
  32. package/dist/eval/normalize.d.ts +68 -0
  33. package/dist/eval/normalize.js +138 -0
  34. package/dist/eval/paths.d.ts +11 -0
  35. package/dist/eval/paths.js +50 -0
  36. package/dist/eval/plan.d.ts +26 -0
  37. package/dist/eval/plan.js +44 -0
  38. package/dist/eval/report.d.ts +53 -0
  39. package/dist/eval/report.js +258 -0
  40. package/dist/eval/run-suite.d.ts +68 -0
  41. package/dist/eval/run-suite.js +94 -0
  42. package/dist/eval/secrets.d.ts +12 -0
  43. package/dist/eval/secrets.js +45 -0
  44. package/dist/eval/stats.d.ts +17 -0
  45. package/dist/eval/stats.js +50 -0
  46. package/dist/eval/suite.d.ts +20 -0
  47. package/dist/eval/suite.js +165 -0
  48. package/dist/eval/variants.d.ts +35 -0
  49. package/dist/eval/variants.js +137 -0
  50. package/dist/eval/verifiers.d.ts +49 -0
  51. package/dist/eval/verifiers.js +264 -0
  52. package/dist/forge/broker.d.ts +51 -0
  53. package/dist/forge/broker.js +244 -0
  54. package/dist/forge/budget.d.ts +27 -0
  55. package/dist/forge/budget.js +60 -0
  56. package/dist/forge/catalog-view.d.ts +24 -0
  57. package/dist/forge/catalog-view.js +43 -0
  58. package/dist/forge/errors.d.ts +26 -0
  59. package/dist/forge/errors.js +49 -0
  60. package/dist/forge/guides/javascript.d.ts +25 -0
  61. package/dist/forge/guides/javascript.js +90 -0
  62. package/dist/forge/guides/provider.d.ts +42 -0
  63. package/dist/forge/guides/provider.js +96 -0
  64. package/dist/forge/guides/python-environment.d.ts +15 -0
  65. package/dist/forge/guides/python-environment.js +81 -0
  66. package/dist/forge/guides/python.d.ts +38 -0
  67. package/dist/forge/guides/python.js +170 -0
  68. package/dist/forge/javascript/engine.d.ts +44 -0
  69. package/dist/forge/javascript/engine.js +229 -0
  70. package/dist/forge/javascript/hosts/factory.d.ts +25 -0
  71. package/dist/forge/javascript/hosts/factory.js +21 -0
  72. package/dist/forge/javascript/hosts/framing.d.ts +17 -0
  73. package/dist/forge/javascript/hosts/framing.js +49 -0
  74. package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
  75. package/dist/forge/javascript/hosts/isolate-main.js +63 -0
  76. package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
  77. package/dist/forge/javascript/hosts/process-entry.js +3 -0
  78. package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
  79. package/dist/forge/javascript/hosts/process-host.js +160 -0
  80. package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
  81. package/dist/forge/javascript/hosts/worker-entry.js +3 -0
  82. package/dist/forge/javascript/hosts/worker-host.d.ts +27 -0
  83. package/dist/forge/javascript/hosts/worker-host.js +116 -0
  84. package/dist/forge/javascript/prelude.d.ts +2 -0
  85. package/dist/forge/javascript/prelude.js +333 -0
  86. package/dist/forge/javascript/probe.d.ts +2 -0
  87. package/dist/forge/javascript/probe.js +53 -0
  88. package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
  89. package/dist/forge/javascript/runtime-manifest.js +14 -0
  90. package/dist/forge/javascript/vm-runner.d.ts +21 -0
  91. package/dist/forge/javascript/vm-runner.js +301 -0
  92. package/dist/forge/javascript/wasm-asset.d.ts +8 -0
  93. package/dist/forge/javascript/wasm-asset.js +8 -0
  94. package/dist/forge/javascript/wasm.d.ts +15 -0
  95. package/dist/forge/javascript/wasm.js +54 -0
  96. package/dist/forge/journal.d.ts +105 -0
  97. package/dist/forge/journal.js +174 -0
  98. package/dist/forge/kernel.d.ts +76 -0
  99. package/dist/forge/kernel.js +339 -0
  100. package/dist/forge/limits.d.ts +38 -0
  101. package/dist/forge/limits.js +73 -0
  102. package/dist/forge/nested.d.ts +96 -0
  103. package/dist/forge/nested.js +214 -0
  104. package/dist/forge/ports.d.ts +120 -0
  105. package/dist/forge/ports.js +1 -0
  106. package/dist/forge/progress.d.ts +24 -0
  107. package/dist/forge/progress.js +76 -0
  108. package/dist/forge/protocol.d.ts +110 -0
  109. package/dist/forge/protocol.js +171 -0
  110. package/dist/forge/python/backend-selector.d.ts +38 -0
  111. package/dist/forge/python/backend-selector.js +111 -0
  112. package/dist/forge/python/backend.d.ts +89 -0
  113. package/dist/forge/python/backend.js +1 -0
  114. package/dist/forge/python/engine.d.ts +84 -0
  115. package/dist/forge/python/engine.js +279 -0
  116. package/dist/forge/python/frame-parser.d.ts +24 -0
  117. package/dist/forge/python/frame-parser.js +107 -0
  118. package/dist/forge/python/frame-transport.d.ts +40 -0
  119. package/dist/forge/python/frame-transport.js +101 -0
  120. package/dist/forge/python/import-scan.d.ts +22 -0
  121. package/dist/forge/python/import-scan.js +133 -0
  122. package/dist/forge/python/managed-backend.d.ts +30 -0
  123. package/dist/forge/python/managed-backend.js +111 -0
  124. package/dist/forge/python/oci-backend.d.ts +27 -0
  125. package/dist/forge/python/oci-backend.js +84 -0
  126. package/dist/forge/python/pyodide-backend.d.ts +40 -0
  127. package/dist/forge/python/pyodide-backend.js +289 -0
  128. package/dist/forge/python/pyodide-install.d.ts +41 -0
  129. package/dist/forge/python/pyodide-install.js +167 -0
  130. package/dist/forge/python/pyodide-manifest.d.ts +42 -0
  131. package/dist/forge/python/pyodide-manifest.js +376 -0
  132. package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
  133. package/dist/forge/python/pyodide-runner-source.js +3 -0
  134. package/dist/forge/python/rss.d.ts +2 -0
  135. package/dist/forge/python/rss.js +33 -0
  136. package/dist/forge/python/scratch-copy.d.ts +6 -0
  137. package/dist/forge/python/scratch-copy.js +61 -0
  138. package/dist/forge/python/settle.d.ts +6 -0
  139. package/dist/forge/python/settle.js +21 -0
  140. package/dist/forge/python/status.d.ts +8 -0
  141. package/dist/forge/python/status.js +15 -0
  142. package/dist/forge/python/wasm-node.d.ts +59 -0
  143. package/dist/forge/python/wasm-node.js +220 -0
  144. package/dist/forge/python/wiring.d.ts +39 -0
  145. package/dist/forge/python/wiring.js +47 -0
  146. package/dist/forge/registry.d.ts +26 -0
  147. package/dist/forge/registry.js +83 -0
  148. package/dist/forge/result.d.ts +58 -0
  149. package/dist/forge/result.js +86 -0
  150. package/dist/forge/routing-rule.d.ts +19 -0
  151. package/dist/forge/routing-rule.js +31 -0
  152. package/dist/forge/runtime.d.ts +64 -0
  153. package/dist/forge/runtime.js +88 -0
  154. package/dist/forge/scratch-files.d.ts +36 -0
  155. package/dist/forge/scratch-files.js +94 -0
  156. package/dist/forge/scratch-provider.d.ts +41 -0
  157. package/dist/forge/scratch-provider.js +79 -0
  158. package/dist/forge/scratch-quota.d.ts +8 -0
  159. package/dist/forge/scratch-quota.js +25 -0
  160. package/dist/forge/selector.d.ts +35 -0
  161. package/dist/forge/selector.js +51 -0
  162. package/dist/forge/session-dir.d.ts +59 -0
  163. package/dist/forge/session-dir.js +239 -0
  164. package/dist/forge/session-path.d.ts +31 -0
  165. package/dist/forge/session-path.js +61 -0
  166. package/dist/forge/slots.d.ts +16 -0
  167. package/dist/forge/slots.js +47 -0
  168. package/dist/forge/state-delta.d.ts +54 -0
  169. package/dist/forge/state-delta.js +99 -0
  170. package/dist/forge/state-machine.d.ts +20 -0
  171. package/dist/forge/state-machine.js +40 -0
  172. package/dist/forge/state-store.d.ts +25 -0
  173. package/dist/forge/state-store.js +50 -0
  174. package/dist/forge/task-log.d.ts +9 -0
  175. package/dist/forge/task-log.js +42 -0
  176. package/dist/forge/verdict.d.ts +20 -0
  177. package/dist/forge/verdict.js +28 -0
  178. package/dist/forge-isolate.d.ts +6 -0
  179. package/dist/forge-isolate.js +6 -0
  180. package/dist/index.d.ts +23 -1
  181. package/dist/index.js +23 -1
  182. package/dist/permissions/decide.d.ts +42 -0
  183. package/dist/permissions/decide.js +24 -0
  184. package/dist/plan/exit-plan.js +2 -1
  185. package/dist/resources/context.d.ts +2 -1
  186. package/dist/resources/context.js +2 -1
  187. package/dist/runtime/access.d.ts +34 -0
  188. package/dist/runtime/access.js +80 -0
  189. package/dist/runtime/ids.d.ts +21 -0
  190. package/dist/runtime/ids.js +41 -1
  191. package/dist/runtime/process.d.ts +2 -1
  192. package/dist/runtime/process.js +5 -5
  193. package/dist/runtime/store.d.ts +11 -0
  194. package/dist/runtime/store.js +106 -2
  195. package/dist/tools/analysis.d.ts +25 -2
  196. package/dist/tools/analysis.js +315 -267
  197. package/dist/tools/execute.js +6 -7
  198. package/dist/tools/forge-guide.d.ts +6 -0
  199. package/dist/tools/forge-guide.js +60 -0
  200. package/dist/tools/js-run.d.ts +19 -0
  201. package/dist/tools/js-run.js +126 -0
  202. package/dist/tools/standard.d.ts +7 -1
  203. package/dist/tools/standard.js +44 -43
  204. package/package.json +7 -2
@@ -0,0 +1,174 @@
1
+ import { isProcessAlive } from "../runtime/process.js";
2
+ import { canTransition, InvalidTransitionError } from "./state-machine.js";
3
+ const TERMINAL = ["succeeded", "failed", "cancelled", "timed_out"];
4
+ const isTerminal = (state) => TERMINAL.includes(state);
5
+ /** The id of the n-th nested call of a tool call (D-24). It never enters `tool_calls`. */
6
+ export const forgeCallId = (toolCallId, n) => `${toolCallId}:forge:${n}`;
7
+ function toRecord(row) {
8
+ return {
9
+ id: String(row.id),
10
+ rootSessionId: String(row.root_session_id),
11
+ sessionId: String(row.session_id),
12
+ toolCallId: String(row.tool_call_id),
13
+ engine: row.engine,
14
+ isolation: row.isolation,
15
+ selectionReason: String(row.selection_reason),
16
+ status: row.status,
17
+ codeSha256: String(row.code_sha256),
18
+ startedAt: Number(row.started_at),
19
+ ...(row.ended_at != null ? { endedAt: Number(row.ended_at) } : {}),
20
+ ...(row.metrics_json != null
21
+ ? { metrics: JSON.parse(String(row.metrics_json)) }
22
+ : {}),
23
+ ...(row.error_code != null ? { errorCode: row.error_code } : {}),
24
+ terminationUnconfirmed: Number(row.termination_unconfirmed) === 1,
25
+ ...(row.analysis_execution_id != null
26
+ ? { analysisExecutionId: String(row.analysis_execution_id) }
27
+ : {}),
28
+ ...(row.owner_pid != null ? { ownerPid: Number(row.owner_pid) } : {}),
29
+ };
30
+ }
31
+ function toCall(row) {
32
+ return {
33
+ executionId: String(row.execution_id),
34
+ callId: String(row.call_id),
35
+ toolName: String(row.tool_name),
36
+ effect: row.effect,
37
+ status: row.status,
38
+ ...(row.duration_ms != null ? { durationMs: Number(row.duration_ms) } : {}),
39
+ inputSha256: String(row.input_sha256),
40
+ ...(row.error_code != null ? { errorCode: row.error_code } : {}),
41
+ ...(row.paths_json != null ? { paths: JSON.parse(String(row.paths_json)) } : {}),
42
+ };
43
+ }
44
+ export class SqliteExecutionJournal {
45
+ #db;
46
+ #now;
47
+ #ownerPid;
48
+ constructor(db, options = {}) {
49
+ this.#db = db;
50
+ this.#now = options.now ?? Date.now;
51
+ this.#ownerPid = options.ownerPid ?? process.pid;
52
+ }
53
+ /** Records a new execution in `validating`. */
54
+ open(input) {
55
+ this.#db
56
+ .prepare(`INSERT INTO forge_executions(id,root_session_id,session_id,tool_call_id,engine,isolation,
57
+ selection_reason,status,code_sha256,started_at,analysis_execution_id,owner_pid)
58
+ VALUES(?,?,?,?,?,?,?,'validating',?,?,?,?)`)
59
+ .run(input.id, input.rootSessionId, input.sessionId, input.toolCallId, input.engine, input.isolation, input.selectionReason, input.codeSha256, this.#now(), input.analysisExecutionId ?? null, this.#ownerPid);
60
+ return this.#required(input.id);
61
+ }
62
+ /** Links the execution to the `analysis_executions` row its engine created while preparing. */
63
+ linkAnalysisExecution(id, analysisExecutionId) {
64
+ const result = this.#db
65
+ .prepare("UPDATE forge_executions SET analysis_execution_id=? WHERE id=?")
66
+ .run(analysisExecutionId, id);
67
+ if (Number(result.changes) === 0)
68
+ throw new Error(`Unknown Forja execution: ${id}`);
69
+ }
70
+ get(id) {
71
+ const row = this.#db.prepare("SELECT * FROM forge_executions WHERE id=?").get(id);
72
+ return row ? toRecord(row) : undefined;
73
+ }
74
+ listByRoot(rootSessionId) {
75
+ return this.#db
76
+ .prepare("SELECT * FROM forge_executions WHERE root_session_id=? ORDER BY started_at, id")
77
+ .all(rootSessionId).map(toRecord);
78
+ }
79
+ /** Moves to a non-terminal state the diagram allows. Terminal states go through `close()`. */
80
+ transition(id, to) {
81
+ if (isTerminal(to))
82
+ throw new Error(`Terminal state ${to} is reached through close(), not transition()`);
83
+ this.#advance(id, to, {});
84
+ }
85
+ /** Reaches a terminal state, once. A second close throws and changes nothing. */
86
+ close(id, outcome) {
87
+ if (!isTerminal(outcome.status))
88
+ throw new Error(`close() needs a terminal state, got ${String(outcome.status)}`);
89
+ this.#advance(id, outcome.status, {
90
+ endedAt: this.#now(),
91
+ errorCode: outcome.errorCode ?? null,
92
+ metrics: outcome.metrics ? JSON.stringify(outcome.metrics) : null,
93
+ unconfirmed: outcome.terminationUnconfirmed ? 1 : 0,
94
+ });
95
+ }
96
+ #advance(id, to, terminal) {
97
+ this.#db.transaction(() => {
98
+ const row = this.#db.prepare("SELECT status FROM forge_executions WHERE id=?").get(id);
99
+ if (!row)
100
+ throw new Error(`Unknown Forja execution: ${id}`);
101
+ if (!canTransition(row.status, to))
102
+ throw new InvalidTransitionError(row.status, to);
103
+ if (terminal.endedAt === undefined) {
104
+ this.#db
105
+ .prepare("UPDATE forge_executions SET status=? WHERE id=? AND status=?")
106
+ .run(to, id, row.status);
107
+ return;
108
+ }
109
+ this.#db
110
+ .prepare(`UPDATE forge_executions SET status=?, ended_at=?, error_code=?, metrics_json=?,
111
+ termination_unconfirmed=? WHERE id=? AND status=?`)
112
+ .run(to, terminal.endedAt, terminal.errorCode ?? null, terminal.metrics ?? null, terminal.unconfirmed ?? 0, id, row.status);
113
+ });
114
+ }
115
+ /** Records the start of a nested call and returns its id. */
116
+ startCall(input) {
117
+ const execution = this.#required(input.executionId);
118
+ const callId = forgeCallId(execution.toolCallId, input.n);
119
+ this.#db
120
+ .prepare(`INSERT INTO forge_calls(execution_id,call_id,tool_name,effect,status,input_sha256,paths_json)
121
+ VALUES(?,?,?,?,'started',?,?)`)
122
+ .run(input.executionId, callId, input.toolName, input.effect, input.inputSha256, input.effect === "write" && input.paths ? JSON.stringify(input.paths) : null);
123
+ return callId;
124
+ }
125
+ /** Ends a call that is still `started`; false when it already ended (or does not exist). */
126
+ endCall(executionId, callId, outcome) {
127
+ const result = this.#db
128
+ .prepare(`UPDATE forge_calls SET status=?, duration_ms=?, error_code=?
129
+ WHERE execution_id=? AND call_id=? AND status='started'`)
130
+ .run(outcome.status, outcome.durationMs ?? null, outcome.errorCode ?? null, executionId, callId);
131
+ return Number(result.changes) > 0;
132
+ }
133
+ calls(executionId) {
134
+ const rows = this.#db
135
+ .prepare("SELECT * FROM forge_calls WHERE execution_id=?")
136
+ .all(executionId);
137
+ const position = (callId) => Number(callId.slice(callId.lastIndexOf(":") + 1));
138
+ return rows.map(toCall).sort((a, b) => position(a.callId) - position(b.callId));
139
+ }
140
+ #required(id) {
141
+ const record = this.get(id);
142
+ if (!record)
143
+ throw new Error(`Unknown Forja execution: ${id}`);
144
+ return record;
145
+ }
146
+ }
147
+ /**
148
+ * Restart reconciliation (§9.3, FJ-AC-25): every non-terminal execution whose owner process is
149
+ * gone (or unknown) becomes `failed` with `ISOLATE_FAILURE` and reason `interrupted`. Nothing is
150
+ * relaunched. Executions of another live process are left alone. Returns the rows reconciled.
151
+ *
152
+ * Call it once when the application opens the store (Phase 3 wires it next to the runner).
153
+ */
154
+ export function reconcileInterruptedExecutions(db, options = {}) {
155
+ const now = (options.now ?? Date.now)();
156
+ const isAlive = options.isAlive ?? isProcessAlive;
157
+ return db.transaction(() => {
158
+ const rows = db
159
+ .prepare(`SELECT id, status, owner_pid FROM forge_executions
160
+ WHERE status NOT IN ('succeeded','failed','cancelled','timed_out')`)
161
+ .all();
162
+ const update = db.prepare(`UPDATE forge_executions SET status='failed', error_code='ISOLATE_FAILURE', ended_at=?,
163
+ metrics_json=? WHERE id=? AND status=?`);
164
+ let reconciled = 0;
165
+ for (const row of rows) {
166
+ if (row.owner_pid != null && isAlive(row.owner_pid))
167
+ continue;
168
+ const result = update.run(now, JSON.stringify({ reason: "interrupted" }), row.id, row.status);
169
+ if (Number(result.changes) > 0)
170
+ reconciled++;
171
+ }
172
+ return reconciled;
173
+ });
174
+ }
@@ -0,0 +1,76 @@
1
+ import type { EngineId, EngineLanguage, ForgeProgress, PermissionMode } from "@alisio/sdk";
2
+ import type { SqliteExecutionJournal } from "./journal.ts";
3
+ import { type ForgeLimits } from "./limits.ts";
4
+ import type { NestedCallDispatcher } from "./nested.ts";
5
+ import type { EngineRegistry } from "./registry.ts";
6
+ import { type ForgeResult } from "./result.ts";
7
+ import { type EngineSelector, type JavaScriptSelectionContext, type Selection } from "./selector.ts";
8
+ import type { SlotTable } from "./slots.ts";
9
+ import type { SqliteStateStore } from "./state-store.ts";
10
+ export interface KernelConfig {
11
+ /** `forge.javascript.enabled`. */
12
+ enabled: boolean;
13
+ /** The configured limits (the global layer; project config only ever lowered them already). */
14
+ limits: Partial<ForgeLimits>;
15
+ /** `forge.state.enabled`. */
16
+ stateEnabled: boolean;
17
+ }
18
+ export interface KernelDeps {
19
+ engines: EngineRegistry;
20
+ selector: EngineSelector<JavaScriptSelectionContext, EngineId>;
21
+ journal: SqliteExecutionJournal;
22
+ state?: SqliteStateStore;
23
+ slots: SlotTable;
24
+ config: () => KernelConfig;
25
+ now?: () => number;
26
+ newId?: () => string;
27
+ /** How long the end of an execution waits for nested calls the script left running. */
28
+ drainMs?: number;
29
+ }
30
+ /** Everything the core knows about the call that asks for the execution; the model controls none of it. */
31
+ export interface KernelCallContext {
32
+ toolCallId: string;
33
+ session: string;
34
+ rootSessionId: string;
35
+ workspace: string;
36
+ signal: AbortSignal;
37
+ permissionMode: PermissionMode;
38
+ allowAnalysis: boolean;
39
+ allowProcess: boolean;
40
+ /** Absent for an engine that cannot call tools (Python): the kernel then offers none. */
41
+ dispatcher?: NestedCallDispatcher;
42
+ /** Delivers `ForgeProgress` to the run (already coalesced to 4 per second). */
43
+ emit: (progress: ForgeProgress) => void;
44
+ /** `<sessionDir>/tasks/<executionId>.log` is written under this folder when given. */
45
+ tasksDir?: string;
46
+ }
47
+ export interface KernelInvocation {
48
+ source: string;
49
+ title?: string;
50
+ requestedLimits?: Partial<ForgeLimits>;
51
+ call: KernelCallContext;
52
+ /** Language of `source`; JavaScript when omitted. */
53
+ language?: EngineLanguage;
54
+ /**
55
+ * A selection the caller already made (Python's backend selector). It replaces the JavaScript
56
+ * chain; the engine must be registered. The model never provides it.
57
+ */
58
+ selection?: Selection<EngineId>;
59
+ /** Per-engine request extensions, handed to `prepare` next to the state snapshot. */
60
+ payload?: Record<string, unknown>;
61
+ /**
62
+ * Limits set by the core after the merge (Python's own timeout and source size, which the
63
+ * JavaScript maxima do not describe). Never built from model input.
64
+ */
65
+ limitOverrides?: Partial<ForgeLimits>;
66
+ }
67
+ /** The result plus the bound on what the model sees of it (the effective `maxOutputChars`). */
68
+ export interface KernelOutcome {
69
+ result: ForgeResult;
70
+ maxOutputChars: number;
71
+ }
72
+ export declare class ExecutionKernel {
73
+ #private;
74
+ constructor(deps: KernelDeps);
75
+ execute(invocation: KernelInvocation): Promise<KernelOutcome>;
76
+ }
@@ -0,0 +1,339 @@
1
+ /**
2
+ * `ExecutionKernel` (spec D1, §5.2): the fixed lifecycle every Forja execution follows, whatever the
3
+ * engine. Template Method:
4
+ *
5
+ * validate -> admit -> select -> prepare -> run (with deadline) -> collect -> cleanup (always)
6
+ *
7
+ * The kernel owns what must not depend on an engine: limits, the concurrency slots, the journal
8
+ * (every transition and nested call), the cross-execution state (committed only on success), the
9
+ * deadline and cancellation with the host's OWN clock, the output bound and the single terminal
10
+ * state. The engine only knows how to run code in an isolate.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { newUuid } from "../runtime/ids.js";
14
+ import { CapabilityBroker } from "./broker.js";
15
+ import { BudgetLedger } from "./budget.js";
16
+ import { ForgeError, isForgeError } from "./errors.js";
17
+ import { DEFAULT_FORGE_LIMITS, mergeLimits } from "./limits.js";
18
+ import { ProgressCoalescer } from "./progress.js";
19
+ import { hintFor } from "./result.js";
20
+ import { StateDelta } from "./state-delta.js";
21
+ import { ExecutionStateMachine } from "./state-machine.js";
22
+ import { TaskLog } from "./task-log.js";
23
+ import { verdictOf } from "./verdict.js";
24
+ /** What an engine without tool calls sees as its nested-call port: nothing. */
25
+ const NO_NESTED_CALLS = {
26
+ catalog: () => [],
27
+ knows: () => false,
28
+ call: async () => ({
29
+ ok: false,
30
+ nestedCallId: "",
31
+ code: "TOOL_UNAVAILABLE",
32
+ message: "this engine cannot call tools",
33
+ }),
34
+ };
35
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
36
+ export class ExecutionKernel {
37
+ #deps;
38
+ constructor(deps) {
39
+ this.#deps = deps;
40
+ }
41
+ async execute(invocation) {
42
+ const deps = this.#deps;
43
+ const now = deps.now ?? Date.now;
44
+ const call = invocation.call;
45
+ const startedAt = now();
46
+ const id = (deps.newId ?? (() => `fx_${newUuid()}`))();
47
+ const config = deps.config();
48
+ const descriptors = deps.engines.descriptors();
49
+ const language = invocation.language ?? "javascript";
50
+ const dispatcher = call.dispatcher ?? NO_NESTED_CALLS;
51
+ // select (cheap and pure, so it precedes the journal row, which needs the engine and the reason)
52
+ let selection;
53
+ let selectionError;
54
+ try {
55
+ selection =
56
+ invocation.selection ??
57
+ deps.selector.select({
58
+ language,
59
+ javascriptEnabled: config.enabled,
60
+ descriptors,
61
+ });
62
+ }
63
+ catch (error) {
64
+ selectionError = isForgeError(error)
65
+ ? error
66
+ : new ForgeError("ENGINE_UNAVAILABLE", error instanceof Error ? error.message : String(error));
67
+ }
68
+ const engineId = selection?.engine ?? "javascript-quickjs";
69
+ const isolation = descriptors[engineId]?.isolation ?? "none";
70
+ const selectionReason = selection?.reason ?? selectionError?.message ?? "no engine";
71
+ deps.journal.open({
72
+ id,
73
+ rootSessionId: call.rootSessionId,
74
+ sessionId: call.session,
75
+ toolCallId: call.toolCallId,
76
+ engine: engineId,
77
+ isolation,
78
+ selectionReason,
79
+ codeSha256: sha256(invocation.source),
80
+ });
81
+ const machine = new ExecutionStateMachine();
82
+ const advance = (to) => {
83
+ machine.transition(to);
84
+ deps.journal.transition(id, to);
85
+ };
86
+ const log = call.tasksDir ? new TaskLog(`${call.tasksDir}/${id}.log`) : undefined;
87
+ const progress = new ProgressCoalescer(call.emit);
88
+ const base = { kind: "forge", executionId: id, engine: engineId };
89
+ const phase = (name, message) => progress.terminal({ ...base, phase: name, ...(message ? { message } : {}) });
90
+ let limits;
91
+ let release;
92
+ let engine;
93
+ let prepared;
94
+ let broker;
95
+ let verdict = {
96
+ status: "failed",
97
+ error: { code: "ISOLATE_FAILURE", message: "execution did not finish" },
98
+ };
99
+ let report;
100
+ let queueMs = 0;
101
+ const abort = new AbortController();
102
+ let stop;
103
+ let running = false;
104
+ let timer;
105
+ let termination;
106
+ const requestStop = (reason) => {
107
+ if (!running || stop)
108
+ return;
109
+ stop = reason;
110
+ // Nested calls hear it first: a guest waiting on one executes no bytecode and cannot notice.
111
+ abort.abort(new Error(reason === "timeout" ? "deadline" : "cancelled"));
112
+ const why = reason === "timeout" ? "timeout" : "cancelled";
113
+ if (engine && prepared)
114
+ termination = engine.terminate(prepared, why);
115
+ };
116
+ const onUserAbort = () => requestStop("cancelled");
117
+ try {
118
+ // validate
119
+ if (selectionError)
120
+ throw selectionError;
121
+ const layers = {
122
+ global: config.limits,
123
+ ...(invocation.requestedLimits ? { request: invocation.requestedLimits } : {}),
124
+ };
125
+ limits = { ...mergeLimits(layers), ...invocation.limitOverrides };
126
+ if (Buffer.byteLength(invocation.source, "utf8") > limits.maxSourceBytes)
127
+ throw new ForgeError("INVALID_INPUT", `the script is larger than ${limits.maxSourceBytes} bytes`);
128
+ if (invocation.source.trim() === "")
129
+ throw new ForgeError("INVALID_INPUT", "the script is empty");
130
+ // admit (the slots are the JavaScript engine's: Python runs are not Forja script hosts, and a
131
+ // `js_run` that calls `python_run` holds its session's slot while the Python run starts)
132
+ if (language === "javascript")
133
+ release = deps.slots.acquire(call.session);
134
+ advance("preparing");
135
+ phase("preparing");
136
+ const snapshot = config.stateEnabled && deps.state
137
+ ? deps.state.snapshot(call.rootSessionId)
138
+ : { values: {}, revisions: {} };
139
+ const delta = config.stateEnabled && deps.state ? new StateDelta(snapshot) : undefined;
140
+ // prepare
141
+ engine = deps.engines.get(engineId);
142
+ const request = {
143
+ language,
144
+ source: invocation.source,
145
+ ...(invocation.title ? { title: invocation.title } : {}),
146
+ engineHint: engineId,
147
+ payload: {
148
+ ...invocation.payload,
149
+ state: snapshot.values,
150
+ catalogSize: dispatcher.catalog().length,
151
+ },
152
+ };
153
+ prepared = await engine.prepare(request, {
154
+ executionId: id,
155
+ toolCallId: call.toolCallId,
156
+ rootSessionId: call.rootSessionId,
157
+ workspace: call.workspace,
158
+ permissionMode: call.permissionMode,
159
+ allowAnalysis: call.allowAnalysis,
160
+ allowProcess: call.allowProcess,
161
+ signal: call.signal,
162
+ limits,
163
+ });
164
+ if (prepared.analysisExecutionId)
165
+ deps.journal.linkAnalysisExecution(id, prepared.analysisExecutionId);
166
+ broker = new CapabilityBroker({
167
+ dispatcher,
168
+ ledger: new BudgetLedger(limits),
169
+ limits,
170
+ journal: deps.journal,
171
+ executionId: id,
172
+ signal: abort.signal,
173
+ state: delta,
174
+ update: (event) => progress.update(event),
175
+ terminal: (event) => progress.terminal(event),
176
+ engine: engineId,
177
+ log: (line) => log?.write(line),
178
+ now,
179
+ });
180
+ const activeBroker = broker;
181
+ let send = () => { };
182
+ activeBroker.bind((reply) => send(reply));
183
+ // run (the deadline starts here and covers compute and nested calls)
184
+ let outcome;
185
+ if (call.signal.aborted) {
186
+ // Cancelled before it ever ran: preparing -> cancelled.
187
+ outcome = { kind: "failed", code: "CANCELLED", message: "cancelled" };
188
+ verdict = verdictOf(outcome, "cancelled", limits.timeoutMs);
189
+ }
190
+ else {
191
+ advance("running");
192
+ running = true;
193
+ queueMs = now() - startedAt;
194
+ phase("running");
195
+ log?.write(`started ${id} (${selectionReason})`);
196
+ call.signal.addEventListener("abort", onUserAbort, { once: true });
197
+ timer = setTimeout(() => requestStop("timeout"), limits.timeoutMs);
198
+ outcome = await engine.run(prepared, {
199
+ receive: (message) => activeBroker.receive(message),
200
+ bind: (next) => {
201
+ send = next;
202
+ },
203
+ signal: abort.signal,
204
+ });
205
+ running = false;
206
+ // The engine's own collection (artifacts to publish) comes before the state commit, and
207
+ // only for an execution the host did not stop.
208
+ let collecting = false;
209
+ if (!stop && engine.collect) {
210
+ if (outcome.kind === "completed") {
211
+ advance("collecting");
212
+ phase("collecting");
213
+ collecting = true;
214
+ }
215
+ outcome = await engine.collect(prepared, outcome);
216
+ }
217
+ verdict = verdictOf(outcome, stop, limits.timeoutMs);
218
+ if (stop) {
219
+ // The host's own stop decides the terminal state; wait until the guest is confirmed gone.
220
+ report = await (termination ??
221
+ engine.terminate(prepared, stop === "timeout" ? "timeout" : "cancelled"));
222
+ }
223
+ else if (outcome.kind === "failed") {
224
+ report = await engine.terminate(prepared, "limit_exceeded");
225
+ }
226
+ abort.abort(new Error("execution ended"));
227
+ await activeBroker.drain(deps.drainMs ?? 2_000);
228
+ if (verdict.status === "succeeded") {
229
+ if (!collecting) {
230
+ advance("collecting");
231
+ phase("collecting");
232
+ }
233
+ try {
234
+ if (delta && deps.state)
235
+ deps.state.commit(call.rootSessionId, snapshot, delta.ops());
236
+ }
237
+ catch (error) {
238
+ if (isForgeError(error))
239
+ verdict = { status: "failed", error: { code: error.code, message: error.message } };
240
+ else
241
+ throw error;
242
+ }
243
+ }
244
+ }
245
+ }
246
+ catch (error) {
247
+ running = false;
248
+ const forge = isForgeError(error) ? error : undefined;
249
+ verdict = {
250
+ status: "failed",
251
+ error: {
252
+ code: forge?.code ?? "ISOLATE_FAILURE",
253
+ message: error instanceof Error ? error.message : String(error),
254
+ },
255
+ };
256
+ }
257
+ finally {
258
+ clearTimeout(timer);
259
+ call.signal.removeEventListener("abort", onUserAbort);
260
+ abort.abort(new Error("execution ended"));
261
+ if (broker)
262
+ await broker.drain(deps.drainMs ?? 2_000);
263
+ if (engine && prepared) {
264
+ try {
265
+ await engine.dispose(prepared);
266
+ }
267
+ catch {
268
+ /* The isolate is already stopped or unreachable; nothing more to do from here. */
269
+ }
270
+ }
271
+ progress.close();
272
+ }
273
+ // The terminal state is reached exactly once, with the state machine's blessing.
274
+ const unconfirmed = report ? !report.confirmed : false;
275
+ const stats = broker?.stats ?? { total: 0, failed: 0, denied: 0 };
276
+ const output = broker?.output ?? [];
277
+ const outputBytes = output.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item)), 0);
278
+ const wallMs = now() - startedAt;
279
+ try {
280
+ if (verdict.status === "cancelled" && machine.state === "running")
281
+ advance("cancelling");
282
+ if (verdict.status === "timed_out" && machine.state === "running")
283
+ advance("timed_out_pending");
284
+ deps.journal.close(id, {
285
+ status: verdict.status,
286
+ ...(verdict.error ? { errorCode: verdict.error.code } : {}),
287
+ metrics: {
288
+ wallMs,
289
+ queueMs,
290
+ calls: stats,
291
+ outputBytes,
292
+ ...(broker?.progressDropped ? { progressDropped: broker.progressDropped } : {}),
293
+ },
294
+ terminationUnconfirmed: unconfirmed,
295
+ });
296
+ }
297
+ catch (error) {
298
+ // The row is already terminal (a reconciliation raced us); the result still goes back.
299
+ log?.write(`journal: ${error instanceof Error ? error.message : String(error)}`);
300
+ }
301
+ log?.write(`${verdict.status}${verdict.error ? `: ${verdict.error.code}` : ""} (${wallMs} ms)`);
302
+ await log?.flush();
303
+ // A guest that could not be confirmed gone keeps its slot until it is (or the process ends).
304
+ if (unconfirmed && report?.settled)
305
+ void report.settled.then(() => release?.());
306
+ else
307
+ release?.();
308
+ const result = {
309
+ schemaVersion: 1,
310
+ executionId: id,
311
+ engine: engineId,
312
+ isolation,
313
+ selectionReason,
314
+ status: verdict.status,
315
+ output,
316
+ ...(verdict.value !== undefined ? { value: verdict.value } : {}),
317
+ ...(verdict.error
318
+ ? {
319
+ error: {
320
+ ...verdict.error,
321
+ ...(() => {
322
+ const hint = language === "javascript"
323
+ ? hintFor(verdict.error.code, verdict.error.message, invocation.source)
324
+ : undefined;
325
+ return hint ? { hint } : {};
326
+ })(),
327
+ },
328
+ }
329
+ : {}),
330
+ calls: stats,
331
+ truncated: false,
332
+ metrics: { wallMs, queueMs, outputBytes },
333
+ };
334
+ return {
335
+ result,
336
+ maxOutputChars: limits?.maxOutputChars ?? DEFAULT_FORGE_LIMITS.maxOutputChars,
337
+ };
338
+ }
339
+ }
@@ -0,0 +1,38 @@
1
+ export interface ForgeLimits {
2
+ maxSourceBytes: number;
3
+ timeoutMs: number;
4
+ /** QuickJS heap. */
5
+ memoryBytes: number;
6
+ /** QuickJS stack. */
7
+ stackBytes: number;
8
+ /** Nested tool calls per execution. */
9
+ maxToolCalls: number;
10
+ /** Simultaneous nested tool calls. */
11
+ maxConcurrency: number;
12
+ maxCallInputBytes: number;
13
+ maxCallResponseBytes: number;
14
+ maxRetainedOutputBytes: number;
15
+ /** Characters of output returned to the model. */
16
+ maxOutputChars: number;
17
+ }
18
+ export type ForgeLimitKey = keyof ForgeLimits;
19
+ /**
20
+ * The most a tool input may weigh: the protocol message that carries it (envelope, ids, tool name)
21
+ * must fit in `MAX_MESSAGE_BYTES`, so the effective limit stays a little below it whatever the
22
+ * configuration says. Over this a call is refused as `TOOL_INPUT_INVALID` before it is sent.
23
+ */
24
+ export declare const MAX_CALL_INPUT_BYTES: number;
25
+ /** Defaults of the spec's §6.5 table. */
26
+ export declare const DEFAULT_FORGE_LIMITS: Readonly<ForgeLimits>;
27
+ /** Hard global maxima of the same table. */
28
+ export declare const MAX_FORGE_LIMITS: Readonly<ForgeLimits>;
29
+ /** The limits a project config may lower; the rest is global-only. */
30
+ export declare const PROJECT_LOWERABLE_LIMITS: readonly ForgeLimitKey[];
31
+ export interface LimitLayers {
32
+ global?: Partial<ForgeLimits>;
33
+ project?: Partial<ForgeLimits>;
34
+ request?: Partial<ForgeLimits>;
35
+ }
36
+ export declare function mergeLimits(layers: LimitLayers): ForgeLimits;
37
+ /** The limits the guest is told about in `start`; the host enforces them regardless. */
38
+ export type GuestLimits = Pick<ForgeLimits, "timeoutMs" | "memoryBytes" | "stackBytes" | "maxToolCalls" | "maxConcurrency" | "maxCallInputBytes" | "maxCallResponseBytes" | "maxRetainedOutputBytes">;