@biaoo/tiangong-wiki 0.2.2 → 0.3.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.
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AppError } from "../utils/errors.js";
3
+ const ASCII_ID_PATTERN = /^[A-Za-z0-9._:@/-]+$/;
4
+ function isPlainObject(value) {
5
+ return Object.prototype.toString.call(value) === "[object Object]";
6
+ }
7
+ function normalizeOptionalString(value) {
8
+ if (typeof value !== "string") {
9
+ return null;
10
+ }
11
+ const normalized = value.trim();
12
+ return normalized ? normalized : null;
13
+ }
14
+ function validateAsciiField(field, value) {
15
+ if (!ASCII_ID_PATTERN.test(value)) {
16
+ throw new AppError(`${field} must be a stable ASCII identifier, got ${value}`, "config", {
17
+ code: "invalid_request",
18
+ field,
19
+ });
20
+ }
21
+ return value;
22
+ }
23
+ function newRequestId() {
24
+ return `req:${randomUUID()}`;
25
+ }
26
+ function getBodyActor(body) {
27
+ return isPlainObject(body.actor) ? body.actor : null;
28
+ }
29
+ export function buildCliWriteActor(env = process.env) {
30
+ return {
31
+ actorId: validateAsciiField("actorId", env.WIKI_ACTOR_ID?.trim() || "user:local-cli"),
32
+ actorType: validateAsciiField("actorType", env.WIKI_ACTOR_TYPE?.trim() || "user"),
33
+ requestId: validateAsciiField("requestId", env.WIKI_REQUEST_ID?.trim() || newRequestId()),
34
+ };
35
+ }
36
+ export function buildSystemWriteActor(label = "daemon") {
37
+ return {
38
+ actorId: validateAsciiField("actorId", `system:${label}`),
39
+ actorType: validateAsciiField("actorType", "system"),
40
+ requestId: validateAsciiField("requestId", newRequestId()),
41
+ };
42
+ }
43
+ export function resolveWriteActor(request, body, fallback) {
44
+ const bodyActor = getBodyActor(body);
45
+ const actorId = normalizeOptionalString(request.headers["x-wiki-actor-id"]) ??
46
+ normalizeOptionalString(bodyActor?.actorId) ??
47
+ fallback.actorId;
48
+ const actorType = normalizeOptionalString(request.headers["x-wiki-actor-type"]) ??
49
+ normalizeOptionalString(bodyActor?.actorType) ??
50
+ fallback.actorType;
51
+ const requestId = normalizeOptionalString(request.headers["x-request-id"]) ??
52
+ normalizeOptionalString(bodyActor?.requestId) ??
53
+ fallback.requestId ??
54
+ newRequestId();
55
+ return {
56
+ actorId: validateAsciiField("actorId", actorId),
57
+ actorType: validateAsciiField("actorType", actorType),
58
+ requestId: validateAsciiField("requestId", requestId),
59
+ };
60
+ }
@@ -0,0 +1,360 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { openRuntimeDb } from "../core/runtime.js";
3
+ import { AppError, asAppError } from "../utils/errors.js";
4
+ import { toOffsetIso } from "../utils/time.js";
5
+ const DEFAULT_MAX_DEPTH = 100;
6
+ const DEFAULT_JOB_TIMEOUT_MS = 300_000;
7
+ const RECENT_JOB_LIMIT = 12;
8
+ const QUEUED_JOB_PREVIEW_LIMIT = 20;
9
+ function parsePositiveInteger(value, fallback) {
10
+ const parsed = Number.parseInt(value ?? "", 10);
11
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
12
+ }
13
+ function parseOptionalObject(value) {
14
+ if (!value) {
15
+ return null;
16
+ }
17
+ try {
18
+ const parsed = JSON.parse(value);
19
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
20
+ ? parsed
21
+ : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function serializeOptionalObject(value) {
28
+ return value ? JSON.stringify(value) : null;
29
+ }
30
+ function snapshotDurationMs(job) {
31
+ if (!job.startedAt || !job.finishedAt) {
32
+ return null;
33
+ }
34
+ const startedAtMs = new Date(job.startedAt).getTime();
35
+ const finishedAtMs = new Date(job.finishedAt).getTime();
36
+ if (!Number.isFinite(startedAtMs) || !Number.isFinite(finishedAtMs)) {
37
+ return null;
38
+ }
39
+ return Math.max(0, finishedAtMs - startedAtMs);
40
+ }
41
+ function cloneSnapshot(snapshot, positionInQueue = snapshot.positionInQueue) {
42
+ return {
43
+ ...snapshot,
44
+ positionInQueue,
45
+ resultSummary: snapshot.resultSummary ? { ...snapshot.resultSummary } : null,
46
+ errorDetails: snapshot.errorDetails ? { ...snapshot.errorDetails } : null,
47
+ };
48
+ }
49
+ function createInitialSnapshot(taskType, timeoutMs, queueDepthAtEnqueue) {
50
+ return {
51
+ jobId: randomUUID(),
52
+ taskType,
53
+ status: "queued",
54
+ enqueuedAt: toOffsetIso(),
55
+ startedAt: null,
56
+ finishedAt: null,
57
+ durationMs: null,
58
+ timeoutMs,
59
+ queueDepthAtEnqueue,
60
+ positionInQueue: queueDepthAtEnqueue + 1,
61
+ resultSummary: null,
62
+ errorMessage: null,
63
+ errorDetails: null,
64
+ };
65
+ }
66
+ function readPersistedRecentJobs(env, limit = RECENT_JOB_LIMIT) {
67
+ const { db } = openRuntimeDb(env);
68
+ try {
69
+ const rows = db.prepare(`
70
+ SELECT
71
+ job_id AS jobId,
72
+ task_type AS taskType,
73
+ status,
74
+ enqueued_at AS enqueuedAt,
75
+ started_at AS startedAt,
76
+ finished_at AS finishedAt,
77
+ duration_ms AS durationMs,
78
+ timeout_ms AS timeoutMs,
79
+ queue_depth_at_enqueue AS queueDepthAtEnqueue,
80
+ result_summary AS resultSummary,
81
+ error_message AS errorMessage,
82
+ error_details AS errorDetails
83
+ FROM daemon_write_jobs
84
+ WHERE status IN ('succeeded', 'failed', 'timed_out')
85
+ ORDER BY COALESCE(finished_at, enqueued_at) DESC
86
+ LIMIT ?
87
+ `).all(limit);
88
+ return rows.map((row) => ({
89
+ jobId: row.jobId,
90
+ taskType: row.taskType,
91
+ status: row.status,
92
+ enqueuedAt: row.enqueuedAt,
93
+ startedAt: row.startedAt,
94
+ finishedAt: row.finishedAt,
95
+ durationMs: row.durationMs,
96
+ timeoutMs: row.timeoutMs,
97
+ queueDepthAtEnqueue: row.queueDepthAtEnqueue,
98
+ positionInQueue: null,
99
+ resultSummary: parseOptionalObject(row.resultSummary),
100
+ errorMessage: row.errorMessage,
101
+ errorDetails: parseOptionalObject(row.errorDetails),
102
+ }));
103
+ }
104
+ finally {
105
+ db.close();
106
+ }
107
+ }
108
+ function readPersistedJob(env, jobId) {
109
+ const { db } = openRuntimeDb(env);
110
+ try {
111
+ const row = db.prepare(`
112
+ SELECT
113
+ job_id AS jobId,
114
+ task_type AS taskType,
115
+ status,
116
+ enqueued_at AS enqueuedAt,
117
+ started_at AS startedAt,
118
+ finished_at AS finishedAt,
119
+ duration_ms AS durationMs,
120
+ timeout_ms AS timeoutMs,
121
+ queue_depth_at_enqueue AS queueDepthAtEnqueue,
122
+ result_summary AS resultSummary,
123
+ error_message AS errorMessage,
124
+ error_details AS errorDetails
125
+ FROM daemon_write_jobs
126
+ WHERE job_id = ?
127
+ `).get(jobId);
128
+ if (!row) {
129
+ return null;
130
+ }
131
+ return {
132
+ jobId: row.jobId,
133
+ taskType: row.taskType,
134
+ status: row.status,
135
+ enqueuedAt: row.enqueuedAt,
136
+ startedAt: row.startedAt,
137
+ finishedAt: row.finishedAt,
138
+ durationMs: row.durationMs,
139
+ timeoutMs: row.timeoutMs,
140
+ queueDepthAtEnqueue: row.queueDepthAtEnqueue,
141
+ positionInQueue: null,
142
+ resultSummary: parseOptionalObject(row.resultSummary),
143
+ errorMessage: row.errorMessage,
144
+ errorDetails: parseOptionalObject(row.errorDetails),
145
+ };
146
+ }
147
+ finally {
148
+ db.close();
149
+ }
150
+ }
151
+ function persistJobSnapshot(env, snapshot) {
152
+ const { db } = openRuntimeDb(env);
153
+ try {
154
+ db.prepare(`
155
+ INSERT INTO daemon_write_jobs (
156
+ job_id,
157
+ task_type,
158
+ status,
159
+ enqueued_at,
160
+ started_at,
161
+ finished_at,
162
+ duration_ms,
163
+ timeout_ms,
164
+ queue_depth_at_enqueue,
165
+ result_summary,
166
+ error_message,
167
+ error_details
168
+ )
169
+ VALUES (
170
+ @job_id,
171
+ @task_type,
172
+ @status,
173
+ @enqueued_at,
174
+ @started_at,
175
+ @finished_at,
176
+ @duration_ms,
177
+ @timeout_ms,
178
+ @queue_depth_at_enqueue,
179
+ @result_summary,
180
+ @error_message,
181
+ @error_details
182
+ )
183
+ ON CONFLICT(job_id) DO UPDATE SET
184
+ task_type = excluded.task_type,
185
+ status = excluded.status,
186
+ enqueued_at = excluded.enqueued_at,
187
+ started_at = excluded.started_at,
188
+ finished_at = excluded.finished_at,
189
+ duration_ms = excluded.duration_ms,
190
+ timeout_ms = excluded.timeout_ms,
191
+ queue_depth_at_enqueue = excluded.queue_depth_at_enqueue,
192
+ result_summary = excluded.result_summary,
193
+ error_message = excluded.error_message,
194
+ error_details = excluded.error_details
195
+ `).run({
196
+ job_id: snapshot.jobId,
197
+ task_type: snapshot.taskType,
198
+ status: snapshot.status,
199
+ enqueued_at: snapshot.enqueuedAt,
200
+ started_at: snapshot.startedAt,
201
+ finished_at: snapshot.finishedAt,
202
+ duration_ms: snapshot.durationMs,
203
+ timeout_ms: snapshot.timeoutMs,
204
+ queue_depth_at_enqueue: snapshot.queueDepthAtEnqueue,
205
+ result_summary: serializeOptionalObject(snapshot.resultSummary),
206
+ error_message: snapshot.errorMessage,
207
+ error_details: serializeOptionalObject(snapshot.errorDetails),
208
+ });
209
+ }
210
+ finally {
211
+ db.close();
212
+ }
213
+ }
214
+ export class DaemonWriteQueue {
215
+ maxDepth;
216
+ jobTimeoutMs;
217
+ env;
218
+ hooks;
219
+ pendingJobs = [];
220
+ idleResolvers = [];
221
+ activeJob = null;
222
+ constructor(env, hooks = {}) {
223
+ this.env = env;
224
+ this.hooks = hooks;
225
+ this.maxDepth = parsePositiveInteger(env.WIKI_TEST_WRITE_QUEUE_MAX_DEPTH, DEFAULT_MAX_DEPTH);
226
+ this.jobTimeoutMs = parsePositiveInteger(env.WIKI_TEST_WRITE_QUEUE_TIMEOUT_MS, DEFAULT_JOB_TIMEOUT_MS);
227
+ }
228
+ hasWork() {
229
+ return this.activeJob !== null || this.pendingJobs.length > 0;
230
+ }
231
+ getSummary() {
232
+ return {
233
+ limits: {
234
+ maxDepth: this.maxDepth,
235
+ jobTimeoutMs: this.jobTimeoutMs,
236
+ },
237
+ counts: {
238
+ queued: this.pendingJobs.length,
239
+ running: this.activeJob ? 1 : 0,
240
+ recent: readPersistedRecentJobs(this.env).length,
241
+ },
242
+ activeJob: this.activeJob ? cloneSnapshot(this.activeJob.snapshot, null) : null,
243
+ queuedJobs: this.pendingJobs
244
+ .slice(0, QUEUED_JOB_PREVIEW_LIMIT)
245
+ .map((job, index) => cloneSnapshot(job.snapshot, index + 1)),
246
+ recentJobs: readPersistedRecentJobs(this.env),
247
+ generatedAt: toOffsetIso(),
248
+ };
249
+ }
250
+ getJob(jobId) {
251
+ if (this.activeJob?.snapshot.jobId === jobId) {
252
+ return cloneSnapshot(this.activeJob.snapshot, null);
253
+ }
254
+ const pendingIndex = this.pendingJobs.findIndex((job) => job.snapshot.jobId === jobId);
255
+ if (pendingIndex >= 0) {
256
+ return cloneSnapshot(this.pendingJobs[pendingIndex].snapshot, pendingIndex + 1);
257
+ }
258
+ return readPersistedJob(this.env, jobId);
259
+ }
260
+ async waitForIdle() {
261
+ if (!this.hasWork()) {
262
+ return;
263
+ }
264
+ await new Promise((resolve) => {
265
+ this.idleResolvers.push(resolve);
266
+ });
267
+ }
268
+ enqueue(taskType, run, options = {}) {
269
+ if (this.pendingJobs.length >= this.maxDepth) {
270
+ throw new AppError("Write queue is full.", "runtime", {
271
+ code: "queue_full",
272
+ maxDepth: this.maxDepth,
273
+ });
274
+ }
275
+ const snapshot = createInitialSnapshot(taskType, this.jobTimeoutMs, this.pendingJobs.length);
276
+ persistJobSnapshot(this.env, snapshot);
277
+ return new Promise((resolve, reject) => {
278
+ this.pendingJobs.push({
279
+ snapshot,
280
+ run: () => run(),
281
+ resolve: (value) => resolve(value),
282
+ reject,
283
+ summarizeResult: options.summarizeResult
284
+ ? (result) => options.summarizeResult(result)
285
+ : undefined,
286
+ });
287
+ void this.processNext();
288
+ });
289
+ }
290
+ notifyIdleIfNeeded() {
291
+ if (this.hasWork()) {
292
+ return;
293
+ }
294
+ while (this.idleResolvers.length > 0) {
295
+ this.idleResolvers.pop()();
296
+ }
297
+ }
298
+ async processNext() {
299
+ if (this.activeJob || this.pendingJobs.length === 0) {
300
+ return;
301
+ }
302
+ const job = this.pendingJobs.shift();
303
+ this.activeJob = job;
304
+ job.snapshot.status = "running";
305
+ job.snapshot.startedAt = toOffsetIso();
306
+ job.snapshot.positionInQueue = null;
307
+ persistJobSnapshot(this.env, job.snapshot);
308
+ this.hooks.onJobStart?.(cloneSnapshot(job.snapshot, null));
309
+ let timeoutHandle = null;
310
+ let didTimeout = false;
311
+ const runPromise = Promise.resolve().then(job.run);
312
+ const timeoutPromise = new Promise((_, reject) => {
313
+ timeoutHandle = setTimeout(() => {
314
+ didTimeout = true;
315
+ reject(new AppError(`Write queue job timed out after ${job.snapshot.timeoutMs}ms.`, "runtime", {
316
+ code: "job_timeout",
317
+ jobId: job.snapshot.jobId,
318
+ taskType: job.snapshot.taskType,
319
+ timeoutMs: job.snapshot.timeoutMs,
320
+ }));
321
+ }, job.snapshot.timeoutMs);
322
+ });
323
+ try {
324
+ const result = await Promise.race([runPromise, timeoutPromise]);
325
+ job.snapshot.status = "succeeded";
326
+ job.snapshot.finishedAt = toOffsetIso();
327
+ job.snapshot.durationMs = snapshotDurationMs(job.snapshot);
328
+ job.snapshot.resultSummary = job.summarizeResult?.(result) ?? null;
329
+ job.snapshot.errorMessage = null;
330
+ job.snapshot.errorDetails = null;
331
+ persistJobSnapshot(this.env, job.snapshot);
332
+ job.resolve(result);
333
+ }
334
+ catch (error) {
335
+ const appError = asAppError(error);
336
+ job.snapshot.status = didTimeout ? "timed_out" : "failed";
337
+ job.snapshot.finishedAt = toOffsetIso();
338
+ job.snapshot.durationMs = snapshotDurationMs(job.snapshot);
339
+ job.snapshot.errorMessage = appError.message;
340
+ job.snapshot.errorDetails =
341
+ typeof appError.details === "object" && appError.details !== null && !Array.isArray(appError.details)
342
+ ? { ...appError.details }
343
+ : null;
344
+ persistJobSnapshot(this.env, job.snapshot);
345
+ job.reject(appError);
346
+ }
347
+ finally {
348
+ if (timeoutHandle) {
349
+ clearTimeout(timeoutHandle);
350
+ }
351
+ if (didTimeout) {
352
+ await runPromise.catch(() => undefined);
353
+ }
354
+ this.activeJob = null;
355
+ this.hooks.onJobFinish?.(cloneSnapshot(job.snapshot, null));
356
+ this.notifyIdleIfNeeded();
357
+ void this.processNext();
358
+ }
359
+ }
360
+ }
@@ -3,6 +3,7 @@ import { getMeta } from "../core/db.js";
3
3
  import { parsePage } from "../core/frontmatter.js";
4
4
  import { buildDoctorReport } from "../core/onboarding.js";
5
5
  import { resolveRuntimePaths } from "../core/paths.js";
6
+ import { readCanonicalPageSource } from "../core/page-source.js";
6
7
  import { compactPageSummary } from "../core/presenters.js";
7
8
  import { listPageColumns, mapPageRow, selectPageById } from "../core/query.js";
8
9
  import { openRuntimeDb } from "../core/runtime.js";
@@ -859,16 +860,10 @@ export async function getDashboardPageSource(env = process.env, inputPageId) {
859
860
  throw new AppError(`Page not found: ${pageId}`, "not_found");
860
861
  }
861
862
  const pageFilePath = path.join(paths.wikiPath, ...String(page.id).split("/"));
862
- const parsed = parsePage(pageFilePath, paths.wikiPath, config);
863
- const rawData = parsed.ok ? parsed.parsed.rawData : {};
863
+ const pageSource = readCanonicalPageSource(pageFilePath, paths.wikiPath, config);
864
864
  return {
865
- pageSource: {
866
- pageId: String(page.id),
867
- pagePath: pageFilePath,
868
- rawMarkdown: readOptionalText(pageFilePath),
869
- frontmatter: rawData,
870
- },
871
- vaultSource: await resolvePageVaultSource(db, config, env, page, rawData),
865
+ pageSource,
866
+ vaultSource: await resolvePageVaultSource(db, config, env, page, pageSource.frontmatter),
872
867
  generatedAt: toOffsetIso(),
873
868
  };
874
869
  }
@@ -1,10 +1,18 @@
1
1
  import { getTemplate } from "../core/config.js";
2
2
  import { resolveAgentSettings } from "../core/paths.js";
3
+ import { normalizePageId } from "../core/paths.js";
3
4
  import { createPageFromTemplate } from "../core/page-files.js";
5
+ import { updatePageById } from "../core/page-files.js";
6
+ import { readCanonicalPageSourceById } from "../core/page-source.js";
4
7
  import { loadRuntimeConfig } from "../core/runtime.js";
8
+ import { openRuntimeDb } from "../core/runtime.js";
5
9
  import { syncWorkspace } from "../core/sync.js";
6
10
  import { getVaultQueueItem, processVaultQueueBatch } from "../core/vault-processing.js";
7
- import { AppError } from "../utils/errors.js";
11
+ import { selectPageById } from "../core/query.js";
12
+ import { AppError, asAppError } from "../utils/errors.js";
13
+ function isPlainObject(value) {
14
+ return Object.prototype.toString.call(value) === "[object Object]";
15
+ }
8
16
  function assertValidSyncCommandOptions(options) {
9
17
  if (options.vaultFileId && !options.process) {
10
18
  throw new AppError("--vault-file requires --process.", "config");
@@ -132,12 +140,92 @@ export async function createPage(env = process.env, options) {
132
140
  title: options.title,
133
141
  nodeId: options.nodeId ?? undefined,
134
142
  });
135
- await syncWorkspace({
136
- env,
137
- targetPaths: [created.pageId],
138
- });
143
+ try {
144
+ await syncWorkspace({
145
+ env,
146
+ targetPaths: [created.pageId],
147
+ });
148
+ }
149
+ catch (error) {
150
+ const appError = asAppError(error);
151
+ throw new AppError(`Sync failed after creating page: ${created.pageId}`, "runtime", {
152
+ code: "sync_failed",
153
+ pageId: created.pageId,
154
+ filePath: created.filePath,
155
+ revisionAfter: readCanonicalPageSourceById(created.pageId, paths.wikiPath, config).revision,
156
+ cause: appError.message,
157
+ });
158
+ }
139
159
  return {
140
160
  created: created.pageId,
141
161
  filePath: created.filePath,
142
162
  };
143
163
  }
164
+ export async function updatePage(env = process.env, options) {
165
+ const pageIdInput = typeof options.pageId === "string" ? options.pageId.trim() : "";
166
+ if (!pageIdInput) {
167
+ throw new AppError("pageId is required.", "config", {
168
+ code: "invalid_request",
169
+ field: "pageId",
170
+ });
171
+ }
172
+ const hasBodyMarkdown = typeof options.bodyMarkdown === "string";
173
+ const hasFrontmatterPatch = options.frontmatterPatch !== undefined &&
174
+ isPlainObject(options.frontmatterPatch) &&
175
+ Object.keys(options.frontmatterPatch).length > 0;
176
+ if (options.frontmatterPatch !== undefined && !isPlainObject(options.frontmatterPatch)) {
177
+ throw new AppError("frontmatterPatch must be an object.", "config", {
178
+ code: "invalid_request",
179
+ field: "frontmatterPatch",
180
+ });
181
+ }
182
+ if (!hasBodyMarkdown && !hasFrontmatterPatch) {
183
+ throw new AppError("bodyMarkdown or frontmatterPatch is required.", "config", {
184
+ code: "invalid_request",
185
+ });
186
+ }
187
+ const { db, config, paths } = openRuntimeDb(env);
188
+ let canonicalPageId;
189
+ try {
190
+ const normalizedPageId = normalizePageId(pageIdInput, paths.wikiPath);
191
+ const page = selectPageById(db, config, normalizedPageId);
192
+ if (!page) {
193
+ throw new AppError(`Page not found: ${normalizedPageId}`, "not_found");
194
+ }
195
+ canonicalPageId = String(page.id);
196
+ }
197
+ finally {
198
+ db.close();
199
+ }
200
+ const currentSource = readCanonicalPageSourceById(canonicalPageId, paths.wikiPath, config);
201
+ const expectedRevision = typeof options.ifRevision === "string" && options.ifRevision.trim() ? options.ifRevision.trim() : null;
202
+ if (expectedRevision && currentSource.revision !== expectedRevision) {
203
+ throw new AppError(`Page revision conflict: ${canonicalPageId}`, "runtime", {
204
+ code: "revision_conflict",
205
+ pageId: canonicalPageId,
206
+ ifRevision: expectedRevision,
207
+ currentRevision: currentSource.revision,
208
+ });
209
+ }
210
+ updatePageById(paths, canonicalPageId, {
211
+ bodyMarkdown: hasBodyMarkdown ? options.bodyMarkdown : undefined,
212
+ frontmatterPatch: hasFrontmatterPatch ? options.frontmatterPatch : undefined,
213
+ });
214
+ try {
215
+ await syncWorkspace({
216
+ env,
217
+ targetPaths: [canonicalPageId],
218
+ });
219
+ }
220
+ catch (error) {
221
+ const appError = asAppError(error);
222
+ throw new AppError(`Sync failed after updating page: ${canonicalPageId}`, "runtime", {
223
+ code: "sync_failed",
224
+ pageId: canonicalPageId,
225
+ revisionBefore: currentSource.revision,
226
+ revisionAfter: readCanonicalPageSourceById(canonicalPageId, paths.wikiPath, config).revision,
227
+ cause: appError.message,
228
+ });
229
+ }
230
+ return readCanonicalPageSourceById(canonicalPageId, paths.wikiPath, config);
231
+ }
@@ -0,0 +1,90 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ export class DaemonHttpError extends Error {
3
+ type;
4
+ httpStatus;
5
+ details;
6
+ constructor(message, type, httpStatus, details) {
7
+ super(message);
8
+ this.type = type;
9
+ this.httpStatus = httpStatus;
10
+ this.details = details;
11
+ this.name = "DaemonHttpError";
12
+ }
13
+ }
14
+ function parsePort(rawValue) {
15
+ if (!rawValue || !rawValue.trim()) {
16
+ return null;
17
+ }
18
+ const value = Number.parseInt(rawValue.trim(), 10);
19
+ if (!Number.isInteger(value) || value < 0 || value > 65535) {
20
+ throw new Error(`Invalid WIKI_DAEMON_PORT: ${rawValue}`);
21
+ }
22
+ return value;
23
+ }
24
+ export function resolveDaemonBaseUrl(env = process.env) {
25
+ const rawBaseUrl = env.WIKI_DAEMON_BASE_URL?.trim();
26
+ if (rawBaseUrl) {
27
+ return new URL(rawBaseUrl.endsWith("/") ? rawBaseUrl : `${rawBaseUrl}/`);
28
+ }
29
+ const port = parsePort(env.WIKI_DAEMON_PORT);
30
+ if (port === null) {
31
+ throw new Error("WIKI_DAEMON_BASE_URL or WIKI_DAEMON_PORT is required for the MCP adapter.");
32
+ }
33
+ const host = env.WIKI_DAEMON_HOST?.trim() || "127.0.0.1";
34
+ return new URL(`http://${host}:${port}/`);
35
+ }
36
+ function buildUrl(baseUrl, routePath, query) {
37
+ const url = new URL(routePath.startsWith("/") ? routePath.slice(1) : routePath, baseUrl);
38
+ for (const [key, value] of Object.entries(query ?? {})) {
39
+ if (value === null || value === undefined) {
40
+ continue;
41
+ }
42
+ url.searchParams.set(key, String(value));
43
+ }
44
+ return url;
45
+ }
46
+ export async function requestDaemonJson(options) {
47
+ const controller = new AbortController();
48
+ const timeout = options.timeoutMs ?? (options.method === "POST" ? 310_000 : 10_000);
49
+ const timer = delay(timeout, undefined, { signal: controller.signal }).then(() => controller.abort()).catch(() => undefined);
50
+ try {
51
+ const response = await fetch(buildUrl(resolveDaemonBaseUrl(options.env), options.path, options.query), {
52
+ method: options.method,
53
+ headers: options.body === undefined ? options.headers : { "content-type": "application/json", ...options.headers },
54
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
55
+ signal: controller.signal,
56
+ });
57
+ const rawText = await response.text();
58
+ const payload = rawText ? JSON.parse(rawText) : null;
59
+ if (!response.ok) {
60
+ const details = payload && typeof payload === "object" && payload !== null && "details" in payload
61
+ ? payload.details
62
+ : { status: response.status };
63
+ const type = payload && typeof payload === "object" && payload !== null && "type" in payload
64
+ ? String(payload.type)
65
+ : "runtime";
66
+ const message = payload && typeof payload === "object" && payload !== null && "error" in payload
67
+ ? String(payload.error)
68
+ : `Daemon request failed with HTTP ${response.status}`;
69
+ throw new DaemonHttpError(message, type, response.status, details);
70
+ }
71
+ return payload;
72
+ }
73
+ catch (error) {
74
+ if (error instanceof DaemonHttpError) {
75
+ throw error;
76
+ }
77
+ if (error instanceof Error && error.name === "AbortError") {
78
+ throw new DaemonHttpError(`Daemon request timed out after ${timeout}ms.`, "runtime", 504, {
79
+ code: "timeout",
80
+ });
81
+ }
82
+ throw new DaemonHttpError(error instanceof Error ? error.message : String(error), "runtime", 500, {
83
+ code: "transport_error",
84
+ });
85
+ }
86
+ finally {
87
+ controller.abort();
88
+ await timer;
89
+ }
90
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { startMcpHttpServer } from "./server.js";
3
+ async function main() {
4
+ const started = await startMcpHttpServer(process.env);
5
+ process.stdout.write(`${JSON.stringify({
6
+ status: "listening",
7
+ host: started.host,
8
+ port: started.port,
9
+ healthUrl: started.healthUrl,
10
+ mcpUrl: started.mcpUrl,
11
+ })}\n`);
12
+ const shutdown = async () => {
13
+ await started.close();
14
+ process.exit(0);
15
+ };
16
+ process.once("SIGINT", () => {
17
+ void shutdown();
18
+ });
19
+ process.once("SIGTERM", () => {
20
+ void shutdown();
21
+ });
22
+ }
23
+ void main().catch((error) => {
24
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
25
+ process.exit(1);
26
+ });