kibi-cli 0.18.1 → 0.19.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/cli-json-command.d.ts +1 -2
- package/dist/cli-json-command.d.ts.map +1 -1
- package/dist/cli-json-command.js +2 -34
- package/dist/cli-operation-loader.d.ts +3 -0
- package/dist/cli-operation-loader.d.ts.map +1 -0
- package/dist/cli-operation-loader.js +58 -0
- package/dist/cli-operation-metadata.d.ts +82 -0
- package/dist/cli-operation-metadata.d.ts.map +1 -0
- package/dist/cli-operation-metadata.js +106 -0
- package/dist/cli-protocol.d.ts +1 -1
- package/dist/cli-protocol.d.ts.map +1 -1
- package/dist/cli-protocol.js +4 -3
- package/dist/cli-register-foundation.d.ts.map +1 -1
- package/dist/cli-register-foundation.js +14 -17
- package/dist/cli-register-json.d.ts +3 -0
- package/dist/cli-register-json.d.ts.map +1 -0
- package/dist/cli-register-json.js +35 -0
- package/dist/cli-register-maintenance.d.ts.map +1 -1
- package/dist/cli-register-maintenance.js +35 -10
- package/dist/cli-register-reporting.d.ts.map +1 -1
- package/dist/cli-register-reporting.js +13 -14
- package/dist/cli-register-skills.d.ts.map +1 -1
- package/dist/cli-register-skills.js +8 -6
- package/dist/cli.js +1 -1
- package/dist/commands/check.d.ts.map +1 -1
- package/dist/commands/check.js +30 -12
- package/dist/commands/coverage.js +1 -1
- package/dist/commands/discovery-shared.d.ts +2 -1
- package/dist/commands/discovery-shared.d.ts.map +1 -1
- package/dist/commands/discovery-shared.js +35 -13
- package/dist/commands/engine.d.ts +8 -0
- package/dist/commands/engine.d.ts.map +1 -0
- package/dist/commands/engine.js +100 -0
- package/dist/commands/gaps.js +1 -1
- package/dist/commands/graph.js +1 -1
- package/dist/commands/query.d.ts.map +1 -1
- package/dist/commands/query.js +3 -1
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +3 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +2 -1
- package/dist/commands/sync/cache.d.ts +10 -0
- package/dist/commands/sync/cache.d.ts.map +1 -1
- package/dist/commands/sync/cache.js +30 -1
- package/dist/commands/sync/persistence.d.ts +10 -1
- package/dist/commands/sync/persistence.d.ts.map +1 -1
- package/dist/commands/sync/persistence.js +220 -44
- package/dist/commands/sync/staging.d.ts +10 -1
- package/dist/commands/sync/staging.d.ts.map +1 -1
- package/dist/commands/sync/staging.js +84 -1
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +450 -13
- package/dist/engine-daemon.d.ts +3 -0
- package/dist/engine-daemon.d.ts.map +1 -0
- package/dist/engine-daemon.js +34 -0
- package/dist/engine.d.ts +63 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +1256 -0
- package/dist/extractors/relationships.d.ts +2 -0
- package/dist/extractors/relationships.d.ts.map +1 -1
- package/dist/extractors/relationships.js +15 -17
- package/dist/prolog.d.ts +8 -0
- package/dist/prolog.d.ts.map +1 -1
- package/dist/prolog.js +39 -8
- package/dist/public/operations/discovery-entities.d.ts.map +1 -1
- package/dist/public/operations/discovery-entities.js +3 -2
- package/dist/public/operations/discovery-executors.d.ts.map +1 -1
- package/dist/public/operations/discovery-executors.js +40 -3
- package/dist/public/operations/prolog-json.d.ts.map +1 -1
- package/dist/public/operations/prolog-json.js +14 -4
- package/dist/public/operations/runtime-types.d.ts +31 -0
- package/dist/public/operations/runtime-types.d.ts.map +1 -1
- package/dist/public/skills/init-kibi/SKILL.md +2 -2
- package/dist/public/skills/kibi-freshness/SKILL.md +2 -2
- package/dist/public/skills/kibi-traceability/SKILL.md +1 -1
- package/dist/public/skills/kibi-usage/SKILL.md +2 -2
- package/dist/public/skills/kibi-usage/resources/operation-access.md +1 -1
- package/dist/runtime/cli-runtime.d.ts.map +1 -1
- package/dist/runtime/cli-runtime.js +33 -37
- package/package.json +8 -4
- package/src/public/operations/discovery-entities.ts +3 -2
- package/src/public/operations/discovery-executors.ts +44 -3
- package/src/public/operations/prolog-json.ts +15 -3
- package/src/public/operations/runtime-types.ts +39 -0
- package/src/public/skills/init-kibi/SKILL.md +2 -2
- package/src/public/skills/kibi-freshness/SKILL.md +2 -2
- package/src/public/skills/kibi-traceability/SKILL.md +1 -1
- package/src/public/skills/kibi-usage/SKILL.md +2 -2
- package/src/public/skills/kibi-usage/resources/operation-access.md +1 -1
package/dist/engine.js
ADDED
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Kibi — single-writer workspace engine and framed RPC client.
|
|
3
|
+
*
|
|
4
|
+
* The engine deliberately owns one interactive SWI-Prolog process for one
|
|
5
|
+
* workspace/branch. CLI and MCP callers only exchange typed envelopes over
|
|
6
|
+
* the local socket; Prolog itself never crosses the process boundary.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
11
|
+
import * as net from "node:net";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { PrologProcess, resolveKbPlPath } from "./prolog.js";
|
|
16
|
+
import { parseEntityFromList, parseListOfLists } from "./prolog/codec.js";
|
|
17
|
+
import { isValidBranchName } from "./utils/branch-resolver.js";
|
|
18
|
+
export const ENGINE_PROTOCOL_VERSION = 1;
|
|
19
|
+
export const ENGINE_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
20
|
+
export const ENGINE_PACKAGE_VERSIONS = process.env.KIBI_PACKAGE_VERSIONS ?? "unknown";
|
|
21
|
+
const ENGINE_QUERY_CACHE_MAX_ENTRIES = 128;
|
|
22
|
+
const ENGINE_QUERY_CACHE_MAX_RESULT_BYTES = 8 * 1024 * 1024;
|
|
23
|
+
const ENGINE_FRESHNESS_CACHE_MS = 100;
|
|
24
|
+
function engineIdleTimeoutMs() {
|
|
25
|
+
const configured = Number.parseInt(process.env.KIBI_ENGINE_IDLE_TIMEOUT_MS ?? "", 10);
|
|
26
|
+
return Number.isFinite(configured) && configured >= 100
|
|
27
|
+
? configured
|
|
28
|
+
: ENGINE_IDLE_TIMEOUT_MS;
|
|
29
|
+
}
|
|
30
|
+
function quoteProlog(value) {
|
|
31
|
+
return value.replaceAll("'", "''");
|
|
32
|
+
}
|
|
33
|
+
function cacheableEngineGoal(goal) {
|
|
34
|
+
const normalized = goal.trim();
|
|
35
|
+
if (!normalized ||
|
|
36
|
+
normalized.startsWith("(") ||
|
|
37
|
+
normalized.includes("kb_storage_status")) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
// The daemon cache is deliberately limited to deterministic discovery and
|
|
41
|
+
// graph reads. Status/freshness reads external files and writes invalidate
|
|
42
|
+
// the entire cache before they can be observed by another client.
|
|
43
|
+
return (normalized.startsWith("findall(") ||
|
|
44
|
+
normalized.startsWith("kb_entity(") ||
|
|
45
|
+
normalized.startsWith("kb_entities_by_source(") ||
|
|
46
|
+
normalized.startsWith("kb_relationship("));
|
|
47
|
+
}
|
|
48
|
+
function mutatingEngineGoal(goal) {
|
|
49
|
+
return /(?:kb_(?:assert|retract|commit|save|delete)|rdf_transaction|rdf_assert|rdf_retract)/.test(goal);
|
|
50
|
+
}
|
|
51
|
+
function safeEngineGoal(goal) {
|
|
52
|
+
// The socket is a local capability boundary, not a general-purpose SWI
|
|
53
|
+
// console. Public clients may compose the typed Kibi predicates needed by
|
|
54
|
+
// the 18 operation contracts, but process/filesystem/network escape hatches
|
|
55
|
+
// are rejected before they reach Prolog.
|
|
56
|
+
if (/\b(?:halt|abort|shell|system|process_create|consult|load_files|open|close|delete_file|rename_file|make_directory|thread_create|rdf_attach_db|rdf_load|rdf_save|assertz|asserta|retractall)\s*\(/.test(goal)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return /(?:^|[(:,\s])(?:kb_|checks:|status:|discovery:|requirement_proof:|findall\(|aggregate_all\(|rdf_transaction\(|set_prolog_flag\(|use_module\(|atom_json_dict\(|member\(|once\(|true\b|fail\b)/.test(goal.trim());
|
|
60
|
+
}
|
|
61
|
+
function frame(value) {
|
|
62
|
+
const payload = Buffer.from(JSON.stringify(value), "utf8");
|
|
63
|
+
const header = Buffer.allocUnsafe(4);
|
|
64
|
+
header.writeUInt32BE(payload.byteLength, 0);
|
|
65
|
+
return Buffer.concat([header, payload]);
|
|
66
|
+
}
|
|
67
|
+
function writeSocketFrame(socket, value) {
|
|
68
|
+
if (socket.destroyed || !socket.writable)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
socket.write(frame(value));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A client may disconnect while a queued request is finishing. The
|
|
75
|
+
// journal transaction remains authoritative; there is no response to
|
|
76
|
+
// deliver to a closed peer.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function parseFrames(buffer, onFrame) {
|
|
80
|
+
let cursor = 0;
|
|
81
|
+
while (buffer.byteLength - cursor >= 4) {
|
|
82
|
+
const length = buffer.readUInt32BE(cursor);
|
|
83
|
+
if (length > 64 * 1024 * 1024) {
|
|
84
|
+
throw new Error("Kibi engine frame exceeds 64 MiB limit");
|
|
85
|
+
}
|
|
86
|
+
if (buffer.byteLength - cursor - 4 < length)
|
|
87
|
+
break;
|
|
88
|
+
const payload = buffer.subarray(cursor + 4, cursor + 4 + length);
|
|
89
|
+
onFrame(JSON.parse(payload.toString("utf8")));
|
|
90
|
+
cursor += 4 + length;
|
|
91
|
+
}
|
|
92
|
+
return buffer.subarray(cursor);
|
|
93
|
+
}
|
|
94
|
+
function runtimeDirectory() {
|
|
95
|
+
const configured = process.env.KIBI_RUNTIME_DIR ??
|
|
96
|
+
process.env.XDG_RUNTIME_DIR ??
|
|
97
|
+
path.join(os.tmpdir(), "kibi-runtime");
|
|
98
|
+
const candidates = [configured, path.join(os.tmpdir(), "kibi-runtime")];
|
|
99
|
+
for (const candidate of candidates) {
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(candidate, { recursive: true, mode: 0o700 });
|
|
102
|
+
// XDG_RUNTIME_DIR can exist but be mounted read-only (for example in a
|
|
103
|
+
// constrained container). Probe the directory before using it for the
|
|
104
|
+
// socket, start lock, and pid files so auto-start fails over cleanly.
|
|
105
|
+
const probe = path.join(candidate, `.kibi-write-${process.pid}`);
|
|
106
|
+
writeFileSync(probe, "", { mode: 0o600 });
|
|
107
|
+
unlinkSync(probe);
|
|
108
|
+
return candidate;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Try the private system-temp fallback below.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw new Error("Unable to create a writable Kibi engine runtime directory");
|
|
115
|
+
}
|
|
116
|
+
function fsyncPath(filePath) {
|
|
117
|
+
if (!existsSync(filePath))
|
|
118
|
+
return;
|
|
119
|
+
let descriptor = null;
|
|
120
|
+
try {
|
|
121
|
+
descriptor = openSync(filePath, "r");
|
|
122
|
+
fsyncSync(descriptor);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
if (descriptor !== null)
|
|
126
|
+
closeSync(descriptor);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Cross the kernel durability boundary before a journaled mutation is
|
|
131
|
+
* acknowledged. SWI flushes completed RDF transaction records to its journal
|
|
132
|
+
* stream; fsyncing the journal and atomic CURRENT pointer makes that flush
|
|
133
|
+
* survive a host crash as well as a process crash.
|
|
134
|
+
*/
|
|
135
|
+
// implements REQ-core-journaled-engine-persistence
|
|
136
|
+
export function fsyncJournaledBranchStore(branchPath) {
|
|
137
|
+
const rdfRoot = path.join(branchPath, "rdf");
|
|
138
|
+
const directories = [];
|
|
139
|
+
const pending = existsSync(rdfRoot) ? [rdfRoot] : [];
|
|
140
|
+
while (pending.length > 0) {
|
|
141
|
+
const directory = pending.pop();
|
|
142
|
+
if (directory === undefined)
|
|
143
|
+
break;
|
|
144
|
+
directories.push(directory);
|
|
145
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
146
|
+
const entryPath = path.join(directory, entry.name);
|
|
147
|
+
if (entry.isDirectory())
|
|
148
|
+
pending.push(entryPath);
|
|
149
|
+
else if (entry.isFile() && entry.name !== "lock")
|
|
150
|
+
fsyncPath(entryPath);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const metadata of ["CURRENT", "storage.json", "kb.rdf"]) {
|
|
154
|
+
fsyncPath(path.join(branchPath, metadata));
|
|
155
|
+
}
|
|
156
|
+
// Publish directory entries from the leaves upward, ending at the branch
|
|
157
|
+
// directory that owns CURRENT and the storage marker.
|
|
158
|
+
if (process.platform !== "win32") {
|
|
159
|
+
for (const directory of directories.reverse())
|
|
160
|
+
fsyncPath(directory);
|
|
161
|
+
fsyncPath(branchPath);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// implements REQ-core-journaled-engine-persistence
|
|
165
|
+
export function engineSocketPath(workspaceRoot, branch) {
|
|
166
|
+
const canonicalRoot = (() => {
|
|
167
|
+
try {
|
|
168
|
+
return realpathSync(workspaceRoot);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return path.resolve(workspaceRoot);
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
const key = createHash("sha256")
|
|
175
|
+
.update(`${canonicalRoot}\0${branch}`)
|
|
176
|
+
// 128 bits keeps collision risk negligible while staying below the
|
|
177
|
+
// roughly 108-byte sockaddr_un limit even in a nested XDG runtime path.
|
|
178
|
+
.digest("hex")
|
|
179
|
+
.slice(0, 32);
|
|
180
|
+
if (process.platform === "win32") {
|
|
181
|
+
return `\\\\.\\pipe\\kibi-${key}`;
|
|
182
|
+
}
|
|
183
|
+
return path.join(runtimeDirectory(), `kibi-${key}.sock`);
|
|
184
|
+
}
|
|
185
|
+
export function enginePidPath(workspaceRoot, branch) {
|
|
186
|
+
const socket = engineSocketPath(workspaceRoot, branch);
|
|
187
|
+
if (process.platform === "win32") {
|
|
188
|
+
return path.join(runtimeDirectory(), `kibi-${createHash("sha256")
|
|
189
|
+
.update(`${path.resolve(workspaceRoot)}\0${branch}`)
|
|
190
|
+
.digest("hex")}.pid`);
|
|
191
|
+
}
|
|
192
|
+
return `${socket}.pid`;
|
|
193
|
+
}
|
|
194
|
+
export function engineStartLockPath(workspaceRoot, branch) {
|
|
195
|
+
if (process.platform === "win32") {
|
|
196
|
+
return path.join(runtimeDirectory(), `kibi-${createHash("sha256")
|
|
197
|
+
.update(`${path.resolve(workspaceRoot)}\0${branch}`)
|
|
198
|
+
.digest("hex")}.start.lock`);
|
|
199
|
+
}
|
|
200
|
+
return `${engineSocketPath(workspaceRoot, branch)}.start.lock`;
|
|
201
|
+
}
|
|
202
|
+
function tryAcquireStartLock(lockPath) {
|
|
203
|
+
try {
|
|
204
|
+
const descriptor = openSync(lockPath, "wx", 0o600);
|
|
205
|
+
writeFileSync(lockPath, `${process.pid}:${Date.now()}\n`, { flag: "w" });
|
|
206
|
+
return descriptor;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Remove a lock left by a crashed starter, but never steal one from a
|
|
210
|
+
// live process. A short age grace period avoids racing a slow Node start.
|
|
211
|
+
try {
|
|
212
|
+
const stamp = readFileSync(lockPath, "utf8").trim().split(":");
|
|
213
|
+
const pid = Number.parseInt(stamp[0] ?? "", 10);
|
|
214
|
+
const created = Number.parseInt(stamp[1] ?? "", 10);
|
|
215
|
+
let age = 0;
|
|
216
|
+
try {
|
|
217
|
+
age = Date.now() - statSync(lockPath).mtimeMs;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
age = 0;
|
|
221
|
+
}
|
|
222
|
+
if (!Number.isFinite(created) && age <= 5_000)
|
|
223
|
+
return null;
|
|
224
|
+
let alive = false;
|
|
225
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, 0);
|
|
228
|
+
alive = true;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
alive = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!alive &&
|
|
235
|
+
(Number.isFinite(created) ? Date.now() - created > 5_000 : age > 5_000)) {
|
|
236
|
+
unlinkSync(lockPath);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// The other starter may be publishing/removing the lock right now.
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function releaseStartLock(lockPath, descriptor) {
|
|
246
|
+
if (descriptor !== null) {
|
|
247
|
+
try {
|
|
248
|
+
closeSync(descriptor);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// best effort
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
unlinkSync(lockPath);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Another starter may already have cleaned the lock.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function recoverStaleJournalLock(branchPath) {
|
|
262
|
+
const lockPath = path.join(branchPath, "rdf", "lock");
|
|
263
|
+
if (!existsSync(lockPath))
|
|
264
|
+
return;
|
|
265
|
+
let contents = "";
|
|
266
|
+
try {
|
|
267
|
+
contents = readFileSync(lockPath, "utf8");
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const match = /pid\((\d+)\)/.exec(contents);
|
|
273
|
+
if (!match)
|
|
274
|
+
return;
|
|
275
|
+
const pid = Number.parseInt(match[1] ?? "", 10);
|
|
276
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid)
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
process.kill(pid, 0);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
try {
|
|
284
|
+
rmSync(lockPath, { force: true });
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Let rdf_attach_db report the lock error if another process wins the
|
|
288
|
+
// race between the liveness probe and removal.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function daemonEntry() {
|
|
293
|
+
const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
294
|
+
// Packed clients keep the daemon beside engine.js. Source-tree repository
|
|
295
|
+
// tests resolve engine.ts from src/, while the daemon is hosted by the
|
|
296
|
+
// already-built Node artifact in dist/.
|
|
297
|
+
const candidates = [
|
|
298
|
+
path.join(moduleDirectory, "engine-daemon.js"),
|
|
299
|
+
path.join(moduleDirectory, "..", "dist", "engine-daemon.js"),
|
|
300
|
+
// Sandboxed/evaluation hosts may bundle the MCP server into their own
|
|
301
|
+
// dist/ directory while preserving kibi-cli as a staged dependency. Keep
|
|
302
|
+
// the Node daemon external to that bundle so it can resolve its Prolog and
|
|
303
|
+
// codec modules exactly as the published CLI package does.
|
|
304
|
+
path.join(moduleDirectory, "..", "node_modules", "kibi-cli", "dist", "engine-daemon.js"),
|
|
305
|
+
];
|
|
306
|
+
for (const candidate of candidates) {
|
|
307
|
+
if (existsSync(candidate))
|
|
308
|
+
return candidate;
|
|
309
|
+
}
|
|
310
|
+
throw new Error("The Kibi engine is not built. Run `npm run build:cli` before using the CLI engine.");
|
|
311
|
+
}
|
|
312
|
+
function connectSocket(socketPath, timeoutMs) {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const socket = net.createConnection(socketPath);
|
|
315
|
+
let settled = false;
|
|
316
|
+
const timer = setTimeout(() => {
|
|
317
|
+
if (settled)
|
|
318
|
+
return;
|
|
319
|
+
settled = true;
|
|
320
|
+
socket.destroy();
|
|
321
|
+
reject(new Error(`Timed out connecting to Kibi engine at ${socketPath}`));
|
|
322
|
+
}, timeoutMs);
|
|
323
|
+
socket.once("connect", () => {
|
|
324
|
+
if (settled)
|
|
325
|
+
return;
|
|
326
|
+
settled = true;
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
resolve(socket);
|
|
329
|
+
});
|
|
330
|
+
// Keep a permanent error listener. Some Node versions emit the
|
|
331
|
+
// connection-refused error on the next tick after a timeout destroys the
|
|
332
|
+
// socket; a once-listener can otherwise become an uncaught event during
|
|
333
|
+
// concurrent auto-start attempts.
|
|
334
|
+
socket.on("error", (error) => {
|
|
335
|
+
if (settled)
|
|
336
|
+
return;
|
|
337
|
+
settled = true;
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
reject(error);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
async function connectWithRetry(socketPath, timeoutMs) {
|
|
344
|
+
const deadline = Date.now() + timeoutMs;
|
|
345
|
+
let lastError;
|
|
346
|
+
while (Date.now() < deadline) {
|
|
347
|
+
const errorPath = `${socketPath}.error`;
|
|
348
|
+
if (existsSync(errorPath)) {
|
|
349
|
+
try {
|
|
350
|
+
const diagnostic = readFileSync(errorPath, "utf8").trim();
|
|
351
|
+
if (diagnostic)
|
|
352
|
+
throw new Error(diagnostic);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
throw new Error(`Kibi engine failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Avoid creating a refused/ENOENT socket handle on every poll while the
|
|
359
|
+
// daemon is still starting and avoid noisy late socket errors.
|
|
360
|
+
if (process.platform !== "win32" && !existsSync(socketPath)) {
|
|
361
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
return await connectSocket(socketPath, Math.min(250, deadline - Date.now()));
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
lastError = error;
|
|
369
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
throw new Error(`Unable to connect to Kibi engine at ${socketPath}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
|
373
|
+
}
|
|
374
|
+
/** PrologPort-compatible client used by CLI and MCP operation runtimes. */
|
|
375
|
+
// implements REQ-core-journaled-engine-persistence
|
|
376
|
+
export class EngineClient {
|
|
377
|
+
workspaceRoot;
|
|
378
|
+
branch;
|
|
379
|
+
timeout;
|
|
380
|
+
socket = null;
|
|
381
|
+
daemon = null;
|
|
382
|
+
inputBuffer = Buffer.alloc(0);
|
|
383
|
+
requestId = 0;
|
|
384
|
+
pending = new Map();
|
|
385
|
+
requestTail = Promise.resolve();
|
|
386
|
+
lastResult = null;
|
|
387
|
+
constructor(options) {
|
|
388
|
+
if (!isValidBranchName(options.branch)) {
|
|
389
|
+
throw new Error(`Invalid Kibi engine branch name: ${options.branch}`);
|
|
390
|
+
}
|
|
391
|
+
const resolvedWorkspaceRoot = path.resolve(options.workspaceRoot);
|
|
392
|
+
try {
|
|
393
|
+
this.workspaceRoot = realpathSync(resolvedWorkspaceRoot);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
this.workspaceRoot = resolvedWorkspaceRoot;
|
|
397
|
+
}
|
|
398
|
+
this.branch = options.branch;
|
|
399
|
+
this.timeout = options.timeout ?? 120_000;
|
|
400
|
+
}
|
|
401
|
+
async start(allowSpawn = true) {
|
|
402
|
+
if (this.socket !== null && !this.socket.destroyed)
|
|
403
|
+
return;
|
|
404
|
+
const socketPath = engineSocketPath(this.workspaceRoot, this.branch);
|
|
405
|
+
try {
|
|
406
|
+
if (existsSync(socketPath)) {
|
|
407
|
+
this.socket = await connectWithRetry(socketPath, 150);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
throw new Error("engine socket is absent");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
if (!allowSpawn)
|
|
415
|
+
return;
|
|
416
|
+
const nodePath = process.env.KIBI_NODE_PATH ?? "node";
|
|
417
|
+
const entry = daemonEntry();
|
|
418
|
+
const lockPath = engineStartLockPath(this.workspaceRoot, this.branch);
|
|
419
|
+
const startDeadline = Date.now() + this.timeout;
|
|
420
|
+
let lock = null;
|
|
421
|
+
while (lock === null && Date.now() < startDeadline) {
|
|
422
|
+
if (existsSync(socketPath)) {
|
|
423
|
+
try {
|
|
424
|
+
this.socket = await connectWithRetry(socketPath, 100);
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// The daemon may be replacing a stale socket; retry below.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (this.socket === null) {
|
|
432
|
+
lock = tryAcquireStartLock(lockPath);
|
|
433
|
+
if (lock === null) {
|
|
434
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (this.socket === null && lock === null) {
|
|
439
|
+
throw new Error(`Unable to connect to Kibi engine at ${socketPath} while another starter owns ${lockPath}`);
|
|
440
|
+
}
|
|
441
|
+
if (lock !== null) {
|
|
442
|
+
try {
|
|
443
|
+
rmSync(`${socketPath}.error`, { force: true });
|
|
444
|
+
try {
|
|
445
|
+
const daemon = spawn(nodePath, [
|
|
446
|
+
entry,
|
|
447
|
+
"--workspace",
|
|
448
|
+
this.workspaceRoot,
|
|
449
|
+
"--branch",
|
|
450
|
+
this.branch,
|
|
451
|
+
"--socket",
|
|
452
|
+
socketPath,
|
|
453
|
+
], {
|
|
454
|
+
detached: true,
|
|
455
|
+
stdio: "ignore",
|
|
456
|
+
env: {
|
|
457
|
+
...process.env,
|
|
458
|
+
KIBI_ENGINE_PROTOCOL: String(ENGINE_PROTOCOL_VERSION),
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
await new Promise((resolve, reject) => {
|
|
462
|
+
daemon.once("spawn", resolve);
|
|
463
|
+
daemon.once("error", reject);
|
|
464
|
+
});
|
|
465
|
+
this.daemon = daemon;
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
throw new Error(`Kibi engine requires Node.js >=18 to host the daemon; failed to start '${nodePath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
469
|
+
}
|
|
470
|
+
this.daemon.unref();
|
|
471
|
+
this.socket = await connectWithRetry(socketPath, this.timeout);
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
releaseStartLock(lockPath, lock);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (this.socket === null)
|
|
479
|
+
return;
|
|
480
|
+
this.attachSocket(this.socket);
|
|
481
|
+
}
|
|
482
|
+
attachSocket(socket) {
|
|
483
|
+
socket.on("data", (chunk) => {
|
|
484
|
+
try {
|
|
485
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
486
|
+
this.inputBuffer = parseFrames(Buffer.concat([this.inputBuffer, bytes]), (value) => this.handleResponse(value));
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
socket.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
socket.on("close", () => {
|
|
493
|
+
this.socket = null;
|
|
494
|
+
const error = new Error("Kibi engine connection closed");
|
|
495
|
+
for (const pending of this.pending.values())
|
|
496
|
+
pending.reject(error);
|
|
497
|
+
this.pending.clear();
|
|
498
|
+
});
|
|
499
|
+
socket.on("error", (error) => {
|
|
500
|
+
for (const pending of this.pending.values())
|
|
501
|
+
pending.reject(error);
|
|
502
|
+
this.pending.clear();
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
handleResponse(value) {
|
|
506
|
+
if (value === null || typeof value !== "object")
|
|
507
|
+
return;
|
|
508
|
+
const response = value;
|
|
509
|
+
if (typeof response.id !== "number")
|
|
510
|
+
return;
|
|
511
|
+
const pending = this.pending.get(response.id);
|
|
512
|
+
if (!pending)
|
|
513
|
+
return;
|
|
514
|
+
this.pending.delete(response.id);
|
|
515
|
+
if (response.ok)
|
|
516
|
+
pending.resolve(response.result);
|
|
517
|
+
else
|
|
518
|
+
pending.reject(new Error(response.error ?? "Kibi engine request failed"));
|
|
519
|
+
}
|
|
520
|
+
async request(request, signal) {
|
|
521
|
+
await this.start();
|
|
522
|
+
const socket = this.socket;
|
|
523
|
+
if (socket === null || socket.destroyed)
|
|
524
|
+
throw new Error("Kibi engine is not connected");
|
|
525
|
+
const id = ++this.requestId;
|
|
526
|
+
const result = new Promise((resolve, reject) => {
|
|
527
|
+
this.pending.set(id, {
|
|
528
|
+
resolve: resolve,
|
|
529
|
+
reject,
|
|
530
|
+
});
|
|
531
|
+
try {
|
|
532
|
+
socket.write(frame({
|
|
533
|
+
...request,
|
|
534
|
+
id,
|
|
535
|
+
protocolVersion: ENGINE_PROTOCOL_VERSION,
|
|
536
|
+
packageVersions: ENGINE_PACKAGE_VERSIONS,
|
|
537
|
+
workspaceRoot: this.workspaceRoot,
|
|
538
|
+
branch: this.branch,
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
this.pending.delete(id);
|
|
543
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (signal !== undefined) {
|
|
547
|
+
const abort = () => {
|
|
548
|
+
if (!this.pending.has(id))
|
|
549
|
+
return;
|
|
550
|
+
this.pending.delete(id);
|
|
551
|
+
this.cancel(id);
|
|
552
|
+
reject(new Error("Kibi engine request cancelled"));
|
|
553
|
+
};
|
|
554
|
+
if (signal.aborted)
|
|
555
|
+
abort();
|
|
556
|
+
else
|
|
557
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
async query(goal, signal) {
|
|
563
|
+
let result;
|
|
564
|
+
const previous = this.requestTail;
|
|
565
|
+
let release;
|
|
566
|
+
this.requestTail = new Promise((resolve) => {
|
|
567
|
+
release = resolve;
|
|
568
|
+
});
|
|
569
|
+
await previous;
|
|
570
|
+
try {
|
|
571
|
+
result = await this.request({ method: "query", goal }, signal);
|
|
572
|
+
this.lastResult = result;
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
release();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async queryBatch(goals) {
|
|
580
|
+
const normalized = goals.map((goal) => goal.trim().replace(/\.+\s*$/, ""));
|
|
581
|
+
return this.query(`rdf_transaction((${normalized.join(", ")}))`);
|
|
582
|
+
}
|
|
583
|
+
async queryEntities(input) {
|
|
584
|
+
const result = await this.request({
|
|
585
|
+
method: "entities",
|
|
586
|
+
...(input.type !== undefined ? { type: input.type } : {}),
|
|
587
|
+
...(input.id !== undefined ? { entityId: input.id } : {}),
|
|
588
|
+
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
|
589
|
+
...(input.sourceFile !== undefined
|
|
590
|
+
? { sourceFile: input.sourceFile }
|
|
591
|
+
: {}),
|
|
592
|
+
limit: input.limit,
|
|
593
|
+
offset: input.offset,
|
|
594
|
+
});
|
|
595
|
+
if (!result.success) {
|
|
596
|
+
throw new Error(result.error ?? "Indexed entity query failed");
|
|
597
|
+
}
|
|
598
|
+
const rows = result.bindings.Rows
|
|
599
|
+
? parseListOfLists(result.bindings.Rows).map(parseEntityFromList)
|
|
600
|
+
: [];
|
|
601
|
+
const count = Number.parseInt(result.bindings.Count ?? "0", 10);
|
|
602
|
+
return {
|
|
603
|
+
entities: rows,
|
|
604
|
+
count: Number.isFinite(count) ? count : rows.length,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
async searchEntities(input) {
|
|
608
|
+
const result = await this.request({
|
|
609
|
+
method: "search",
|
|
610
|
+
searchQuery: input.query,
|
|
611
|
+
...(input.type !== undefined ? { type: input.type } : {}),
|
|
612
|
+
limit: input.limit,
|
|
613
|
+
offset: input.offset,
|
|
614
|
+
});
|
|
615
|
+
if (!result.success) {
|
|
616
|
+
throw new Error(result.error ?? "Indexed search candidate query failed");
|
|
617
|
+
}
|
|
618
|
+
const rows = result.bindings.Rows
|
|
619
|
+
? parseListOfLists(result.bindings.Rows).map(parseEntityFromList)
|
|
620
|
+
: [];
|
|
621
|
+
const count = Number.parseInt(result.bindings.Count ?? "0", 10);
|
|
622
|
+
return {
|
|
623
|
+
entities: rows,
|
|
624
|
+
count: Number.isFinite(count) ? count : rows.length,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/** Best-effort cancellation for a queued request. */
|
|
628
|
+
cancel(requestId) {
|
|
629
|
+
const socket = this.socket;
|
|
630
|
+
if (socket === null || socket.destroyed)
|
|
631
|
+
return;
|
|
632
|
+
const id = ++this.requestId;
|
|
633
|
+
socket.write(frame({
|
|
634
|
+
id,
|
|
635
|
+
method: "cancel",
|
|
636
|
+
cancelOf: requestId,
|
|
637
|
+
protocolVersion: ENGINE_PROTOCOL_VERSION,
|
|
638
|
+
packageVersions: ENGINE_PACKAGE_VERSIONS,
|
|
639
|
+
workspaceRoot: this.workspaceRoot,
|
|
640
|
+
branch: this.branch,
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
async nextSolution() {
|
|
644
|
+
const result = this.lastResult;
|
|
645
|
+
this.lastResult = null;
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
async save() {
|
|
649
|
+
return this.query("kb_save");
|
|
650
|
+
}
|
|
651
|
+
async storageStatus() {
|
|
652
|
+
return this.request({ method: "status" });
|
|
653
|
+
}
|
|
654
|
+
async checkpoint() {
|
|
655
|
+
return this.request({ method: "checkpoint" });
|
|
656
|
+
}
|
|
657
|
+
async queryStatusJson() {
|
|
658
|
+
return this.request({ method: "kbStatus" });
|
|
659
|
+
}
|
|
660
|
+
async compact() {
|
|
661
|
+
return this.request({ method: "compact" });
|
|
662
|
+
}
|
|
663
|
+
async exportStorage(targetDirectory) {
|
|
664
|
+
return this.request({
|
|
665
|
+
method: "export",
|
|
666
|
+
targetDirectory,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
invalidateCache() {
|
|
670
|
+
this.lastResult = null;
|
|
671
|
+
}
|
|
672
|
+
isRunning() {
|
|
673
|
+
return this.socket !== null && !this.socket.destroyed;
|
|
674
|
+
}
|
|
675
|
+
getPid() {
|
|
676
|
+
const pidPath = enginePidPath(this.workspaceRoot, this.branch);
|
|
677
|
+
try {
|
|
678
|
+
return Number.parseInt(readFileSync(pidPath, "utf8").trim(), 10) || 0;
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return 0;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async stop(startIfMissing = true) {
|
|
685
|
+
const socketPath = engineSocketPath(this.workspaceRoot, this.branch);
|
|
686
|
+
if (!this.isRunning()) {
|
|
687
|
+
try {
|
|
688
|
+
await this.start(startIfMissing);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
if (!startIfMissing)
|
|
692
|
+
return;
|
|
693
|
+
throw new Error("Unable to start Kibi engine for stop request");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (!this.isRunning())
|
|
697
|
+
return;
|
|
698
|
+
await this.request({ method: "stop" }).catch(() => undefined);
|
|
699
|
+
this.socket?.destroy();
|
|
700
|
+
this.socket = null;
|
|
701
|
+
// The daemon acknowledges stop before its SWI child has flushed and the
|
|
702
|
+
// listening socket has closed. Do not let a following auto-start attach to
|
|
703
|
+
// that half-shutdown process.
|
|
704
|
+
const deadline = Date.now() + 5_000;
|
|
705
|
+
while (existsSync(socketPath) && Date.now() < deadline) {
|
|
706
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async terminate() {
|
|
710
|
+
// Closing a client connection releases the engine; the daemon remains
|
|
711
|
+
// available for other operations until its idle timeout expires.
|
|
712
|
+
this.socket?.end();
|
|
713
|
+
this.socket?.destroy();
|
|
714
|
+
this.socket = null;
|
|
715
|
+
this.daemon = null;
|
|
716
|
+
this.lastResult = null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// implements REQ-core-journaled-engine-persistence
|
|
720
|
+
export async function ensureJournaledBranchStoreAsync(branchPath) {
|
|
721
|
+
mkdirSync(branchPath, { recursive: true });
|
|
722
|
+
const markerPath = path.join(branchPath, "storage.json");
|
|
723
|
+
const marker = existsSync(markerPath) ? readFileSync(markerPath, "utf8") : "";
|
|
724
|
+
if (marker.includes("kibi.rdf-journal.v1")) {
|
|
725
|
+
const rdfDirectory = path.join(branchPath, "rdf");
|
|
726
|
+
const currentPath = path.join(branchPath, "CURRENT");
|
|
727
|
+
const sentinelPath = path.join(branchPath, "kb.rdf");
|
|
728
|
+
if (!existsSync(rdfDirectory) || !existsSync(currentPath)) {
|
|
729
|
+
recoverInterruptedGeneration(branchPath);
|
|
730
|
+
if (existsSync(rdfDirectory) && existsSync(currentPath)) {
|
|
731
|
+
recoverStaleJournalLock(branchPath);
|
|
732
|
+
archiveLegacyFiles(branchPath);
|
|
733
|
+
if (!existsSync(sentinelPath)) {
|
|
734
|
+
writeFileSync(sentinelPath, "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// Publication moves the validated generation before archiving the
|
|
739
|
+
// legacy files. If the process dies between those moves, preserve the
|
|
740
|
+
// still-live legacy store and discard only the incomplete publication.
|
|
741
|
+
const legacyRdf = path.join(branchPath, "kb.rdf");
|
|
742
|
+
const legacyAudit = path.join(branchPath, "audit.log");
|
|
743
|
+
const legacyContents = existsSync(legacyRdf)
|
|
744
|
+
? readFileSync(legacyRdf, "utf8")
|
|
745
|
+
: "";
|
|
746
|
+
if (existsSync(legacyAudit) ||
|
|
747
|
+
(existsSync(legacyRdf) &&
|
|
748
|
+
!legacyContents.includes("kibi.rdf-journal.v1"))) {
|
|
749
|
+
rmSync(rdfDirectory, { recursive: true, force: true });
|
|
750
|
+
rmSync(currentPath, { force: true });
|
|
751
|
+
rmSync(markerPath, { force: true });
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
throw new Error(`Journaled branch store is incomplete at ${branchPath}; restore the immutable legacy/ backup or remove the interrupted generation before retrying`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
recoverStaleJournalLock(branchPath);
|
|
759
|
+
// The sentinel is deliberately written last during migration. If it is
|
|
760
|
+
// missing after a crash, archive any remaining legacy files and fence
|
|
761
|
+
// old clients before allowing a new engine to attach.
|
|
762
|
+
archiveLegacyFiles(branchPath);
|
|
763
|
+
if (!existsSync(sentinelPath)) {
|
|
764
|
+
writeFileSync(sentinelPath, "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
|
|
765
|
+
}
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const legacyRdf = path.join(branchPath, "kb.rdf");
|
|
770
|
+
const legacyAudit = path.join(branchPath, "audit.log");
|
|
771
|
+
if (!existsSync(legacyRdf) && !existsSync(legacyAudit)) {
|
|
772
|
+
mkdirSync(path.join(branchPath, "rdf"), { recursive: true });
|
|
773
|
+
writeFileSync(markerPath, '{"format":"kibi.rdf-journal.v1","schemaVersion":1}\n', { mode: 0o600 });
|
|
774
|
+
writeFileSync(path.join(branchPath, "CURRENT"), "generation-1:0\n", {
|
|
775
|
+
mode: 0o600,
|
|
776
|
+
});
|
|
777
|
+
writeFileSync(path.join(branchPath, "kb.rdf"), "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (existsSync(legacyRdf)) {
|
|
781
|
+
const legacyContents = readFileSync(legacyRdf, "utf8");
|
|
782
|
+
if (!/<rdf:RDF(?:\s|>)/.test(legacyContents)) {
|
|
783
|
+
throw new Error(`Legacy Kibi store is corrupt at ${legacyRdf}: expected RDF/XML root`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const parent = path.dirname(branchPath);
|
|
787
|
+
const migrationKey = createHash("sha256")
|
|
788
|
+
.update(path.resolve(branchPath))
|
|
789
|
+
.digest("hex")
|
|
790
|
+
.slice(0, 16);
|
|
791
|
+
const staging = path.join(parent, `.${migrationKey}.migration.${process.pid}.${Date.now()}`);
|
|
792
|
+
mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
793
|
+
// Production migration runs in the Node daemon. The one-shot branch exists
|
|
794
|
+
// solely for repository tests that import this helper under Bun.
|
|
795
|
+
const testOneShot = process.env.NODE_ENV === "test" &&
|
|
796
|
+
typeof globalThis.Bun !== "undefined";
|
|
797
|
+
const migrator = new PrologProcess({
|
|
798
|
+
timeout: 120_000,
|
|
799
|
+
oneShot: testOneShot,
|
|
800
|
+
});
|
|
801
|
+
try {
|
|
802
|
+
if (!testOneShot)
|
|
803
|
+
await migrator.start();
|
|
804
|
+
const attachGoal = `kb_attach('${quoteProlog(branchPath)}')`;
|
|
805
|
+
const migrateGoal = `kb_migrate_legacy('${quoteProlog(branchPath)}','${quoteProlog(staging)}')`;
|
|
806
|
+
const migrated = testOneShot
|
|
807
|
+
? await migrator.query(`(${attachGoal}, ${migrateGoal})`)
|
|
808
|
+
: await (async () => {
|
|
809
|
+
const attach = await migrator.query(attachGoal);
|
|
810
|
+
if (!attach.success)
|
|
811
|
+
throw new Error(attach.error ?? "Failed to attach legacy KB");
|
|
812
|
+
return migrator.query(migrateGoal);
|
|
813
|
+
})();
|
|
814
|
+
if (!migrated.success)
|
|
815
|
+
throw new Error(migrated.error ?? "Legacy KB migration failed");
|
|
816
|
+
fsyncJournaledBranchStore(staging);
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
rmSync(staging, { recursive: true, force: true });
|
|
820
|
+
throw error;
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
await migrator.terminate();
|
|
824
|
+
}
|
|
825
|
+
// Publish the validated generation while the originals are still present.
|
|
826
|
+
// A crash before the marker is moved leaves the legacy branch readable; a
|
|
827
|
+
// crash after it is moved is repaired by the marker branch above.
|
|
828
|
+
for (const file of ["rdf", "storage.json", "CURRENT"]) {
|
|
829
|
+
const source = path.join(staging, file);
|
|
830
|
+
if (!existsSync(source))
|
|
831
|
+
continue;
|
|
832
|
+
const target = path.join(branchPath, file);
|
|
833
|
+
if (existsSync(target))
|
|
834
|
+
rmSync(target, { recursive: true, force: true });
|
|
835
|
+
renameSync(source, target);
|
|
836
|
+
}
|
|
837
|
+
archiveLegacyFiles(branchPath);
|
|
838
|
+
// Older clients must fail closed instead of seeing an empty branch.
|
|
839
|
+
writeFileSync(path.join(branchPath, "kb.rdf"), "KIBI_STORAGE_FORMAT=kibi.rdf-journal.v1\n", { mode: 0o600 });
|
|
840
|
+
fsyncJournaledBranchStore(branchPath);
|
|
841
|
+
rmSync(staging, { recursive: true, force: true });
|
|
842
|
+
}
|
|
843
|
+
function archiveLegacyFiles(branchPath) {
|
|
844
|
+
const legacyDir = path.join(branchPath, "legacy");
|
|
845
|
+
mkdirSync(legacyDir, { recursive: true, mode: 0o700 });
|
|
846
|
+
for (const file of ["kb.rdf", "audit.log"]) {
|
|
847
|
+
const source = path.join(branchPath, file);
|
|
848
|
+
if (!existsSync(source))
|
|
849
|
+
continue;
|
|
850
|
+
if (file === "kb.rdf") {
|
|
851
|
+
const contents = readFileSync(source, "utf8");
|
|
852
|
+
if (contents.includes("kibi.rdf-journal.v1"))
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const destination = path.join(legacyDir, file);
|
|
856
|
+
if (existsSync(destination)) {
|
|
857
|
+
// Never overwrite an immutable backup from an earlier interrupted
|
|
858
|
+
// attempt. Preserve the second copy with an explicit recovery suffix.
|
|
859
|
+
renameSync(source, path.join(legacyDir, `${file}.recovery.${Date.now()}`));
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
renameSync(source, destination);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function recoverInterruptedGeneration(branchPath) {
|
|
867
|
+
let entries = [];
|
|
868
|
+
try {
|
|
869
|
+
entries = readdirSync(branchPath);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const newest = (prefix) => {
|
|
875
|
+
const candidates = entries
|
|
876
|
+
.filter((entry) => entry.startsWith(prefix))
|
|
877
|
+
.sort()
|
|
878
|
+
.reverse();
|
|
879
|
+
const candidate = candidates[0];
|
|
880
|
+
return candidate === undefined ? null : path.join(branchPath, candidate);
|
|
881
|
+
};
|
|
882
|
+
const oldRdf = newest("rdf.old.");
|
|
883
|
+
const oldCurrent = newest("CURRENT.old.");
|
|
884
|
+
const rdfPath = path.join(branchPath, "rdf");
|
|
885
|
+
const currentPath = path.join(branchPath, "CURRENT");
|
|
886
|
+
// If either half of the pointer/database pair is missing, prefer restoring
|
|
887
|
+
// the matching old pair. This prevents a new RDF generation from being
|
|
888
|
+
// opened with the old commit sequence after a crash between renames.
|
|
889
|
+
if ((!existsSync(rdfPath) || !existsSync(currentPath)) &&
|
|
890
|
+
oldRdf &&
|
|
891
|
+
oldCurrent) {
|
|
892
|
+
try {
|
|
893
|
+
if (existsSync(rdfPath))
|
|
894
|
+
rmSync(rdfPath, { recursive: true, force: true });
|
|
895
|
+
if (existsSync(currentPath))
|
|
896
|
+
rmSync(currentPath, { force: true });
|
|
897
|
+
renameSync(oldRdf, rdfPath);
|
|
898
|
+
renameSync(oldCurrent, currentPath);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
// Fall through to the individual best-effort restores below.
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (!existsSync(rdfPath) && oldRdf) {
|
|
906
|
+
try {
|
|
907
|
+
renameSync(oldRdf, rdfPath);
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
// Leave the actionable incomplete-store error for the caller.
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (!existsSync(currentPath) && oldCurrent) {
|
|
914
|
+
try {
|
|
915
|
+
renameSync(oldCurrent, currentPath);
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
// Leave the actionable incomplete-store error for the caller.
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
// implements REQ-core-journaled-engine-lifecycle
|
|
923
|
+
export async function runEngineDaemon(options) {
|
|
924
|
+
if (!isValidBranchName(options.branch)) {
|
|
925
|
+
throw new Error(`Invalid Kibi engine branch name: ${options.branch}`);
|
|
926
|
+
}
|
|
927
|
+
await ensureJournaledBranchStoreAsync(path.join(options.workspaceRoot, ".kb", "branches", options.branch));
|
|
928
|
+
const prolog = new PrologProcess({ timeout: 120_000, oneShot: false });
|
|
929
|
+
await prolog.start();
|
|
930
|
+
const branchPath = path.join(options.workspaceRoot, ".kb", "branches", options.branch);
|
|
931
|
+
const attached = await prolog.query(`kb_attach('${quoteProlog(branchPath)}')`);
|
|
932
|
+
if (!attached.success)
|
|
933
|
+
throw new Error(attached.error ?? "Failed to attach branch KB");
|
|
934
|
+
const statusModulePath = path.join(path.dirname(resolveKbPlPath()), "status.pl");
|
|
935
|
+
const statusLoaded = await prolog.query(`use_module('${quoteProlog(statusModulePath.replaceAll("\\", "/"))}')`);
|
|
936
|
+
if (!statusLoaded.success) {
|
|
937
|
+
throw new Error(statusLoaded.error ?? "Failed to load Kibi status module");
|
|
938
|
+
}
|
|
939
|
+
mkdirSync(path.dirname(options.socketPath), { recursive: true, mode: 0o700 });
|
|
940
|
+
if (existsSync(options.socketPath)) {
|
|
941
|
+
let live = false;
|
|
942
|
+
try {
|
|
943
|
+
const existing = await connectSocket(options.socketPath, 100);
|
|
944
|
+
existing.destroy();
|
|
945
|
+
live = true;
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
// A refused connection means this is a stale filesystem socket.
|
|
949
|
+
}
|
|
950
|
+
if (live) {
|
|
951
|
+
throw new Error(`A Kibi engine is already listening at ${options.socketPath}`);
|
|
952
|
+
}
|
|
953
|
+
try {
|
|
954
|
+
rmSync(options.socketPath, { force: true });
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
// The listen call below reports permission/address-in-use failures.
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
writeFileSync(enginePidPath(options.workspaceRoot, options.branch), `${process.pid}\n`, { mode: 0o600 });
|
|
961
|
+
const server = net.createServer();
|
|
962
|
+
let activeClients = 0;
|
|
963
|
+
let idleTimer = null;
|
|
964
|
+
let queue = Promise.resolve();
|
|
965
|
+
let idleCompactionQueued = false;
|
|
966
|
+
const queryCache = new Map();
|
|
967
|
+
let freshnessCache = null;
|
|
968
|
+
const cancelledRequests = new Set();
|
|
969
|
+
const clients = new Set();
|
|
970
|
+
let shuttingDown = false;
|
|
971
|
+
const scheduleIdleExit = () => {
|
|
972
|
+
if (activeClients > 0)
|
|
973
|
+
return;
|
|
974
|
+
if (!idleCompactionQueued) {
|
|
975
|
+
idleCompactionQueued = true;
|
|
976
|
+
queue = queue
|
|
977
|
+
.then(async () => {
|
|
978
|
+
if (activeClients === 0) {
|
|
979
|
+
await prolog.query("kb_storage_compact_if_needed");
|
|
980
|
+
}
|
|
981
|
+
})
|
|
982
|
+
.catch(() => undefined)
|
|
983
|
+
.finally(() => {
|
|
984
|
+
idleCompactionQueued = false;
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
if (idleTimer)
|
|
988
|
+
clearTimeout(idleTimer);
|
|
989
|
+
idleTimer = setTimeout(() => {
|
|
990
|
+
if (activeClients === 0)
|
|
991
|
+
void shutdown();
|
|
992
|
+
}, engineIdleTimeoutMs());
|
|
993
|
+
};
|
|
994
|
+
const handle = async (request) => {
|
|
995
|
+
if (request.protocolVersion !== ENGINE_PROTOCOL_VERSION) {
|
|
996
|
+
throw new Error(`Kibi engine protocol mismatch: client=${request.protocolVersion ?? "missing"}, server=${ENGINE_PROTOCOL_VERSION}`);
|
|
997
|
+
}
|
|
998
|
+
const expectedPackages = process.env.KIBI_PACKAGE_VERSIONS;
|
|
999
|
+
if (expectedPackages !== undefined &&
|
|
1000
|
+
request.packageVersions !== expectedPackages) {
|
|
1001
|
+
throw new Error(`Kibi engine package-version mismatch: client=${request.packageVersions ?? "missing"}, server=${expectedPackages}`);
|
|
1002
|
+
}
|
|
1003
|
+
if (path.resolve(request.workspaceRoot ?? "") !==
|
|
1004
|
+
path.resolve(options.workspaceRoot) ||
|
|
1005
|
+
request.branch !== options.branch) {
|
|
1006
|
+
throw new Error("Kibi engine workspace identity mismatch");
|
|
1007
|
+
}
|
|
1008
|
+
switch (request.method) {
|
|
1009
|
+
case "entities": {
|
|
1010
|
+
const limit = request.limit ?? 100;
|
|
1011
|
+
const offset = request.offset ?? 0;
|
|
1012
|
+
if (!Number.isInteger(limit) ||
|
|
1013
|
+
!Number.isInteger(offset) ||
|
|
1014
|
+
limit < 0 ||
|
|
1015
|
+
offset < 0 ||
|
|
1016
|
+
limit > 100_000) {
|
|
1017
|
+
throw new Error("entities.limit/offset must be bounded integers");
|
|
1018
|
+
}
|
|
1019
|
+
const type = request.type === undefined
|
|
1020
|
+
? "none"
|
|
1021
|
+
: `'${quoteProlog(request.type)}'`;
|
|
1022
|
+
const id = request.entityId === undefined
|
|
1023
|
+
? "none"
|
|
1024
|
+
: `'${quoteProlog(request.entityId)}'`;
|
|
1025
|
+
const tags = `[${(request.tags ?? [])
|
|
1026
|
+
.map((tag) => `'${quoteProlog(tag)}'`)
|
|
1027
|
+
.join(",")}]`;
|
|
1028
|
+
const source = request.sourceFile === undefined
|
|
1029
|
+
? "none"
|
|
1030
|
+
: `'${quoteProlog(request.sourceFile)}'`;
|
|
1031
|
+
const result = await prolog.query(`kb_query_entities(${type}, ${id}, ${tags}, ${source}, ${limit}, ${offset}, Rows, Count)`);
|
|
1032
|
+
if (!result.success) {
|
|
1033
|
+
throw new Error(result.error ?? "Indexed entity query failed");
|
|
1034
|
+
}
|
|
1035
|
+
return result;
|
|
1036
|
+
}
|
|
1037
|
+
case "search": {
|
|
1038
|
+
const limit = request.limit ?? 10_000;
|
|
1039
|
+
const offset = request.offset ?? 0;
|
|
1040
|
+
if (typeof request.searchQuery !== "string" ||
|
|
1041
|
+
request.searchQuery.trim().length === 0) {
|
|
1042
|
+
throw new Error("search.searchQuery must be a non-empty string");
|
|
1043
|
+
}
|
|
1044
|
+
if (!Number.isInteger(limit) ||
|
|
1045
|
+
!Number.isInteger(offset) ||
|
|
1046
|
+
limit < 0 ||
|
|
1047
|
+
offset < 0 ||
|
|
1048
|
+
limit > 100_000) {
|
|
1049
|
+
throw new Error("search.limit/offset must be bounded integers");
|
|
1050
|
+
}
|
|
1051
|
+
const type = request.type === undefined
|
|
1052
|
+
? "none"
|
|
1053
|
+
: `'${quoteProlog(request.type)}'`;
|
|
1054
|
+
const result = await prolog.query(`kb_search_entities(${type}, '${quoteProlog(request.searchQuery)}', ${limit}, ${offset}, Rows, Count)`);
|
|
1055
|
+
if (!result.success) {
|
|
1056
|
+
throw new Error(result.error ?? "Indexed search candidate query failed");
|
|
1057
|
+
}
|
|
1058
|
+
return result;
|
|
1059
|
+
}
|
|
1060
|
+
case "kbStatus": {
|
|
1061
|
+
if (freshnessCache !== null &&
|
|
1062
|
+
Date.now() - freshnessCache.capturedAt <= ENGINE_FRESHNESS_CACHE_MS) {
|
|
1063
|
+
return freshnessCache.result;
|
|
1064
|
+
}
|
|
1065
|
+
const result = await prolog.query("status:kb_status_json(JsonString)");
|
|
1066
|
+
if (result.success) {
|
|
1067
|
+
freshnessCache = { capturedAt: Date.now(), result };
|
|
1068
|
+
}
|
|
1069
|
+
return result;
|
|
1070
|
+
}
|
|
1071
|
+
case "query": {
|
|
1072
|
+
if (typeof request.goal !== "string")
|
|
1073
|
+
throw new Error("query.goal must be a string");
|
|
1074
|
+
if (!safeEngineGoal(request.goal)) {
|
|
1075
|
+
throw new Error("Kibi engine accepts only typed Kibi predicates; arbitrary Prolog goals are not exposed");
|
|
1076
|
+
}
|
|
1077
|
+
if (mutatingEngineGoal(request.goal)) {
|
|
1078
|
+
queryCache.clear();
|
|
1079
|
+
freshnessCache = null;
|
|
1080
|
+
}
|
|
1081
|
+
if (cacheableEngineGoal(request.goal)) {
|
|
1082
|
+
const cached = queryCache.get(request.goal);
|
|
1083
|
+
if (cached !== undefined)
|
|
1084
|
+
return cached;
|
|
1085
|
+
}
|
|
1086
|
+
const result = await prolog.query(request.goal);
|
|
1087
|
+
if (result.success && mutatingEngineGoal(request.goal)) {
|
|
1088
|
+
fsyncJournaledBranchStore(branchPath);
|
|
1089
|
+
}
|
|
1090
|
+
if (result.success &&
|
|
1091
|
+
cacheableEngineGoal(request.goal) &&
|
|
1092
|
+
Buffer.byteLength(JSON.stringify(result), "utf8") <=
|
|
1093
|
+
ENGINE_QUERY_CACHE_MAX_RESULT_BYTES) {
|
|
1094
|
+
if (queryCache.size >= ENGINE_QUERY_CACHE_MAX_ENTRIES) {
|
|
1095
|
+
const oldest = queryCache.keys().next().value;
|
|
1096
|
+
if (typeof oldest === "string")
|
|
1097
|
+
queryCache.delete(oldest);
|
|
1098
|
+
}
|
|
1099
|
+
queryCache.set(request.goal, result);
|
|
1100
|
+
}
|
|
1101
|
+
return result;
|
|
1102
|
+
}
|
|
1103
|
+
case "status":
|
|
1104
|
+
return prolog.query("kb_storage_status(Status), atom_json_dict(Json, Status, [])");
|
|
1105
|
+
case "checkpoint": {
|
|
1106
|
+
queryCache.clear();
|
|
1107
|
+
freshnessCache = null;
|
|
1108
|
+
const result = await prolog.query("kb_sync_checkpoint");
|
|
1109
|
+
if (result.success)
|
|
1110
|
+
fsyncJournaledBranchStore(branchPath);
|
|
1111
|
+
return result;
|
|
1112
|
+
}
|
|
1113
|
+
case "compact": {
|
|
1114
|
+
const result = await prolog.query("kb_storage_compact");
|
|
1115
|
+
if (result.success)
|
|
1116
|
+
fsyncJournaledBranchStore(branchPath);
|
|
1117
|
+
return result;
|
|
1118
|
+
}
|
|
1119
|
+
case "export":
|
|
1120
|
+
if (typeof request.targetDirectory !== "string")
|
|
1121
|
+
throw new Error("export.targetDirectory is required");
|
|
1122
|
+
return prolog.query(`kb_storage_export('${quoteProlog(request.targetDirectory)}')`);
|
|
1123
|
+
case "stop":
|
|
1124
|
+
setImmediate(() => void shutdown());
|
|
1125
|
+
return { stopped: true };
|
|
1126
|
+
case "cancel":
|
|
1127
|
+
if (typeof request.cancelOf === "number") {
|
|
1128
|
+
cancelledRequests.add(request.cancelOf);
|
|
1129
|
+
}
|
|
1130
|
+
return { cancelled: request.cancelOf ?? null };
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
const shutdown = async () => {
|
|
1134
|
+
if (shuttingDown)
|
|
1135
|
+
return;
|
|
1136
|
+
shuttingDown = true;
|
|
1137
|
+
process.off("SIGTERM", requestSignalShutdown);
|
|
1138
|
+
process.off("SIGINT", requestSignalShutdown);
|
|
1139
|
+
if (idleTimer)
|
|
1140
|
+
clearTimeout(idleTimer);
|
|
1141
|
+
const saved = await prolog.query("kb_save").catch(() => null);
|
|
1142
|
+
if (saved?.success) {
|
|
1143
|
+
try {
|
|
1144
|
+
fsyncJournaledBranchStore(branchPath);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
// Shutdown remains best effort. Request-time writes fail closed if
|
|
1148
|
+
// their durability barrier cannot complete.
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
await prolog.terminate().catch(() => undefined);
|
|
1152
|
+
for (const client of clients)
|
|
1153
|
+
client.destroy();
|
|
1154
|
+
server.close();
|
|
1155
|
+
// A close callback can remain pending on older Node releases when a
|
|
1156
|
+
// client disconnects during `server.close()`. The daemon has already
|
|
1157
|
+
// removed its socket/pid and has no useful work left, so do not keep the
|
|
1158
|
+
// hosting process alive on that bookkeeping handle.
|
|
1159
|
+
server.unref();
|
|
1160
|
+
try {
|
|
1161
|
+
if (existsSync(options.socketPath))
|
|
1162
|
+
rmSync(options.socketPath, { force: true });
|
|
1163
|
+
if (existsSync(enginePidPath(options.workspaceRoot, options.branch)))
|
|
1164
|
+
rmSync(enginePidPath(options.workspaceRoot, options.branch), {
|
|
1165
|
+
force: true,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
// best effort cleanup
|
|
1170
|
+
}
|
|
1171
|
+
// The daemon is a detached host process. Once the SWI child and socket are
|
|
1172
|
+
// gone, force the final event-loop exit rather than retaining a stale
|
|
1173
|
+
// detached Node process on platform-specific server bookkeeping.
|
|
1174
|
+
setTimeout(() => process.exit(0), 50).unref();
|
|
1175
|
+
};
|
|
1176
|
+
// Detached engines must cross the same durability boundary when a service
|
|
1177
|
+
// manager or a test harness terminates them as they do for an RPC stop.
|
|
1178
|
+
// implements REQ-test-journaled-engine-harness
|
|
1179
|
+
const requestSignalShutdown = () => {
|
|
1180
|
+
void shutdown();
|
|
1181
|
+
};
|
|
1182
|
+
process.once("SIGTERM", requestSignalShutdown);
|
|
1183
|
+
process.once("SIGINT", requestSignalShutdown);
|
|
1184
|
+
server.on("connection", (socket) => {
|
|
1185
|
+
clients.add(socket);
|
|
1186
|
+
activeClients += 1;
|
|
1187
|
+
let buffer = Buffer.alloc(0);
|
|
1188
|
+
socket.on("data", (chunk) => {
|
|
1189
|
+
try {
|
|
1190
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
1191
|
+
buffer = parseFrames(Buffer.concat([buffer, bytes]), (value) => {
|
|
1192
|
+
const request = value;
|
|
1193
|
+
if (request.method === "cancel" &&
|
|
1194
|
+
typeof request.cancelOf === "number") {
|
|
1195
|
+
cancelledRequests.add(request.cancelOf);
|
|
1196
|
+
}
|
|
1197
|
+
queue = queue
|
|
1198
|
+
.then(async () => {
|
|
1199
|
+
if (cancelledRequests.delete(request.id)) {
|
|
1200
|
+
writeSocketFrame(socket, {
|
|
1201
|
+
id: request.id,
|
|
1202
|
+
ok: false,
|
|
1203
|
+
error: "Kibi engine request cancelled",
|
|
1204
|
+
serverPackageVersions: ENGINE_PACKAGE_VERSIONS,
|
|
1205
|
+
});
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
try {
|
|
1209
|
+
const result = await handle(request);
|
|
1210
|
+
writeSocketFrame(socket, {
|
|
1211
|
+
id: request.id,
|
|
1212
|
+
ok: true,
|
|
1213
|
+
result,
|
|
1214
|
+
serverPackageVersions: ENGINE_PACKAGE_VERSIONS,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
writeSocketFrame(socket, {
|
|
1219
|
+
id: request.id,
|
|
1220
|
+
ok: false,
|
|
1221
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1222
|
+
serverPackageVersions: ENGINE_PACKAGE_VERSIONS,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
})
|
|
1226
|
+
.catch(() => undefined);
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
socket.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
socket.on("close", () => {
|
|
1234
|
+
clients.delete(socket);
|
|
1235
|
+
activeClients -= 1;
|
|
1236
|
+
scheduleIdleExit();
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
await new Promise((resolve, reject) => {
|
|
1240
|
+
server.once("error", reject);
|
|
1241
|
+
server.listen(options.socketPath, () => resolve());
|
|
1242
|
+
});
|
|
1243
|
+
if (process.platform !== "win32") {
|
|
1244
|
+
try {
|
|
1245
|
+
chmodSync(options.socketPath, 0o600);
|
|
1246
|
+
}
|
|
1247
|
+
catch {
|
|
1248
|
+
// The runtime directory is private; retain a usable socket if a
|
|
1249
|
+
// platform does not expose chmod for its local socket implementation.
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
scheduleIdleExit();
|
|
1253
|
+
await new Promise((resolve) => {
|
|
1254
|
+
server.once("close", resolve);
|
|
1255
|
+
});
|
|
1256
|
+
}
|