@jini-ai/daemon 0.6.12 → 0.6.15-rc.2
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/CHANGELOG.md +34 -0
- package/dist/agent-executor/codex/home-config.d.ts +50 -0
- package/dist/agent-executor/codex/home-config.js +209 -0
- package/dist/agent-executor/codex/home-staging.d.ts +60 -0
- package/dist/agent-executor/codex/home-staging.js +195 -0
- package/dist/agent-executor/codex/isolation.d.ts +27 -0
- package/dist/agent-executor/codex/isolation.js +67 -0
- package/dist/agent-executor/contracts.d.ts +4 -1
- package/dist/agent-executor/event-translation.d.ts +7 -7
- package/dist/agent-executor/event-translation.js +17 -17
- package/dist/agent-executor/events.js +5 -2
- package/dist/agent-executor/index.d.ts +3 -1
- package/dist/agent-executor/index.js +3 -1
- package/dist/agent-executor/launch.d.ts +3 -2
- package/dist/agent-executor/launch.js +77 -21
- package/dist/agent-executor/live-session-state.js +4 -1
- package/dist/agent-executor/mcp-bridge-probe.d.ts +2 -1
- package/dist/agent-executor/mcp-bridge-probe.js +1 -1
- package/dist/agent-executor/mcp-bridge.d.ts +9 -86
- package/dist/agent-executor/mcp-bridge.js +12 -360
- package/dist/ai-trace-lifecycle.d.ts +48 -0
- package/dist/ai-trace-lifecycle.js +116 -0
- package/dist/core/read-only-tools.js +3 -1
- package/dist/delegated-tool-bridge.js +19 -1
- package/dist/http/attachment-lookup-routes.d.ts +44 -0
- package/dist/http/attachment-lookup-routes.js +71 -0
- package/dist/http/attachments.d.ts +112 -7
- package/dist/http/attachments.js +58 -17
- package/dist/http/connectors.js +23 -18
- package/dist/http/index.d.ts +6 -0
- package/dist/http/index.js +3 -0
- package/dist/http/message-relay.js +4 -1
- package/dist/http/tool-execution-routes.d.ts +69 -0
- package/dist/http/tool-execution-routes.js +181 -0
- package/dist/http/xai.js +19 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/remote-attachment-lookup.d.ts +29 -0
- package/dist/remote-attachment-lookup.js +71 -0
- package/dist/remote-tool-executor.d.ts +85 -0
- package/dist/remote-tool-executor.js +250 -0
- package/dist/routing-tool-executor.d.ts +29 -0
- package/dist/routing-tool-executor.js +16 -0
- package/dist/run-lifecycle.d.ts +4 -1
- package/dist/run-lifecycle.js +35 -5
- package/dist/store/event-log/sqlite-kernel.d.ts +18 -0
- package/dist/store/event-log/sqlite-kernel.js +135 -0
- package/dist/store/event-log/sqlite-policy.d.ts +93 -0
- package/dist/store/event-log/sqlite-policy.js +74 -0
- package/dist/store/event-log/sqlite.d.ts +7 -31
- package/dist/store/event-log/sqlite.js +32 -95
- package/dist/tool-recovery/analytics.d.ts +27 -0
- package/dist/tool-recovery/analytics.js +38 -0
- package/dist/tool-recovery/index.d.ts +2 -0
- package/dist/tool-recovery/index.js +2 -0
- package/dist/tool-recovery/observability.d.ts +82 -0
- package/dist/tool-recovery/observability.js +88 -0
- package/package.json +15 -14
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module remote-tool-executor
|
|
3
|
+
*
|
|
4
|
+
* A `ToolExecutor` whose calls run in ANOTHER process on the same machine: the client end of
|
|
5
|
+
* `@jini-ai/daemon/http`'s `registerToolExecutionRoutes`. A host whose agent runtime lives in one
|
|
6
|
+
* process and whose gated tool surface lives in another (a slim daemon beside a site server) wires
|
|
7
|
+
* this in where an in-process executor would go; the far side's own executor still authorizes,
|
|
8
|
+
* confirms, audits and runs the call, so nothing is gated twice here and nothing is gated less.
|
|
9
|
+
*
|
|
10
|
+
* Wire protocol (one POST per call, `application/x-ndjson` response):
|
|
11
|
+
* - request body `{ executionId, principal, run: { id }, toolId, input, context? }`, where `context`
|
|
12
|
+
* is opaque host JSON from `contextFor`;
|
|
13
|
+
* - a 200 status line is flushed BEFORE the far executor is called, then zero or more
|
|
14
|
+
* `{"type":"surface","emission":…}` lines, then exactly one `{"type":"result","result":…}` or
|
|
15
|
+
* `{"type":"throw","error":{"name","message","code?"}}` line;
|
|
16
|
+
* - every refusal (400/401/403/413/503) is a non-200 status sent before the far executor runs.
|
|
17
|
+
*
|
|
18
|
+
* At-most-once. This client never retries. A failure is thrown as a {@link ToolRpcError} with one of
|
|
19
|
+
* two codes, and a caller decides what to tell the model from the code alone:
|
|
20
|
+
* - `TOOL_NOT_DISPATCHED`: the socket never connected, or the far side answered with a non-200
|
|
21
|
+
* status. Nothing ran, so the caller may retry.
|
|
22
|
+
* - `TOOL_OUTCOME_UNKNOWN`: anything else after the request was written — a reset, a hang-up, an end
|
|
23
|
+
* with no terminal line, a malformed line (including a terminal line missing a field a settled call
|
|
24
|
+
* must carry). "No headers seen yet" is NOT proof that nothing ran: a TCP reset can discard bytes
|
|
25
|
+
* the far side already sent, so the call may have committed.
|
|
26
|
+
*
|
|
27
|
+
* Transport is `node:http.request` (`node:https.request` for an `https:` base URL, which honours the
|
|
28
|
+
* process trust store including `NODE_EXTRA_CA_CERTS`) with a fresh connection per call
|
|
29
|
+
* (`agent: false`), never `fetch` and never a pooled keep-alive socket: a reused socket the far side
|
|
30
|
+
* already closed fails with a reset after the write, which would turn a call that never ran into an
|
|
31
|
+
* "outcome unknown" one. There is no idle timeout because a tool may legitimately park on a human for
|
|
32
|
+
* a long time.
|
|
33
|
+
*/
|
|
34
|
+
import { randomUUID } from 'node:crypto';
|
|
35
|
+
import * as http from 'node:http';
|
|
36
|
+
import * as https from 'node:https';
|
|
37
|
+
import { TLSSocket } from 'node:tls';
|
|
38
|
+
import { ToolInputError } from '@jini-ai/core';
|
|
39
|
+
/** A remote tool call that failed in transport. `code` says whether the call may have run; see the module doc. */
|
|
40
|
+
export class ToolRpcError extends Error {
|
|
41
|
+
code;
|
|
42
|
+
constructor({ code, message }, optional = {}) {
|
|
43
|
+
super(message, optional);
|
|
44
|
+
this.name = 'ToolRpcError';
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export const TOOL_EXECUTIONS_ROUTE_PATH = '/internal/tool-executions';
|
|
49
|
+
function isRecord(value) {
|
|
50
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
const TOOL_EXECUTION_STATUSES = new Set(['completed', 'denied', 'confirmation-denied', 'timed-out', 'cancelled', 'failed']);
|
|
53
|
+
/**
|
|
54
|
+
* The shape each line type must carry. A terminal line settles the call, so it must hold every field
|
|
55
|
+
* a settled call needs: `{"type":"result","result":{}}` accepted as-is would record an ordinary
|
|
56
|
+
* result and let a continuation repeat a call that may have committed.
|
|
57
|
+
*/
|
|
58
|
+
const LINE_SHAPES = new Map([
|
|
59
|
+
['surface', (line) => isRecord(line.emission)],
|
|
60
|
+
['result', ({ result }) => isRecord(result) && typeof result.executionId === 'string' && TOOL_EXECUTION_STATUSES.has(result.status)],
|
|
61
|
+
['throw', ({ error }) => isRecord(error) && typeof error.message === 'string'],
|
|
62
|
+
]);
|
|
63
|
+
/** Parses one NDJSON line; `null` means malformed, which the caller treats as outcome unknown. */
|
|
64
|
+
export function parseToolExecutionLine({ text }, _optional = {}) {
|
|
65
|
+
let value;
|
|
66
|
+
try {
|
|
67
|
+
value = JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const valid = isRecord(value) && LINE_SHAPES.get(value.type)?.(value) === true;
|
|
73
|
+
return valid ? value : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Turns a far-side throw back into an error a local caller can branch on. `ToolInputError` keeps its
|
|
77
|
+
* class, because the in-process executor throws it for an unknown tool id and callers test it with
|
|
78
|
+
* `instanceof`. Anything else becomes a plain `Error` carrying the same name, message and code.
|
|
79
|
+
*/
|
|
80
|
+
export function rehydrateToolExecutionError({ error }, _optional = {}) {
|
|
81
|
+
if (error.name === 'ToolInputError')
|
|
82
|
+
return new ToolInputError({ message: error.message });
|
|
83
|
+
const rebuilt = new Error(error.message);
|
|
84
|
+
rebuilt.name = typeof error.name === 'string' ? error.name : 'Error';
|
|
85
|
+
if (typeof error.code === 'string')
|
|
86
|
+
rebuilt.code = error.code;
|
|
87
|
+
return rebuilt;
|
|
88
|
+
}
|
|
89
|
+
function rpcError(code, detail, cause) {
|
|
90
|
+
const prefix = code === 'TOOL_NOT_DISPATCHED' ? 'remote tool call was not dispatched' : 'remote tool call outcome is unknown';
|
|
91
|
+
return new ToolRpcError({ code, message: `${prefix}: ${detail}` }, cause === undefined ? {} : { cause });
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Marks `connected` once the request's socket can carry the request (or immediately for an open one).
|
|
95
|
+
* For TLS that is the finished handshake, not the TCP connect: a refused certificate fails before a
|
|
96
|
+
* byte of the request is written, so nothing ran.
|
|
97
|
+
*/
|
|
98
|
+
function trackConnect(req, onConnect) {
|
|
99
|
+
req.once('socket', (socket) => {
|
|
100
|
+
if (socket.connecting)
|
|
101
|
+
socket.once(socket instanceof TLSSocket ? 'secureConnect' : 'connect', onConnect);
|
|
102
|
+
else
|
|
103
|
+
onConnect();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Opens the POST, or returns `TOOL_NOT_DISPATCHED` when the adapter refuses synchronously (e.g. `ERR_INVALID_PROTOCOL`): nothing was written. */
|
|
107
|
+
function openRequest({ url, token, body, request }, _optional = {}) {
|
|
108
|
+
try {
|
|
109
|
+
return request(url, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
agent: false,
|
|
112
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return rpcError('TOOL_NOT_DISPATCHED', error instanceof Error ? error.message : String(error), error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads the NDJSON stream. Surfaces are forwarded strictly in order and the terminal line settles
|
|
121
|
+
* only after every earlier surface was delivered. A surface the local emitter rejects is dropped: the
|
|
122
|
+
* far handler cannot observe that rejection, and failing the call over a display-only message
|
|
123
|
+
* would turn a committed call into an unknown one.
|
|
124
|
+
*/
|
|
125
|
+
function readToolStream({ res, settle, fail }, { emitSurface }) {
|
|
126
|
+
let buffer = '';
|
|
127
|
+
let terminal = false;
|
|
128
|
+
let surfaces = Promise.resolve();
|
|
129
|
+
const onLine = (text) => {
|
|
130
|
+
if (terminal || text.trim() === '')
|
|
131
|
+
return;
|
|
132
|
+
const line = parseToolExecutionLine({ text });
|
|
133
|
+
if (line === null) {
|
|
134
|
+
terminal = true;
|
|
135
|
+
fail('malformed response line');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (line.type === 'surface') {
|
|
139
|
+
surfaces = surfaces.then(() => emitSurface?.(line.emission)).catch(() => undefined);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
terminal = true;
|
|
143
|
+
void surfaces.then(() => settle(line));
|
|
144
|
+
};
|
|
145
|
+
res.setEncoding('utf8');
|
|
146
|
+
res.on('data', (chunk) => {
|
|
147
|
+
buffer += chunk;
|
|
148
|
+
const lines = buffer.split('\n');
|
|
149
|
+
buffer = lines.pop() ?? '';
|
|
150
|
+
lines.forEach(onLine);
|
|
151
|
+
});
|
|
152
|
+
// Once the terminal line is in hand the outcome is known, so a later reset cannot unsettle it.
|
|
153
|
+
const failUnlessTerminal = (detail) => { if (!terminal)
|
|
154
|
+
fail(detail); };
|
|
155
|
+
res.on('end', () => { onLine(buffer); failUnlessTerminal('response ended without a terminal line'); });
|
|
156
|
+
res.on('aborted', () => failUnlessTerminal('response aborted'));
|
|
157
|
+
res.on('error', (error) => failUnlessTerminal(`response error: ${error.message}`));
|
|
158
|
+
}
|
|
159
|
+
function settleTerminal(line, settlers) {
|
|
160
|
+
if (line.type === 'result')
|
|
161
|
+
settlers.resolve(line.result);
|
|
162
|
+
else
|
|
163
|
+
settlers.reject(rehydrateToolExecutionError({ error: line.error }));
|
|
164
|
+
}
|
|
165
|
+
/** Starts one POST that settles through `settlers`; returns a cancel that destroys it and resolves `cancelled`. */
|
|
166
|
+
function startCall({ url, token, body, executionId, settlers }, { request, emitSurface }) {
|
|
167
|
+
const req = openRequest({ url, token, body, request });
|
|
168
|
+
if (req instanceof ToolRpcError) {
|
|
169
|
+
settlers.reject(req);
|
|
170
|
+
return () => undefined;
|
|
171
|
+
}
|
|
172
|
+
let settled = false;
|
|
173
|
+
let connected = false;
|
|
174
|
+
const finish = (outcome, destroy) => {
|
|
175
|
+
if (settled)
|
|
176
|
+
return;
|
|
177
|
+
settled = true;
|
|
178
|
+
if (destroy)
|
|
179
|
+
req.destroy();
|
|
180
|
+
outcome();
|
|
181
|
+
};
|
|
182
|
+
const unknown = (detail, cause) => finish(() => settlers.reject(rpcError('TOOL_OUTCOME_UNKNOWN', detail, cause)), true);
|
|
183
|
+
trackConnect(req, () => { connected = true; });
|
|
184
|
+
req.on('error', (error) => {
|
|
185
|
+
if (!connected)
|
|
186
|
+
finish(() => settlers.reject(rpcError('TOOL_NOT_DISPATCHED', error.message, error)), true);
|
|
187
|
+
else
|
|
188
|
+
unknown(error.message, error);
|
|
189
|
+
});
|
|
190
|
+
req.on('response', (res) => {
|
|
191
|
+
if (res.statusCode !== 200) {
|
|
192
|
+
res.resume();
|
|
193
|
+
finish(() => settlers.reject(rpcError('TOOL_NOT_DISPATCHED', `HTTP ${res.statusCode ?? 'unknown'}`)), true);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
readToolStream({
|
|
197
|
+
res,
|
|
198
|
+
settle: (line) => finish(() => settleTerminal(line, settlers), false),
|
|
199
|
+
fail: (detail) => unknown(detail),
|
|
200
|
+
}, { emitSurface });
|
|
201
|
+
});
|
|
202
|
+
req.end(body);
|
|
203
|
+
return () => finish(() => settlers.resolve({ executionId, status: 'cancelled' }), true);
|
|
204
|
+
}
|
|
205
|
+
function wireBody({ executionId, principal, run, toolId, input, context }) {
|
|
206
|
+
return JSON.stringify({ executionId, principal, run: { id: run.id }, toolId, input, ...(context === undefined ? {} : { context }) });
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Creates the client `ToolExecutor` for a far `registerToolExecutionRoutes` endpoint.
|
|
210
|
+
*
|
|
211
|
+
* - `execute` streams one call; a caller `signal` (or `cancel` with the id sent on the wire) destroys
|
|
212
|
+
* the request, which the far route turns into its own abort, and settles locally as `cancelled`
|
|
213
|
+
* — the same result an in-process executor gives a cancelled call.
|
|
214
|
+
* - `getAuditRecord` returns `null`: the far side owns the audit trail.
|
|
215
|
+
* - `resumeConfirmation` throws: confirmations park on, and are answered on, the far side.
|
|
216
|
+
*
|
|
217
|
+
* @complexity O(response bytes) per call; `cancel` is an O(1) map lookup.
|
|
218
|
+
*/
|
|
219
|
+
export function createRemoteToolExecutor({ baseUrl, token }, { request: injectedRequest, contextFor } = {}) {
|
|
220
|
+
const url = new URL(TOOL_EXECUTIONS_ROUTE_PATH, baseUrl);
|
|
221
|
+
const request = injectedRequest ?? (url.protocol === 'https:' ? https.request : http.request);
|
|
222
|
+
const inFlight = new Map();
|
|
223
|
+
async function execute({ principal, run, toolId, input }, { signal, emitSurface } = {}) {
|
|
224
|
+
const executionId = randomUUID();
|
|
225
|
+
if (signal?.aborted)
|
|
226
|
+
return { executionId, status: 'cancelled' };
|
|
227
|
+
const body = wireBody({ executionId, principal, run, toolId, input, context: contextFor?.({ principal, run, toolId }) });
|
|
228
|
+
let settlers;
|
|
229
|
+
const promise = new Promise((resolve, reject) => { settlers = { resolve, reject }; });
|
|
230
|
+
const cancel = startCall({ url, token, body, executionId, settlers }, { request, emitSurface });
|
|
231
|
+
inFlight.set(executionId, cancel);
|
|
232
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
233
|
+
try {
|
|
234
|
+
return await promise;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
inFlight.delete(executionId);
|
|
238
|
+
signal?.removeEventListener('abort', cancel);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
execute,
|
|
243
|
+
resumeConfirmation: () => {
|
|
244
|
+
throw new Error('remote tool executions are confirmed on the executing server');
|
|
245
|
+
},
|
|
246
|
+
cancel: ({ executionId }) => inFlight.get(executionId)?.(),
|
|
247
|
+
getAuditRecord: () => null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=remote-tool-executor.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module routing-tool-executor
|
|
3
|
+
*
|
|
4
|
+
* One `ToolExecutor` over two: tools this process registered run locally, every other id goes to a
|
|
5
|
+
* remote executor (`createRemoteToolExecutor`). The routing key is only "is this id in the local
|
|
6
|
+
* registry". Remote descriptors are never registered locally as executable placeholders, so a
|
|
7
|
+
* remote id can never reach a local handler, and an id nobody knows is refused by the far side's
|
|
8
|
+
* executor exactly as an in-process one would refuse it.
|
|
9
|
+
*
|
|
10
|
+
* Execution ids are not tracked here. `cancel` goes to both executors, each of which ignores ids it
|
|
11
|
+
* does not own; `getAuditRecord` asks the local one first (the remote one has none); and
|
|
12
|
+
* `resumeConfirmation` is local-only because remote confirmations park on the far side. That keeps
|
|
13
|
+
* routing correct without a per-call map that would grow for the life of the process.
|
|
14
|
+
*/
|
|
15
|
+
import type { ToolExecutor } from './tool-executor.js';
|
|
16
|
+
export interface RoutingToolExecutorRequired {
|
|
17
|
+
readonly local: ToolExecutor;
|
|
18
|
+
readonly remote: ToolExecutor;
|
|
19
|
+
/** True when `toolId` is in the local registry. */
|
|
20
|
+
readonly isLocal: (required: {
|
|
21
|
+
readonly toolId: string;
|
|
22
|
+
}, optional?: Record<string, never>) => boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Creates the routing executor.
|
|
26
|
+
* @complexity O(1) plus `isLocal`'s own cost per call.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createRoutingToolExecutor({ local, remote, isLocal }: RoutingToolExecutorRequired, _optional?: Record<string, never>): ToolExecutor;
|
|
29
|
+
//# sourceMappingURL=routing-tool-executor.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates the routing executor.
|
|
3
|
+
* @complexity O(1) plus `isLocal`'s own cost per call.
|
|
4
|
+
*/
|
|
5
|
+
export function createRoutingToolExecutor({ local, remote, isLocal }, _optional = {}) {
|
|
6
|
+
return {
|
|
7
|
+
execute: (args, optional) => (isLocal({ toolId: args.toolId }) ? local : remote).execute(args, optional),
|
|
8
|
+
resumeConfirmation: (args) => local.resumeConfirmation(args),
|
|
9
|
+
cancel: (args) => {
|
|
10
|
+
local.cancel(args);
|
|
11
|
+
remote.cancel(args);
|
|
12
|
+
},
|
|
13
|
+
getAuditRecord: (args) => local.getAuditRecord(args) ?? remote.getAuditRecord(args),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=routing-tool-executor.js.map
|
package/dist/run-lifecycle.d.ts
CHANGED
|
@@ -137,9 +137,12 @@ export interface RunLifecycle {
|
|
|
137
137
|
resumeSlowRunNotice(args: {
|
|
138
138
|
readonly runId: string;
|
|
139
139
|
}): void;
|
|
140
|
-
/** Resolves once `runId` reaches a terminal state; resolves immediately if it already has.
|
|
140
|
+
/** Resolves once `runId` reaches a terminal state; resolves immediately if it already has.
|
|
141
|
+
* Aborting `signal` rejects with its reason and unregisters the waiter (a caller tearing down). */
|
|
141
142
|
waitForTerminal(args: {
|
|
142
143
|
readonly runId: string;
|
|
144
|
+
}, optional?: {
|
|
145
|
+
readonly signal?: AbortSignal;
|
|
143
146
|
}): Promise<RunStatus>;
|
|
144
147
|
/**
|
|
145
148
|
* Replays buffered history after `options.afterCursor` (or from the
|
package/dist/run-lifecycle.js
CHANGED
|
@@ -375,6 +375,36 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
|
|
|
375
375
|
// Reported to `start()`'s caller — see above.
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
|
+
/** Settles like `promise`, or rejects with the signal's reason once it aborts; the listener is always released. */
|
|
379
|
+
function abortable({ promise }, { signal }) {
|
|
380
|
+
if (!signal)
|
|
381
|
+
return Promise.resolve(promise);
|
|
382
|
+
if (signal.aborted)
|
|
383
|
+
return Promise.reject(signal.reason);
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
const onAbort = () => { reject(signal.reason); };
|
|
386
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
387
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort); });
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/** Registers a terminal waiter; an abort removes it again so a cancelled wait retains nothing. */
|
|
391
|
+
function parkTerminalWaiter({ record }, { signal }) {
|
|
392
|
+
signal?.throwIfAborted();
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
const onAbort = () => {
|
|
395
|
+
const index = record.terminalWaiters.indexOf(waiter);
|
|
396
|
+
if (index >= 0)
|
|
397
|
+
record.terminalWaiters.splice(index, 1);
|
|
398
|
+
reject(signal?.reason);
|
|
399
|
+
};
|
|
400
|
+
const waiter = (status) => {
|
|
401
|
+
signal?.removeEventListener('abort', onAbort);
|
|
402
|
+
resolve(status);
|
|
403
|
+
};
|
|
404
|
+
record.terminalWaiters.push(waiter);
|
|
405
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
406
|
+
});
|
|
407
|
+
}
|
|
378
408
|
function resolveTerminalWaiters(record) {
|
|
379
409
|
const waiters = record.terminalWaiters.splice(0, record.terminalWaiters.length);
|
|
380
410
|
const status = toPublicStatus(record);
|
|
@@ -792,20 +822,20 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
|
|
|
792
822
|
void handleSlowRunNotice(runId);
|
|
793
823
|
} });
|
|
794
824
|
},
|
|
795
|
-
async waitForTerminal({ runId }) {
|
|
825
|
+
async waitForTerminal({ runId }, { signal } = {}) {
|
|
796
826
|
const record = requireRun(runId);
|
|
797
827
|
// Awaited (and *propagated*, unlike in `get`/`list`) before any waiter is registered. A run
|
|
798
828
|
// whose durable start append rejects is unwound, and nothing ever calls `finish()` for it — so
|
|
799
829
|
// a waiter parked inside that window would never be resolved by anyone. Failing the wait with
|
|
800
830
|
// the same error that failed the start is the only honest terminal answer, and awaiting first
|
|
801
831
|
// means `terminalWaiters` can only ever hold waiters for a run that really does exist.
|
|
802
|
-
|
|
832
|
+
// The abort signal is observed during that wait too: a stalled start append must not strand
|
|
833
|
+
// a caller that has already given up (a pre-aborted signal rejects at once).
|
|
834
|
+
await abortable({ promise: record.startPromise }, { signal });
|
|
803
835
|
if (isTerminalRunState({ state: record.status.state })) {
|
|
804
836
|
return toPublicStatus(record);
|
|
805
837
|
}
|
|
806
|
-
return
|
|
807
|
-
record.terminalWaiters.push(resolve);
|
|
808
|
-
});
|
|
838
|
+
return parkTerminalWaiter({ record }, { signal });
|
|
809
839
|
},
|
|
810
840
|
async stream({ runId, onEvent }, options = {}) {
|
|
811
841
|
const record = runs.get(runId);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { StorageKernel } from '@jini-ai/db/kernel';
|
|
2
|
+
import { type SqliteEventLogDatabase, type SqliteEventLogOptions, type SqliteEventLogWithReceipts } from './sqlite-policy.js';
|
|
3
|
+
/**
|
|
4
|
+
* Borrow a SQLite kernel, awaiting its readiness and optionally ensuring the owner's schema.
|
|
5
|
+
* Every operation joins the caller's kernel context; append/drop/replay use kernel transactions.
|
|
6
|
+
* Refusals are values: hosts aborting a composed operation must use their exception/rollback path.
|
|
7
|
+
* Close leaves the borrowed kernel open. Driver, readiness and serialization failures propagate.
|
|
8
|
+
* @param required Host kernel with the event-log table subset.
|
|
9
|
+
* @param optional Schema setup, retention cap and clock; defaults match the synchronous adapter.
|
|
10
|
+
* @returns Concrete log with optional conflict/capacity receipts.
|
|
11
|
+
* @complexity Setup uses a fixed number of statements; replay is O(n) in retained entries.
|
|
12
|
+
* @example
|
|
13
|
+
* const log = await createKernelSqliteEventLog({ kernel }, { schemaSetup: 'host' });
|
|
14
|
+
*/
|
|
15
|
+
export declare function createKernelSqliteEventLog<DB extends SqliteEventLogDatabase>(required: {
|
|
16
|
+
kernel: StorageKernel<DB>;
|
|
17
|
+
}, optional?: SqliteEventLogOptions): Promise<SqliteEventLogWithReceipts>;
|
|
18
|
+
//# sourceMappingURL=sqlite-kernel.d.ts.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Async SQLite execution over the host kernel; event-log policy lives in sqlite-policy. */
|
|
2
|
+
import { createSystemClock } from '@jini-ai/core/primitives';
|
|
3
|
+
import { SQLITE_EVENT_LOG_SCHEMA, resolveMaxEntriesPerRun, assertValidReplayCursor, existingAppendResult, replayResult, rowToEntry, } from './sqlite-policy.js';
|
|
4
|
+
/**
|
|
5
|
+
* Borrow a SQLite kernel, awaiting its readiness and optionally ensuring the owner's schema.
|
|
6
|
+
* Every operation joins the caller's kernel context; append/drop/replay use kernel transactions.
|
|
7
|
+
* Refusals are values: hosts aborting a composed operation must use their exception/rollback path.
|
|
8
|
+
* Close leaves the borrowed kernel open. Driver, readiness and serialization failures propagate.
|
|
9
|
+
* @param required Host kernel with the event-log table subset.
|
|
10
|
+
* @param optional Schema setup, retention cap and clock; defaults match the synchronous adapter.
|
|
11
|
+
* @returns Concrete log with optional conflict/capacity receipts.
|
|
12
|
+
* @complexity Setup uses a fixed number of statements; replay is O(n) in retained entries.
|
|
13
|
+
* @example
|
|
14
|
+
* const log = await createKernelSqliteEventLog({ kernel }, { schemaSetup: 'host' });
|
|
15
|
+
*/
|
|
16
|
+
export async function createKernelSqliteEventLog(required, optional = {}) {
|
|
17
|
+
const { kernel } = required;
|
|
18
|
+
const maxEntriesPerRun = resolveMaxEntriesPerRun(optional.maxEntriesPerRun);
|
|
19
|
+
if (!kernel || kernel.dialect !== 'sqlite' || kernel.transport !== 'better-sqlite3') {
|
|
20
|
+
throw new Error('createKernelSqliteEventLog: pass a SQLite better-sqlite3 kernel');
|
|
21
|
+
}
|
|
22
|
+
await kernel.ready;
|
|
23
|
+
// Narrow the query type to required tables; keep the same kernel and its async transaction scope.
|
|
24
|
+
const run = (body) => kernel.run(db => body(db));
|
|
25
|
+
await run(db => setupSchema({ db }, optional));
|
|
26
|
+
const clock = optional.clock ?? createSystemClock();
|
|
27
|
+
const appendWithResult = (input, options = {}) => kernel.transaction(() => run(db => appendInKernel({ db, input, clock, maxEntriesPerRun }, options)));
|
|
28
|
+
return {
|
|
29
|
+
appendWithResult,
|
|
30
|
+
async append(input, options = {}) {
|
|
31
|
+
// Select only the protocol's dedupe option so the legacy method cannot opt into refusal.
|
|
32
|
+
const result = await appendWithResult(input, options.dedupeKey === undefined ? {} : { dedupeKey: options.dedupeKey });
|
|
33
|
+
return result.entry;
|
|
34
|
+
},
|
|
35
|
+
async replay({ runId, afterCursor }) {
|
|
36
|
+
// Validate even when the run is unknown, matching the synchronous adapter.
|
|
37
|
+
assertValidReplayCursor(afterCursor);
|
|
38
|
+
// One statement for all three reads: another process cannot evict between the gap check
|
|
39
|
+
// and the entry read, which would otherwise return a gap reported as ok. A statement, not a
|
|
40
|
+
// transaction, so replay works on read-only kernels and inside a host's run() body.
|
|
41
|
+
return run(db => replayInKernel({ db, runId, afterCursor }));
|
|
42
|
+
},
|
|
43
|
+
listRunIds(_args) {
|
|
44
|
+
return run(async (db) => (await db.selectFrom('jini_event_log_runs').select('run_id')
|
|
45
|
+
.orderBy('run_id').execute()).map(row => row.run_id));
|
|
46
|
+
},
|
|
47
|
+
drop({ runId }) {
|
|
48
|
+
return kernel.transaction(() => run(async (db) => {
|
|
49
|
+
await db.deleteFrom('jini_event_log_entries').where('run_id', '=', runId).execute();
|
|
50
|
+
await db.deleteFrom('jini_event_log_runs').where('run_id', '=', runId).execute();
|
|
51
|
+
}));
|
|
52
|
+
},
|
|
53
|
+
async close(_args) { },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Ensure using the shared DDL, or validate all required columns without issuing DDL/writes. */
|
|
57
|
+
async function setupSchema({ db }, options = {}) {
|
|
58
|
+
if (options.schemaSetup !== 'host') {
|
|
59
|
+
// Keep the existing synchronous factory free of a new eager peer import.
|
|
60
|
+
const { sql } = await import('kysely');
|
|
61
|
+
// Kysely executes one statement at a time; the owner DDL contains no embedded semicolons.
|
|
62
|
+
for (const statement of SQLITE_EVENT_LOG_SCHEMA.split(';').filter(part => part.trim())) {
|
|
63
|
+
await sql.raw(statement).execute(db);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await db.selectFrom('jini_event_log_runs').select(['run_id', 'next_cursor']).limit(0).execute();
|
|
67
|
+
await db.selectFrom('jini_event_log_entries')
|
|
68
|
+
.select(['run_id', 'cursor', 'event', 'data', 'recorded_at', 'dedupe_key']).limit(0).execute();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Dedupe precedes the cap; refusals allocate nothing. The caller holds a kernel transaction,
|
|
72
|
+
* which serializes SQLite cursor allocation with projection writes and concurrent appends.
|
|
73
|
+
* @complexity Fixed statement count; count/eviction scan the bounded per-run retained rows.
|
|
74
|
+
*/
|
|
75
|
+
async function appendInKernel({ db, input, clock, maxEntriesPerRun }, options = {}) {
|
|
76
|
+
if (options.dedupeKey !== undefined) {
|
|
77
|
+
const row = await db.selectFrom('jini_event_log_entries').selectAll()
|
|
78
|
+
.where('run_id', '=', input.runId).where('dedupe_key', '=', options.dedupeKey).executeTakeFirst();
|
|
79
|
+
if (row)
|
|
80
|
+
return existingAppendResult({ row, input }, options);
|
|
81
|
+
}
|
|
82
|
+
const { count } = await db.selectFrom('jini_event_log_entries')
|
|
83
|
+
.select(db.fn.countAll().as('count')).where('run_id', '=', input.runId).executeTakeFirstOrThrow();
|
|
84
|
+
if (options.retentionPolicy === 'reject' && count >= maxEntriesPerRun) {
|
|
85
|
+
return { kind: 'capacity-exceeded', maxEntriesPerRun };
|
|
86
|
+
}
|
|
87
|
+
let run = await db.selectFrom('jini_event_log_runs').selectAll()
|
|
88
|
+
.where('run_id', '=', input.runId).executeTakeFirst();
|
|
89
|
+
if (!run) {
|
|
90
|
+
run = { run_id: input.runId, next_cursor: 1 };
|
|
91
|
+
await db.insertInto('jini_event_log_runs').values(run).execute();
|
|
92
|
+
}
|
|
93
|
+
const cursor = run.next_cursor;
|
|
94
|
+
const recordedAt = clock.nowMs();
|
|
95
|
+
await db.insertInto('jini_event_log_entries').values({
|
|
96
|
+
run_id: input.runId, cursor, event: input.event, data: JSON.stringify(input.data ?? null),
|
|
97
|
+
recorded_at: recordedAt, dedupe_key: options.dedupeKey ?? null,
|
|
98
|
+
}).execute();
|
|
99
|
+
await db.updateTable('jini_event_log_runs').set({ next_cursor: cursor + 1 })
|
|
100
|
+
.where('run_id', '=', input.runId).execute();
|
|
101
|
+
if (count + 1 > maxEntriesPerRun) {
|
|
102
|
+
await db.deleteFrom('jini_event_log_entries').where('run_id', '=', input.runId)
|
|
103
|
+
.where('cursor', 'in', db.selectFrom('jini_event_log_entries').select('cursor')
|
|
104
|
+
.where('run_id', '=', input.runId).orderBy('cursor').limit(count + 1 - maxEntriesPerRun)).execute();
|
|
105
|
+
}
|
|
106
|
+
return { kind: 'inserted', entry: { id: String(cursor), event: input.event, data: input.data, recordedAt } };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Read the run, its oldest retained cursor and the entries after the cursor in ONE statement
|
|
110
|
+
* (SQLite reads a statement from one snapshot), then decide gaps/truncation from that snapshot.
|
|
111
|
+
* A gap loads payloads it then discards; the price of needing no transaction.
|
|
112
|
+
* @complexity One statement; O(n) in retained entries after the cursor.
|
|
113
|
+
*/
|
|
114
|
+
async function replayInKernel({ db, runId, afterCursor }) {
|
|
115
|
+
// Cursors start at 1, so "after 0" is every retained entry.
|
|
116
|
+
const after = afterCursor === null ? 0 : Number(afterCursor);
|
|
117
|
+
const rows = await db.selectFrom('jini_event_log_runs as r')
|
|
118
|
+
.leftJoin('jini_event_log_entries as e', join => join.onRef('e.run_id', '=', 'r.run_id').on('e.cursor', '>', after))
|
|
119
|
+
.select(eb => [
|
|
120
|
+
'r.next_cursor', 'e.cursor', 'e.event', 'e.data', 'e.recorded_at',
|
|
121
|
+
eb.selectFrom('jini_event_log_entries').select(eb.fn.min('cursor').as('m'))
|
|
122
|
+
.where('run_id', '=', runId).as('oldest_cursor'),
|
|
123
|
+
])
|
|
124
|
+
.where('r.run_id', '=', runId).orderBy('e.cursor').execute();
|
|
125
|
+
const [first] = rows;
|
|
126
|
+
if (!first)
|
|
127
|
+
return { kind: 'unknown-run' };
|
|
128
|
+
const oldest = first.oldest_cursor === null ? undefined : { cursor: first.oldest_cursor };
|
|
129
|
+
const result = replayResult({ run: { run_id: runId, next_cursor: first.next_cursor }, oldest, afterCursor });
|
|
130
|
+
if (result.kind !== 'ok')
|
|
131
|
+
return result;
|
|
132
|
+
const entries = rows.flatMap(({ cursor, event, data, recorded_at }) => cursor === null || event === null || data === null || recorded_at === null ? [] : [rowToEntry({ cursor, event, data, recorded_at })]);
|
|
133
|
+
return { ...result, entries };
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=sqlite-kernel.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Shared event-log schema and policies; synchronous and kernel execution retain distinct transactions. */
|
|
2
|
+
import type { Clock } from '@jini-ai/core/primitives';
|
|
3
|
+
import type { EventLog, EventLogAppendInput, EventLogAppendOptions, EventLogEntry, EventLogReplayResult } from '@jini-ai/protocol';
|
|
4
|
+
/**
|
|
5
|
+
* Default per-run retention cap applied when `SqliteEventLogOptions.maxEntriesPerRun` is
|
|
6
|
+
* omitted — see that option's doc for why a durable adapter defaults to a bound instead of
|
|
7
|
+
* mirroring the in-memory reference adapter's unbounded-by-default behavior.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_MAX_ENTRIES_PER_RUN = 2000;
|
|
10
|
+
export interface SqliteEventLogOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Maximum entries retained per run before the oldest are evicted. Defaults to
|
|
13
|
+
* {@link DEFAULT_MAX_ENTRIES_PER_RUN} (2000) when omitted — unlike the in-memory reference
|
|
14
|
+
* adapter (`@jini-ai/daemon`'s `createInMemoryEventLog`), where an omitted cap means unbounded,
|
|
15
|
+
* a durable on-disk store defaults to a bounded retention window because unbounded persistent
|
|
16
|
+
* growth is a real, unattended operational risk (disk exhaustion across long-lived runs) in a
|
|
17
|
+
* way an in-process, GC'd, process-lifetime-bounded structure is not. Pass an explicit value
|
|
18
|
+
* (larger, smaller, or `0`) to override the default in either direction — the caller then
|
|
19
|
+
* owns that tradeoff explicitly. Must be a non-negative safe integer; anything else (negative,
|
|
20
|
+
* fractional, `NaN`, `Infinity`) throws at construction time.
|
|
21
|
+
*/
|
|
22
|
+
readonly maxEntriesPerRun?: number;
|
|
23
|
+
/** Core Clock for durable recordedAt values; omitted clock preserves system wall time. */
|
|
24
|
+
readonly clock?: Clock;
|
|
25
|
+
/** Ensure tables by default; host skips DDL and requires an already migrated schema. */
|
|
26
|
+
readonly schemaSetup?: 'ensure' | 'host';
|
|
27
|
+
}
|
|
28
|
+
/** @internal Validates and resolves `options.maxEntriesPerRun`, applying the documented default. */
|
|
29
|
+
export declare function resolveMaxEntriesPerRun(value: number | undefined): number;
|
|
30
|
+
/** @internal Validates a `replay(runId, afterCursor)` cursor. `null` ("from the beginning") is always valid. */
|
|
31
|
+
export declare function assertValidReplayCursor(afterCursor: string | null): void;
|
|
32
|
+
/** A `@jini-ai/daemon` `EventLog` backed by a `better-sqlite3` database, plus a `close()` to release the file handle. */
|
|
33
|
+
export interface SqliteEventLog extends EventLog {
|
|
34
|
+
close(_args: Record<string, never>): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export interface RunRow {
|
|
37
|
+
run_id: string;
|
|
38
|
+
next_cursor: number;
|
|
39
|
+
}
|
|
40
|
+
export interface EntryRow {
|
|
41
|
+
run_id: string;
|
|
42
|
+
cursor: number;
|
|
43
|
+
event: string;
|
|
44
|
+
data: string;
|
|
45
|
+
recorded_at: number;
|
|
46
|
+
dedupe_key: string | null;
|
|
47
|
+
}
|
|
48
|
+
/** Decode the persisted JSON boundary while preserving the original cursor and timestamp. */
|
|
49
|
+
export declare function rowToEntry(row: Pick<EntryRow, 'cursor' | 'event' | 'data' | 'recorded_at'>): EventLogEntry;
|
|
50
|
+
/** The existing event-log tables; hosts may extend this database with their own tables. */
|
|
51
|
+
export interface SqliteEventLogDatabase {
|
|
52
|
+
jini_event_log_runs: RunRow;
|
|
53
|
+
jini_event_log_entries: EntryRow;
|
|
54
|
+
}
|
|
55
|
+
/** Opt-in append policies; omitted options preserve legacy dedupe and FIFO retention. */
|
|
56
|
+
export type SqliteEventLogAppendOptions = EventLogAppendOptions & {
|
|
57
|
+
detectConflicts?: boolean;
|
|
58
|
+
retentionPolicy?: 'evict-oldest' | 'reject';
|
|
59
|
+
};
|
|
60
|
+
/** Refusals carry no entry because no write or cursor allocation took place. */
|
|
61
|
+
export type SqliteEventLogAppendResult<P> = {
|
|
62
|
+
kind: 'inserted' | 'existing';
|
|
63
|
+
entry: EventLogEntry<P>;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'conflict';
|
|
66
|
+
} | {
|
|
67
|
+
kind: 'capacity-exceeded';
|
|
68
|
+
maxEntriesPerRun: number;
|
|
69
|
+
};
|
|
70
|
+
/** Concrete factories expose receipts without widening the protocol's append return type. */
|
|
71
|
+
export interface SqliteEventLogWithReceipts extends SqliteEventLog {
|
|
72
|
+
/**
|
|
73
|
+
* Compare exact persisted JSON only when detectConflicts is true; dedupe precedes capacity.
|
|
74
|
+
* SQL/serialization failures reject. Conflicts/capacity refusals leave all state untouched.
|
|
75
|
+
* @example
|
|
76
|
+
* await log.appendWithResult({ runId: 'r', event: 'update', data: 1 }, { dedupeKey: 'k', detectConflicts: true });
|
|
77
|
+
*/
|
|
78
|
+
appendWithResult<P>(required: EventLogAppendInput<P>, optional?: SqliteEventLogAppendOptions): Promise<SqliteEventLogAppendResult<P>>;
|
|
79
|
+
}
|
|
80
|
+
/** Resolve an existing row without writes; legacy retries do not serialize the new payload. */
|
|
81
|
+
export declare function existingAppendResult<P>({ row, input }: {
|
|
82
|
+
row: EntryRow;
|
|
83
|
+
input: EventLogAppendInput<P>;
|
|
84
|
+
}, options?: SqliteEventLogAppendOptions): SqliteEventLogAppendResult<P>;
|
|
85
|
+
/** Decide replay status before loading payloads, keeping a replay gap to indexed lookups only. */
|
|
86
|
+
export declare function replayResult({ run, oldest, afterCursor }: {
|
|
87
|
+
run: RunRow;
|
|
88
|
+
oldest: Pick<EntryRow, 'cursor'> | undefined;
|
|
89
|
+
afterCursor: string | null;
|
|
90
|
+
}): EventLogReplayResult;
|
|
91
|
+
/** Owner's DDL for both automatic setup and host-managed migrations; no schema fork. */
|
|
92
|
+
export declare const SQLITE_EVENT_LOG_SCHEMA: string;
|
|
93
|
+
//# sourceMappingURL=sqlite-policy.d.ts.map
|