@ferrule-io/ok-fine 0.3.12 → 0.3.14
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/auth/verifier.js +14 -8
- package/dist/config.js +106 -1
- package/dist/http/metrics.js +11 -0
- package/dist/http/rest.js +28 -12
- package/dist/local-host.js +1 -0
- package/dist/mcp/server.js +36 -17
- package/dist/metrics/histogram.js +36 -0
- package/dist/metrics/instrument.js +146 -0
- package/dist/metrics/store.js +298 -0
- package/dist/metrics/types.js +4 -0
- package/dist/okf/concept.js +5 -1
- package/dist/okf/lint.js +15 -1
- package/dist/okf/markdown.js +83 -1
- package/dist/server.js +44 -11
- package/dist/service/knowledge-service.js +379 -61
- package/dist/store/bundle.js +2 -1
- package/dist/store/catalog.js +78 -23
- package/dist/ui/assets/{highlight-D8LQtOZ_.js → highlight-X-D4YRi7.js} +1 -1
- package/dist/ui/assets/{index-CWFStnfZ.js → index-Bs2N743P.js} +33 -33
- package/dist/ui/assets/index-CMUhze4o.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-BjnLNjEO.css +0 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { BUCKET_COUNT, bucketIndex, quantile } from "./histogram.js";
|
|
4
|
+
import { METRICS_LAYERS, } from "./types.js";
|
|
5
|
+
export const METRICS_DB_FILE = "metrics.sqlite";
|
|
6
|
+
/** Bump when the tables or LATENCY_BUCKETS_MS change; a mismatching database is rebuilt (metrics are disposable). */
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
const FLUSH_INTERVAL_MS = 10_000;
|
|
9
|
+
const MINUTE_RETENTION_SECONDS = 172_800;
|
|
10
|
+
const PRUNE_INTERVAL_SECONDS = 3_600;
|
|
11
|
+
const WINDOWS = {
|
|
12
|
+
"1h": { seconds: 3_600, table: "metrics_minute", stepSeconds: 60 },
|
|
13
|
+
"24h": { seconds: 86_400, table: "metrics_minute", stepSeconds: 900 },
|
|
14
|
+
"7d": { seconds: 604_800, table: "metrics_hour", stepSeconds: 7_200 },
|
|
15
|
+
"30d": { seconds: 2_592_000, table: "metrics_hour", stepSeconds: 21_600 },
|
|
16
|
+
};
|
|
17
|
+
const TABLES = ["metrics_minute", "metrics_hour"];
|
|
18
|
+
const BUCKET_COLUMNS = Array.from({ length: BUCKET_COUNT }, (_, i) => `b${String(i).padStart(2, "0")}`);
|
|
19
|
+
const VALUE_COLUMNS = ["count", "client_errors", "server_errors", "sum_ms", "min_ms", "max_ms", ...BUCKET_COLUMNS];
|
|
20
|
+
const SUMS = [
|
|
21
|
+
"SUM(count) AS count",
|
|
22
|
+
"SUM(client_errors) AS client_errors",
|
|
23
|
+
"SUM(server_errors) AS server_errors",
|
|
24
|
+
"SUM(sum_ms) AS sum_ms",
|
|
25
|
+
"MIN(min_ms) AS min_ms",
|
|
26
|
+
"MAX(max_ms) AS max_ms",
|
|
27
|
+
...BUCKET_COLUMNS.map((c) => `SUM(${c}) AS ${c}`),
|
|
28
|
+
].join(", ");
|
|
29
|
+
function createSchema(db) {
|
|
30
|
+
const columns = [
|
|
31
|
+
"bucket_start INTEGER NOT NULL",
|
|
32
|
+
"layer TEXT NOT NULL",
|
|
33
|
+
"op TEXT NOT NULL",
|
|
34
|
+
"count INTEGER NOT NULL",
|
|
35
|
+
"client_errors INTEGER NOT NULL",
|
|
36
|
+
"server_errors INTEGER NOT NULL",
|
|
37
|
+
"sum_ms REAL NOT NULL",
|
|
38
|
+
"min_ms REAL NOT NULL",
|
|
39
|
+
"max_ms REAL NOT NULL",
|
|
40
|
+
...BUCKET_COLUMNS.map((c) => `${c} INTEGER NOT NULL`),
|
|
41
|
+
"PRIMARY KEY (bucket_start, layer, op)",
|
|
42
|
+
].join(", ");
|
|
43
|
+
for (const table of TABLES) {
|
|
44
|
+
db.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
45
|
+
db.exec(`CREATE TABLE ${table} (${columns}) WITHOUT ROWID`);
|
|
46
|
+
}
|
|
47
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
48
|
+
}
|
|
49
|
+
function prepare(db) {
|
|
50
|
+
const perTable = (sql) => ({
|
|
51
|
+
metrics_minute: db.prepare(sql("metrics_minute")),
|
|
52
|
+
metrics_hour: db.prepare(sql("metrics_hour")),
|
|
53
|
+
});
|
|
54
|
+
const insertColumns = ["bucket_start", "layer", "op", ...VALUE_COLUMNS];
|
|
55
|
+
const updates = VALUE_COLUMNS.map((c) => c === "min_ms" || c === "max_ms" ? `${c} = ${c.slice(0, 3)}(${c}, excluded.${c})` : `${c} = ${c} + excluded.${c}`).join(", ");
|
|
56
|
+
return {
|
|
57
|
+
upsert: perTable((t) => `INSERT INTO ${t} (${insertColumns.join(", ")}) VALUES (${insertColumns.map(() => "?").join(", ")}) ` +
|
|
58
|
+
`ON CONFLICT (bucket_start, layer, op) DO UPDATE SET ${updates}`),
|
|
59
|
+
prune: perTable((t) => `DELETE FROM ${t} WHERE bucket_start < ?`),
|
|
60
|
+
operations: perTable((t) => `SELECT layer, op, ${SUMS} FROM ${t} WHERE bucket_start >= ? GROUP BY layer, op`),
|
|
61
|
+
// `bucket_start - (bucket_start % ?)`, not `/ ? * ?`: node:sqlite binds JS numbers as REAL, so `/` would not truncate.
|
|
62
|
+
series: perTable((t) => `SELECT bucket_start - (bucket_start % ?) AS t, layer, ${SUMS} FROM ${t} WHERE bucket_start >= ? ` +
|
|
63
|
+
"GROUP BY t, layer ORDER BY t, layer"),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function num(row, key) {
|
|
67
|
+
const v = row[key];
|
|
68
|
+
return typeof v === "number" ? v : typeof v === "bigint" ? Number(v) : 0;
|
|
69
|
+
}
|
|
70
|
+
function str(row, key) {
|
|
71
|
+
const v = row[key];
|
|
72
|
+
return typeof v === "string" ? v : "";
|
|
73
|
+
}
|
|
74
|
+
function totalsOf(row) {
|
|
75
|
+
return {
|
|
76
|
+
count: num(row, "count"),
|
|
77
|
+
clientErrors: num(row, "client_errors"),
|
|
78
|
+
serverErrors: num(row, "server_errors"),
|
|
79
|
+
sumMs: num(row, "sum_ms"),
|
|
80
|
+
minMs: num(row, "min_ms"),
|
|
81
|
+
maxMs: num(row, "max_ms"),
|
|
82
|
+
buckets: BUCKET_COLUMNS.map((c) => num(row, c)),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function merge(into, from) {
|
|
86
|
+
into.count += from.count;
|
|
87
|
+
into.clientErrors += from.clientErrors;
|
|
88
|
+
into.serverErrors += from.serverErrors;
|
|
89
|
+
into.sumMs += from.sumMs;
|
|
90
|
+
into.minMs = Math.min(into.minMs, from.minMs);
|
|
91
|
+
into.maxMs = Math.max(into.maxMs, from.maxMs);
|
|
92
|
+
for (let i = 0; i < BUCKET_COUNT; i++)
|
|
93
|
+
into.buckets[i] = (into.buckets[i] ?? 0) + (from.buckets[i] ?? 0);
|
|
94
|
+
}
|
|
95
|
+
const round3 = (ms) => Math.round(ms * 1000) / 1000;
|
|
96
|
+
function statsOf(t) {
|
|
97
|
+
return {
|
|
98
|
+
count: t.count,
|
|
99
|
+
clientErrors: t.clientErrors,
|
|
100
|
+
serverErrors: t.serverErrors,
|
|
101
|
+
avgMs: round3(t.count === 0 ? 0 : t.sumMs / t.count),
|
|
102
|
+
maxMs: round3(t.maxMs),
|
|
103
|
+
p50Ms: round3(quantile(t.buckets, 0.5, t.minMs, t.maxMs)),
|
|
104
|
+
p95Ms: round3(quantile(t.buckets, 0.95, t.minMs, t.maxMs)),
|
|
105
|
+
p99Ms: round3(quantile(t.buckets, 0.99, t.minMs, t.maxMs)),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const iso = (sec) => new Date(sec * 1000).toISOString();
|
|
109
|
+
/**
|
|
110
|
+
* Per-minute call aggregates (count, errors, latency histogram) in SQLite under DATA_DIR, rolled up hourly.
|
|
111
|
+
* Observations buffer in memory and flush every FLUSH_INTERVAL_MS; a crash loses at most one interval.
|
|
112
|
+
*/
|
|
113
|
+
export class MetricsStore {
|
|
114
|
+
db;
|
|
115
|
+
statements;
|
|
116
|
+
retentionDays;
|
|
117
|
+
log;
|
|
118
|
+
now;
|
|
119
|
+
pending = new Map();
|
|
120
|
+
lastPruneSec;
|
|
121
|
+
closed = false;
|
|
122
|
+
timer;
|
|
123
|
+
constructor(db, statements, retentionDays, log, now) {
|
|
124
|
+
this.db = db;
|
|
125
|
+
this.statements = statements;
|
|
126
|
+
this.retentionDays = retentionDays;
|
|
127
|
+
this.log = log;
|
|
128
|
+
this.now = now;
|
|
129
|
+
this.lastPruneSec = Math.floor(now() / 1000);
|
|
130
|
+
this.prune(this.lastPruneSec);
|
|
131
|
+
this.timer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
132
|
+
this.timer.unref();
|
|
133
|
+
}
|
|
134
|
+
static async open(options) {
|
|
135
|
+
// Loaded lazily: node:sqlite prints an ExperimentalWarning on load, so only processes that open storage pay it,
|
|
136
|
+
// not every `ok-fine --help` or stdio-proxy start.
|
|
137
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
138
|
+
const connect = () => {
|
|
139
|
+
let db;
|
|
140
|
+
try {
|
|
141
|
+
db = new DatabaseSync(options.path);
|
|
142
|
+
// A filesystem without WAL support keeps rollback journaling; the result is ignored.
|
|
143
|
+
db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 1000");
|
|
144
|
+
const version = db.prepare("PRAGMA user_version").get();
|
|
145
|
+
if (!version || num(version, "user_version") !== SCHEMA_VERSION)
|
|
146
|
+
createSchema(db);
|
|
147
|
+
return { db, statements: prepare(db) };
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (db?.isOpen)
|
|
151
|
+
db.close();
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
let opened;
|
|
156
|
+
try {
|
|
157
|
+
opened = connect();
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
options.log.warn({ err, path: options.path }, "metrics database unusable; recreating it");
|
|
161
|
+
for (const suffix of ["", "-wal", "-shm"])
|
|
162
|
+
rmSync(`${options.path}${suffix}`, { force: true });
|
|
163
|
+
opened = connect();
|
|
164
|
+
}
|
|
165
|
+
return new MetricsStore(opened.db, opened.statements, options.retentionDays, options.log, options.now ?? Date.now);
|
|
166
|
+
}
|
|
167
|
+
record(layer, op, startedAt, outcome) {
|
|
168
|
+
this.observe(layer, op, performance.now() - startedAt, outcome);
|
|
169
|
+
}
|
|
170
|
+
observe(layer, op, durationMs, outcome) {
|
|
171
|
+
if (this.closed)
|
|
172
|
+
return;
|
|
173
|
+
const minuteStart = Math.floor(this.now() / 60_000) * 60;
|
|
174
|
+
const key = `${minuteStart}\u0000${layer}\u0000${op}`;
|
|
175
|
+
let p = this.pending.get(key);
|
|
176
|
+
if (!p) {
|
|
177
|
+
p = {
|
|
178
|
+
minuteStart,
|
|
179
|
+
layer,
|
|
180
|
+
op,
|
|
181
|
+
count: 0,
|
|
182
|
+
clientErrors: 0,
|
|
183
|
+
serverErrors: 0,
|
|
184
|
+
sumMs: 0,
|
|
185
|
+
minMs: durationMs,
|
|
186
|
+
maxMs: durationMs,
|
|
187
|
+
buckets: new Array(BUCKET_COUNT).fill(0),
|
|
188
|
+
};
|
|
189
|
+
this.pending.set(key, p);
|
|
190
|
+
}
|
|
191
|
+
p.count++;
|
|
192
|
+
if (outcome === "client_error")
|
|
193
|
+
p.clientErrors++;
|
|
194
|
+
if (outcome === "server_error")
|
|
195
|
+
p.serverErrors++;
|
|
196
|
+
p.sumMs += durationMs;
|
|
197
|
+
p.minMs = Math.min(p.minMs, durationMs);
|
|
198
|
+
p.maxMs = Math.max(p.maxMs, durationMs);
|
|
199
|
+
const i = bucketIndex(durationMs);
|
|
200
|
+
p.buckets[i] = (p.buckets[i] ?? 0) + 1;
|
|
201
|
+
}
|
|
202
|
+
/** Writes buffered observations (and prunes when due) in one transaction. Never throws. */
|
|
203
|
+
flush() {
|
|
204
|
+
if (this.closed)
|
|
205
|
+
return;
|
|
206
|
+
const batch = this.pending;
|
|
207
|
+
this.pending = new Map();
|
|
208
|
+
const nowSec = Math.floor(this.now() / 1000);
|
|
209
|
+
const pruneDue = nowSec - this.lastPruneSec >= PRUNE_INTERVAL_SECONDS;
|
|
210
|
+
if (batch.size === 0 && !pruneDue)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
this.db.exec("BEGIN");
|
|
214
|
+
for (const p of batch.values()) {
|
|
215
|
+
const values = [
|
|
216
|
+
p.layer,
|
|
217
|
+
p.op,
|
|
218
|
+
p.count,
|
|
219
|
+
p.clientErrors,
|
|
220
|
+
p.serverErrors,
|
|
221
|
+
p.sumMs,
|
|
222
|
+
p.minMs,
|
|
223
|
+
p.maxMs,
|
|
224
|
+
...p.buckets,
|
|
225
|
+
];
|
|
226
|
+
this.statements.upsert.metrics_minute.run(p.minuteStart, ...values);
|
|
227
|
+
this.statements.upsert.metrics_hour.run(p.minuteStart - (p.minuteStart % 3600), ...values);
|
|
228
|
+
}
|
|
229
|
+
if (pruneDue)
|
|
230
|
+
this.prune(nowSec);
|
|
231
|
+
this.db.exec("COMMIT");
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (this.db.isTransaction)
|
|
235
|
+
this.db.exec("ROLLBACK");
|
|
236
|
+
this.log.warn({ err }, "metrics flush failed; dropped one batch");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
prune(nowSec) {
|
|
240
|
+
this.statements.prune.metrics_minute.run(nowSec - MINUTE_RETENTION_SECONDS);
|
|
241
|
+
this.statements.prune.metrics_hour.run(nowSec - this.retentionDays * 86_400);
|
|
242
|
+
this.lastPruneSec = nowSec;
|
|
243
|
+
}
|
|
244
|
+
report(window) {
|
|
245
|
+
this.flush();
|
|
246
|
+
const spec = WINDOWS[window];
|
|
247
|
+
const nowSec = Math.floor(this.now() / 1000);
|
|
248
|
+
const start = nowSec - spec.seconds;
|
|
249
|
+
const fromSec = start - (start % spec.stepSeconds);
|
|
250
|
+
const layerTotals = new Map();
|
|
251
|
+
const operations = [];
|
|
252
|
+
for (const row of this.statements.operations[spec.table].all(fromSec)) {
|
|
253
|
+
const layer = METRICS_LAYERS.find((l) => l === str(row, "layer"));
|
|
254
|
+
if (!layer)
|
|
255
|
+
continue;
|
|
256
|
+
const totals = totalsOf(row);
|
|
257
|
+
operations.push({ layer, op: str(row, "op"), ...statsOf(totals) });
|
|
258
|
+
const sum = layerTotals.get(layer);
|
|
259
|
+
if (sum)
|
|
260
|
+
merge(sum, totals);
|
|
261
|
+
else
|
|
262
|
+
layerTotals.set(layer, { ...totals, buckets: [...totals.buckets] });
|
|
263
|
+
}
|
|
264
|
+
operations.sort((a, b) => METRICS_LAYERS.indexOf(a.layer) - METRICS_LAYERS.indexOf(b.layer) ||
|
|
265
|
+
b.count - a.count ||
|
|
266
|
+
(a.op < b.op ? -1 : a.op > b.op ? 1 : 0));
|
|
267
|
+
const layers = METRICS_LAYERS.flatMap((layer) => {
|
|
268
|
+
const totals = layerTotals.get(layer);
|
|
269
|
+
return totals ? [{ layer, ...statsOf(totals) }] : [];
|
|
270
|
+
});
|
|
271
|
+
const series = [];
|
|
272
|
+
for (const row of this.statements.series[spec.table].all(spec.stepSeconds, fromSec)) {
|
|
273
|
+
const layer = METRICS_LAYERS.find((l) => l === str(row, "layer"));
|
|
274
|
+
if (!layer)
|
|
275
|
+
continue;
|
|
276
|
+
const stats = statsOf(totalsOf(row));
|
|
277
|
+
series.push({
|
|
278
|
+
at: iso(num(row, "t")),
|
|
279
|
+
layer,
|
|
280
|
+
count: stats.count,
|
|
281
|
+
serverErrors: stats.serverErrors,
|
|
282
|
+
avgMs: stats.avgMs,
|
|
283
|
+
p95Ms: stats.p95Ms,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return { window, from: iso(fromSec), to: iso(nowSec), stepSeconds: spec.stepSeconds, layers, operations, series };
|
|
287
|
+
}
|
|
288
|
+
/** Idempotent: stops the timer, flushes, and closes the database. */
|
|
289
|
+
close() {
|
|
290
|
+
if (this.closed)
|
|
291
|
+
return;
|
|
292
|
+
clearInterval(this.timer);
|
|
293
|
+
this.flush();
|
|
294
|
+
this.closed = true;
|
|
295
|
+
this.db.close();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=store.js.map
|
package/dist/okf/concept.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseFrontmatter, splitFrontmatter } from "./frontmatter.js";
|
|
2
|
-
import { extractLinks } from "./markdown.js";
|
|
2
|
+
import { extractCrossProjectLinks, extractLinks } from "./markdown.js";
|
|
3
3
|
import { blobRevision } from "./paths.js";
|
|
4
4
|
import { displayTitle, effectiveStatus, generatedAt, generatedBy, lastVerifiedAt, staleAfter, trustTier, } from "./semantics.js";
|
|
5
5
|
export function parseConcept(project, id, file, buf) {
|
|
@@ -26,6 +26,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
26
26
|
generatedBy: null,
|
|
27
27
|
lastVerifiedAt: null,
|
|
28
28
|
outbound: [],
|
|
29
|
+
crossProjectOutbound: [],
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
const parsed = parseFrontmatter(split.yaml);
|
|
@@ -49,6 +50,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
49
50
|
generatedBy: null,
|
|
50
51
|
lastVerifiedAt: null,
|
|
51
52
|
outbound: [],
|
|
53
|
+
crossProjectOutbound: [],
|
|
52
54
|
};
|
|
53
55
|
}
|
|
54
56
|
const { data } = parsed;
|
|
@@ -72,6 +74,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
72
74
|
generatedBy: generatedBy(data),
|
|
73
75
|
lastVerifiedAt: lastVerifiedAt(data),
|
|
74
76
|
outbound: extractLinks(split.body, id),
|
|
77
|
+
crossProjectOutbound: extractCrossProjectLinks(split.body),
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
const tags = Array.isArray(data.tags) ? data.tags.filter((t) => typeof t === "string") : [];
|
|
@@ -94,6 +97,7 @@ export function parseConcept(project, id, file, buf) {
|
|
|
94
97
|
generatedBy: generatedBy(data),
|
|
95
98
|
lastVerifiedAt: lastVerifiedAt(data),
|
|
96
99
|
outbound: extractLinks(split.body, id),
|
|
100
|
+
crossProjectOutbound: extractCrossProjectLinks(split.body),
|
|
97
101
|
};
|
|
98
102
|
}
|
|
99
103
|
//# sourceMappingURL=concept.js.map
|
package/dist/okf/lint.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { parseFrontmatter, splitFrontmatter } from "./frontmatter.js";
|
|
3
|
-
import { computationBlocks, extractFootnoteLabels, extractLinks, hasLegacyCitations } from "./markdown.js";
|
|
3
|
+
import { computationBlocks, extractCrossProjectLinks, extractFootnoteLabels, extractLinks, hasLegacyCitations, } from "./markdown.js";
|
|
4
4
|
import { ISO_DATETIME, isStale, parseActor } from "./semantics.js";
|
|
5
5
|
const COMMIT_RE = /^[0-9a-f]{7,64}$/i;
|
|
6
6
|
const SHELL_META_OR_WHITESPACE = /[\s`$;|&<>()\\"'`]/;
|
|
@@ -371,6 +371,20 @@ export function lintConceptFile(path, text, ctx) {
|
|
|
371
371
|
});
|
|
372
372
|
}
|
|
373
373
|
}
|
|
374
|
+
// Warning: cross-project outbound links
|
|
375
|
+
if (ctx.crossProjectConceptExists) {
|
|
376
|
+
const crossLinks = extractCrossProjectLinks(body);
|
|
377
|
+
for (const target of crossLinks) {
|
|
378
|
+
if (!ctx.crossProjectConceptExists(target.project, target.id)) {
|
|
379
|
+
issues.push({
|
|
380
|
+
severity: "warning",
|
|
381
|
+
code: "broken_cross_link",
|
|
382
|
+
path,
|
|
383
|
+
message: `cross-project link to "okf://${target.project}/${target.id}" does not exist`,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
374
388
|
// Info: stale
|
|
375
389
|
if (isStale(data, ctx.now)) {
|
|
376
390
|
issues.push({
|
package/dist/okf/markdown.js
CHANGED
|
@@ -1,8 +1,90 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { marked } from "marked";
|
|
3
|
-
import { isReservedName } from "./paths.js";
|
|
3
|
+
import { isReservedName, PROJECT_RE, SEGMENT_RE } from "./paths.js";
|
|
4
4
|
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
5
5
|
const FOOTNOTE_RE = /\[\^([^\]\s]+)\](?!:)/g;
|
|
6
|
+
export function parseCrossProjectTarget(rawHref) {
|
|
7
|
+
if (!rawHref) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
let href = rawHref;
|
|
11
|
+
try {
|
|
12
|
+
href = decodeURI(href);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// ignore URI decode failures
|
|
16
|
+
}
|
|
17
|
+
if (href.includes("\\")) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const trimmed = href.trim();
|
|
21
|
+
if (!trimmed.toLowerCase().startsWith("okf://")) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
let withoutHash = trimmed;
|
|
25
|
+
const hashIdx = withoutHash.indexOf("#");
|
|
26
|
+
if (hashIdx !== -1) {
|
|
27
|
+
withoutHash = withoutHash.slice(0, hashIdx);
|
|
28
|
+
}
|
|
29
|
+
const queryIdx = withoutHash.indexOf("?");
|
|
30
|
+
if (queryIdx !== -1) {
|
|
31
|
+
withoutHash = withoutHash.slice(0, queryIdx);
|
|
32
|
+
}
|
|
33
|
+
const rest = withoutHash.slice(6);
|
|
34
|
+
const slashIdx = rest.indexOf("/");
|
|
35
|
+
if (slashIdx === -1) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const project = rest.slice(0, slashIdx);
|
|
39
|
+
if (!PROJECT_RE.test(project)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
let target = rest.slice(slashIdx + 1);
|
|
43
|
+
if (target.endsWith(".md")) {
|
|
44
|
+
target = target.slice(0, -3);
|
|
45
|
+
}
|
|
46
|
+
if (target.length === 0 || target.length > 512) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const segments = target.split("/");
|
|
50
|
+
if (segments.length > 12) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
for (const seg of segments) {
|
|
54
|
+
if (!SEGMENT_RE.test(seg)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const lastSeg = segments[segments.length - 1];
|
|
59
|
+
if (!lastSeg) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const lastLower = lastSeg.toLowerCase();
|
|
63
|
+
if (lastLower === "index" || lastLower === "log") {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return { project, id: segments.join("/") };
|
|
67
|
+
}
|
|
68
|
+
export function extractCrossProjectLinks(body) {
|
|
69
|
+
const tokens = marked.lexer(body);
|
|
70
|
+
const found = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
marked.walkTokens(tokens, (token) => {
|
|
73
|
+
if (token.type !== "link" || token.raw.startsWith("[^")) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const target = parseCrossProjectTarget(token.href);
|
|
77
|
+
if (!target) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const key = `${target.project}/${target.id}`;
|
|
81
|
+
if (!seen.has(key)) {
|
|
82
|
+
seen.add(key);
|
|
83
|
+
found.push(target);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
6
88
|
export function extractLinks(body, conceptId) {
|
|
7
89
|
const tokens = marked.lexer(body);
|
|
8
90
|
const found = [];
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
3
|
import { buildOAuthProtectedResourceMetadata, createMcpHandler, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
|
|
3
4
|
import Fastify, {} from "fastify";
|
|
@@ -7,10 +8,13 @@ import { createAuthenticator } from "./auth/http-auth.js";
|
|
|
7
8
|
import { JwtTokenVerifier, principalFromAuthInfo } from "./auth/verifier.js";
|
|
8
9
|
import { OkfError } from "./errors.js";
|
|
9
10
|
import { registerMcpRoute } from "./http/mcp-route.js";
|
|
11
|
+
import { registerMetricsRoutes } from "./http/metrics.js";
|
|
10
12
|
import { registerRestRoutes } from "./http/rest.js";
|
|
11
13
|
import { DEFAULT_UI_DIR, endpointOrigins, registerUiRoutes } from "./http/ui.js";
|
|
12
14
|
import { createLocalHost } from "./local-host.js";
|
|
13
15
|
import { createMcpServer } from "./mcp/server.js";
|
|
16
|
+
import { InstrumentedStorage, instrumentService } from "./metrics/instrument.js";
|
|
17
|
+
import { METRICS_DB_FILE, MetricsStore } from "./metrics/store.js";
|
|
14
18
|
import { KnowledgeService } from "./service/knowledge-service.js";
|
|
15
19
|
import { Catalog } from "./store/catalog.js";
|
|
16
20
|
import { GitBackend } from "./store/git-backend.js";
|
|
@@ -205,10 +209,8 @@ export async function startServer(config, options = {}) {
|
|
|
205
209
|
},
|
|
206
210
|
};
|
|
207
211
|
}
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
const service = new KnowledgeService({ config, storage, catalog, log: app.log });
|
|
211
|
-
await service.initialize();
|
|
212
|
+
const knowledge = await openKnowledge(config, app.log);
|
|
213
|
+
const service = knowledge.service;
|
|
212
214
|
const mcpHandler = createMcpHandler(({ authInfo }) => {
|
|
213
215
|
if (!authInfo)
|
|
214
216
|
throw new Error("MCP request reached the handler without authInfo");
|
|
@@ -270,6 +272,7 @@ export async function startServer(config, options = {}) {
|
|
|
270
272
|
});
|
|
271
273
|
registerMcpRoute(secured, (request, options) => mcpHandler.fetch(request, options), config.publicBaseUrl);
|
|
272
274
|
registerRestRoutes(secured, service);
|
|
275
|
+
registerMetricsRoutes(secured, knowledge.metrics);
|
|
273
276
|
});
|
|
274
277
|
const stopSync = startPeriodicSync(config, service, app.log);
|
|
275
278
|
let localHost;
|
|
@@ -283,7 +286,7 @@ export async function startServer(config, options = {}) {
|
|
|
283
286
|
catch (err) {
|
|
284
287
|
stopSync();
|
|
285
288
|
await localHost?.close();
|
|
286
|
-
await
|
|
289
|
+
await knowledge.close();
|
|
287
290
|
await mcpHandler.close();
|
|
288
291
|
throw err;
|
|
289
292
|
}
|
|
@@ -294,11 +297,43 @@ export async function startServer(config, options = {}) {
|
|
|
294
297
|
stopSync();
|
|
295
298
|
await localHost?.close();
|
|
296
299
|
await app.close();
|
|
297
|
-
await
|
|
300
|
+
await knowledge.close();
|
|
298
301
|
await mcpHandler.close();
|
|
299
302
|
},
|
|
300
303
|
};
|
|
301
304
|
}
|
|
305
|
+
/** Opens git storage, the metrics database under DATA_DIR, and the instrumented service on top of them. */
|
|
306
|
+
async function openKnowledge(config, log) {
|
|
307
|
+
// Creates DATA_DIR, which the metrics database lives in.
|
|
308
|
+
const git = await GitBackend.open(config, log);
|
|
309
|
+
let metrics;
|
|
310
|
+
try {
|
|
311
|
+
metrics = await MetricsStore.open({
|
|
312
|
+
path: join(config.dataDir, METRICS_DB_FILE),
|
|
313
|
+
retentionDays: config.metricsRetentionDays,
|
|
314
|
+
log,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
await git.close();
|
|
319
|
+
throw err;
|
|
320
|
+
}
|
|
321
|
+
const storage = new InstrumentedStorage(git, metrics);
|
|
322
|
+
const close = async () => {
|
|
323
|
+
await storage.close();
|
|
324
|
+
metrics.close();
|
|
325
|
+
};
|
|
326
|
+
// Not instrumented yet: the startup catalog rebuild records storage calls but no service op.
|
|
327
|
+
const raw = new KnowledgeService({ config, storage, catalog: new Catalog(), log });
|
|
328
|
+
try {
|
|
329
|
+
await raw.initialize();
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
await close();
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
return { service: instrumentService(raw, metrics), metrics, close };
|
|
336
|
+
}
|
|
302
337
|
/** Runs `service.syncNow()` every GIT_SYNC_INTERVAL_SECONDS when a remote is configured; returns a stop function. */
|
|
303
338
|
function startPeriodicSync(config, service, log) {
|
|
304
339
|
if (!config.gitRemoteUrl || config.gitSyncIntervalSeconds <= 0)
|
|
@@ -326,10 +361,8 @@ function startPeriodicSync(config, service, log) {
|
|
|
326
361
|
/** Starts the local endpoint host that listens on a domain socket / named pipe. */
|
|
327
362
|
export async function startLocalHost(config, options) {
|
|
328
363
|
const { log } = options;
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
const service = new KnowledgeService({ config, storage, catalog, log });
|
|
332
|
-
await service.initialize();
|
|
364
|
+
const knowledge = await openKnowledge(config, log);
|
|
365
|
+
const service = knowledge.service;
|
|
333
366
|
const stopSync = startPeriodicSync(config, service, log);
|
|
334
367
|
const host = await createLocalHost({ service, config, log });
|
|
335
368
|
let closing;
|
|
@@ -340,7 +373,7 @@ export async function startLocalHost(config, options) {
|
|
|
340
373
|
stopSync();
|
|
341
374
|
await host.close();
|
|
342
375
|
// Waits for the storage mutex, so an in-flight write still commits.
|
|
343
|
-
await
|
|
376
|
+
await knowledge.close();
|
|
344
377
|
})();
|
|
345
378
|
return closing;
|
|
346
379
|
},
|