@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.
- package/README.md +106 -1
- package/README.zh-CN.md +106 -1
- package/dist/commands/create.js +3 -0
- package/dist/commands/sync.js +3 -0
- package/dist/commands/template.js +3 -0
- package/dist/core/codex-workflow.js +37 -15
- package/dist/core/db.js +19 -0
- package/dist/core/onboarding.js +35 -1
- package/dist/core/page-source.js +25 -0
- package/dist/core/paths.js +10 -0
- package/dist/core/workflow-result.js +5 -0
- package/dist/daemon/audit-log.js +18 -0
- package/dist/daemon/client.js +1 -1
- package/dist/daemon/git-journal.js +114 -0
- package/dist/daemon/server.js +446 -124
- package/dist/daemon/write-actor.js +60 -0
- package/dist/daemon/write-queue.js +360 -0
- package/dist/operations/dashboard.js +4 -9
- package/dist/operations/write.js +93 -5
- package/mcp-server/dist/daemon-client.js +90 -0
- package/mcp-server/dist/index.js +26 -0
- package/mcp-server/dist/server.js +525 -0
- package/package.json +11 -5
- package/references/centralized-service-deployment.md +482 -0
- package/references/examples/centralized-service/centralized.env.example +25 -0
- package/references/examples/centralized-service/nginx-centralized-wiki.conf +68 -0
- package/references/examples/centralized-service/tiangong-wiki-daemon.service +17 -0
- package/references/examples/centralized-service/tiangong-wiki-mcp.service +18 -0
- package/references/troubleshooting.md +22 -0
|
@@ -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
|
|
863
|
-
const rawData = parsed.ok ? parsed.parsed.rawData : {};
|
|
863
|
+
const pageSource = readCanonicalPageSource(pageFilePath, paths.wikiPath, config);
|
|
864
864
|
return {
|
|
865
|
-
pageSource
|
|
866
|
-
|
|
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
|
}
|
package/dist/operations/write.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
});
|