@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.
@@ -2,13 +2,20 @@ import http from "node:http";
2
2
  import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { getMeta } from "../core/db.js";
5
- import { openRuntimeDb } from "../core/runtime.js";
5
+ import { normalizePageId } from "../core/paths.js";
6
+ import { readCanonicalPageSourceById } from "../core/page-source.js";
7
+ import { selectPageById } from "../core/query.js";
8
+ import { loadRuntimeConfig, openRuntimeDb } from "../core/runtime.js";
9
+ import { DaemonWriteQueue } from "./write-queue.js";
10
+ import { appendAuditEvent } from "./audit-log.js";
11
+ import { commitWriteJournal, GitPushScheduler } from "./git-journal.js";
12
+ import { buildCliWriteActor, buildSystemWriteActor, resolveWriteActor } from "./write-actor.js";
6
13
  import { exportGraphContent, exportIndexContent } from "../operations/export.js";
7
14
  import { getDashboardGraphOverview, getDashboardLintSummary, getDashboardPageDetail, getDashboardPageSource, getDashboardQueueItemDetail, getDashboardQueueSummary, getDashboardStatus, getDashboardVaultFileDetail, getDashboardVaultSummary, listDashboardLintIssues, listDashboardQueueItems, listDashboardVaultFiles, openDashboardPageSource, openDashboardVaultFile, retryDashboardQueueItem, searchDashboardGraph, } from "../operations/dashboard.js";
8
15
  import { diffVaultFiles, findPages, ftsSearchPages, getPageInfo, getVaultQueue, getWikiStat, listPages, listVaultFiles, renderLintResult, runLint, searchPages, traverseGraph, } from "../operations/query.js";
9
16
  import { createTemplate, listTemplates, listTypes, recommendTypes, showTemplate, showType, } from "../operations/type-template.js";
10
17
  import { runTemplateLint } from "../operations/template-lint.js";
11
- import { createPage, runSync, runSyncCommand } from "../operations/write.js";
18
+ import { createPage, runSync, runSyncCommand, updatePage } from "../operations/write.js";
12
19
  import { AppError, asAppError } from "../utils/errors.js";
13
20
  import { pathExistsSync } from "../utils/fs.js";
14
21
  import { addSeconds, toOffsetIso } from "../utils/time.js";
@@ -114,11 +121,19 @@ async function readJsonBody(request) {
114
121
  throw new AppError(`Failed to parse daemon request body: ${error instanceof Error ? error.message : String(error)}`, "config");
115
122
  }
116
123
  }
117
- function isBusyError(error) {
118
- return (typeof error.details === "object" &&
119
- error.details !== null &&
120
- "code" in error.details &&
121
- error.details.code === "busy");
124
+ function getErrorDetailsCode(error) {
125
+ if (typeof error.details !== "object" || error.details === null || !("code" in error.details)) {
126
+ return null;
127
+ }
128
+ const code = error.details.code;
129
+ return typeof code === "string" ? code : null;
130
+ }
131
+ function isConflictError(error) {
132
+ const code = getErrorDetailsCode(error);
133
+ return code === "busy" || code === "revision_conflict";
134
+ }
135
+ function isServiceUnavailableError(error) {
136
+ return getErrorDetailsCode(error) === "queue_full";
122
137
  }
123
138
  async function buildStatusPayload(env, state) {
124
139
  let lastSyncAt = null;
@@ -156,7 +171,6 @@ export async function runDaemonServer(options) {
156
171
  let cycleTimer = null;
157
172
  let queuedCycle = false;
158
173
  let stopping = false;
159
- let currentWrite = null;
160
174
  let server;
161
175
  let resolveClosed = null;
162
176
  let nextDashboardLogId = 1;
@@ -200,6 +214,7 @@ export async function runDaemonServer(options) {
200
214
  broadcastDashboardLog(entry);
201
215
  console.error(entry.line);
202
216
  };
217
+ const gitPushScheduler = new GitPushScheduler(paths, logInfo, env);
203
218
  const serveDashboardApp = (requestPath, response) => {
204
219
  if (!pathExistsSync(dashboardDistPath)) {
205
220
  throw new AppError(`Dashboard assets not found at ${dashboardDistPath}. Build the dashboard bundle before opening /dashboard.`, "not_found");
@@ -273,6 +288,44 @@ export async function runDaemonServer(options) {
273
288
  writeDaemonState(paths.daemonStatePath, state);
274
289
  }
275
290
  };
291
+ const afterWriteComplete = (task) => {
292
+ if (queuedCycle && !stopping) {
293
+ queuedCycle = false;
294
+ void enqueueWriteTask("cycle", () => runCycleTransaction(buildSystemWriteActor("daemon"), "cycle"), {
295
+ summarizeResult: summarizeCycleResult,
296
+ }).catch((error) => {
297
+ const appError = asAppError(error);
298
+ logError(`queued cycle failed: ${appError.message}`);
299
+ });
300
+ return;
301
+ }
302
+ if (task === "cycle" || task === "sync-trigger") {
303
+ scheduleNextCycle();
304
+ }
305
+ else {
306
+ persistState();
307
+ }
308
+ };
309
+ const writeQueue = new DaemonWriteQueue(env, {
310
+ onJobStart: (job) => {
311
+ if (state) {
312
+ state.currentTask = job.taskType;
313
+ if (job.taskType === "cycle" || job.taskType === "sync-trigger") {
314
+ state.nextRunAt = null;
315
+ }
316
+ persistState();
317
+ }
318
+ },
319
+ onJobFinish: (job) => {
320
+ if (state) {
321
+ state.lastRunAt = toOffsetIso();
322
+ state.lastResult = job.status === "succeeded" ? "ok" : "error";
323
+ state.lastError = job.errorMessage;
324
+ state.currentTask = "idle";
325
+ }
326
+ afterWriteComplete(job.taskType);
327
+ },
328
+ });
276
329
  const clearTimer = () => {
277
330
  if (cycleTimer) {
278
331
  clearTimeout(cycleTimer);
@@ -296,7 +349,7 @@ export async function runDaemonServer(options) {
296
349
  if (stopping) {
297
350
  return;
298
351
  }
299
- if (currentWrite) {
352
+ if (writeQueue.hasWork()) {
300
353
  queuedCycle = true;
301
354
  if (state) {
302
355
  state.nextRunAt = null;
@@ -304,113 +357,305 @@ export async function runDaemonServer(options) {
304
357
  }
305
358
  return;
306
359
  }
307
- void runDefaultCycle("cycle").catch((error) => {
360
+ void enqueueWriteTask("cycle", () => runCycleTransaction(buildSystemWriteActor("daemon"), "cycle"), {
361
+ summarizeResult: summarizeCycleResult,
362
+ }).catch((error) => {
308
363
  const appError = asAppError(error);
309
364
  logError(`scheduled cycle failed: ${appError.message}`);
310
365
  });
311
366
  }, interval * 1000);
312
367
  };
313
- const afterWriteComplete = (task) => {
314
- if (queuedCycle && !stopping) {
315
- queuedCycle = false;
316
- void runDefaultCycle("cycle").catch((error) => {
317
- const appError = asAppError(error);
318
- logError(`queued cycle failed: ${appError.message}`);
319
- });
320
- return;
368
+ const enqueueWriteTask = async (task, run, options = {}) => {
369
+ if (stopping) {
370
+ throw new AppError("Wiki daemon is shutting down.", "runtime");
321
371
  }
322
- if (task === "cycle" || task === "sync-trigger") {
323
- scheduleNextCycle();
372
+ return writeQueue.enqueue(task, run, options);
373
+ };
374
+ const enrichWriteResult = (result, actor, git) => {
375
+ return {
376
+ ...result,
377
+ writeMeta: {
378
+ requestId: actor.requestId,
379
+ actorId: actor.actorId,
380
+ actorType: actor.actorType,
381
+ auditLogPath: paths.auditLogPath,
382
+ git,
383
+ },
384
+ };
385
+ };
386
+ const resolveCanonicalPageId = (inputPageId) => {
387
+ const { db, config } = openRuntimeDb(env);
388
+ try {
389
+ const normalizedPageId = normalizePageId(inputPageId, paths.wikiPath);
390
+ const page = selectPageById(db, config, normalizedPageId);
391
+ if (!page) {
392
+ throw new AppError(`Page not found: ${normalizedPageId}`, "not_found");
393
+ }
394
+ return String(page.id);
324
395
  }
325
- else {
326
- persistState();
396
+ finally {
397
+ db.close();
327
398
  }
328
399
  };
329
- const runWriteTask = async (task, run) => {
330
- if (stopping) {
331
- throw new AppError("Wiki daemon is shutting down.", "runtime");
400
+ const readPageRevision = (pageId) => {
401
+ return readCanonicalPageSourceById(pageId, paths.wikiPath, loadRuntimeConfig(env).config).revision;
402
+ };
403
+ const buildErrorDetails = (error) => {
404
+ const details = typeof error.details === "object" && error.details !== null && !Array.isArray(error.details)
405
+ ? { ...error.details }
406
+ : {};
407
+ if (!("message" in details)) {
408
+ details.message = error.message;
409
+ }
410
+ return details;
411
+ };
412
+ const recordSyncFailureAndThrow = (actor, input) => {
413
+ appendAuditEvent(paths, actor, {
414
+ operation: input.operation,
415
+ resourceId: input.resourceId,
416
+ status: "sync_failed",
417
+ revisionBefore: input.revisionBefore,
418
+ revisionAfter: input.revisionAfter,
419
+ commitHash: null,
420
+ details: buildErrorDetails(input.error),
421
+ });
422
+ throw new AppError(input.error.message, input.error.type, {
423
+ ...buildErrorDetails(input.error),
424
+ requestId: actor.requestId,
425
+ actorId: actor.actorId,
426
+ actorType: actor.actorType,
427
+ auditLogPath: paths.auditLogPath,
428
+ });
429
+ };
430
+ const finalizeJournaledWrite = async (actor, input) => {
431
+ appendAuditEvent(paths, actor, {
432
+ operation: input.operation,
433
+ resourceId: input.resourceId,
434
+ status: "write_applied",
435
+ revisionBefore: input.revisionBefore,
436
+ revisionAfter: input.revisionAfter,
437
+ commitHash: null,
438
+ details: input.details ?? null,
439
+ });
440
+ try {
441
+ const gitResult = commitWriteJournal(paths, actor, {
442
+ operation: input.operation,
443
+ resourceId: input.resourceId,
444
+ });
445
+ const pushScheduled = gitResult.status === "committed" ? gitPushScheduler.schedule(actor) : false;
446
+ appendAuditEvent(paths, actor, {
447
+ operation: input.operation,
448
+ resourceId: input.resourceId,
449
+ status: gitResult.status === "committed" ? "git_commit_succeeded" : "git_commit_skipped",
450
+ revisionBefore: input.revisionBefore,
451
+ revisionAfter: input.revisionAfter,
452
+ commitHash: gitResult.commitHash,
453
+ details: gitResult.status === "committed" ? { pushScheduled } : { reason: "no_staged_changes" },
454
+ });
455
+ return enrichWriteResult(input.result, actor, {
456
+ status: gitResult.status,
457
+ commitHash: gitResult.commitHash,
458
+ pushScheduled,
459
+ });
332
460
  }
333
- if (currentWrite) {
334
- throw new AppError(`Wiki daemon is busy running ${state?.currentTask ?? "another task"}.`, "runtime", {
335
- code: "busy",
336
- currentTask: state?.currentTask ?? "unknown",
461
+ catch (error) {
462
+ const appError = asAppError(error);
463
+ appendAuditEvent(paths, actor, {
464
+ operation: input.operation,
465
+ resourceId: input.resourceId,
466
+ status: "git_commit_failed",
467
+ revisionBefore: input.revisionBefore,
468
+ revisionAfter: input.revisionAfter,
469
+ commitHash: null,
470
+ details: buildErrorDetails(appError),
471
+ });
472
+ throw new AppError("Git commit failed after write succeeded.", "runtime", {
473
+ code: "git_commit_failed",
474
+ degraded: true,
475
+ requestId: actor.requestId,
476
+ actorId: actor.actorId,
477
+ actorType: actor.actorType,
478
+ resourceId: input.resourceId,
479
+ revisionBefore: input.revisionBefore,
480
+ revisionAfter: input.revisionAfter,
481
+ auditLogPath: paths.auditLogPath,
482
+ writeResult: input.result,
483
+ gitError: appError.message,
337
484
  });
338
485
  }
339
- const promise = (async () => {
340
- if (state) {
341
- state.currentTask = task;
342
- if (task === "cycle" || task === "sync-trigger") {
343
- state.nextRunAt = null;
344
- }
345
- persistState();
346
- }
347
- try {
348
- const result = await run();
349
- if (state) {
350
- state.lastRunAt = toOffsetIso();
351
- state.lastResult = "ok";
352
- state.lastError = null;
353
- state.currentTask = "idle";
354
- }
355
- return result;
486
+ };
487
+ const summarizeCycleResult = (result) => ({
488
+ status: result.status,
489
+ task: result.task,
490
+ sync: {
491
+ mode: result.sync.mode,
492
+ inserted: result.sync.inserted,
493
+ updated: result.sync.updated,
494
+ deleted: result.sync.deleted,
495
+ vaultChanges: result.sync.vault.changes,
496
+ },
497
+ queue: result.queue,
498
+ });
499
+ const runCycleTask = async (task) => {
500
+ logInfo(`${task}: start`);
501
+ const syncResult = await runSync(env);
502
+ logInfo(`${task}: sync ok mode=${syncResult.mode} inserted=${syncResult.inserted} updated=${syncResult.updated} deleted=${syncResult.deleted} vaultChanges=${syncResult.vault.changes}`);
503
+ const queueResult = {
504
+ enabled: false,
505
+ processed: 0,
506
+ done: 0,
507
+ skipped: 0,
508
+ errored: 0,
509
+ batches: 0,
510
+ };
511
+ while (!stopping) {
512
+ const batchResult = await processVaultQueueBatch(env, {
513
+ log: (message) => logInfo(`queue ${message}`),
514
+ });
515
+ if (!batchResult.enabled) {
516
+ break;
356
517
  }
357
- catch (error) {
358
- const appError = asAppError(error);
359
- if (state) {
360
- state.lastRunAt = toOffsetIso();
361
- state.lastResult = "error";
362
- state.lastError = appError.message;
363
- state.currentTask = "idle";
364
- }
365
- throw appError;
518
+ queueResult.enabled = true;
519
+ if (batchResult.processed === 0) {
520
+ break;
366
521
  }
367
- finally {
368
- currentWrite = null;
369
- afterWriteComplete(task);
522
+ queueResult.processed += batchResult.processed;
523
+ queueResult.done += batchResult.done;
524
+ queueResult.skipped += batchResult.skipped;
525
+ queueResult.errored += batchResult.errored;
526
+ queueResult.batches += 1;
527
+ }
528
+ if (queueResult.enabled) {
529
+ logInfo(`${task}: queue summary processed=${queueResult.processed} done=${queueResult.done} skipped=${queueResult.skipped} errored=${queueResult.errored} batches=${queueResult.batches}`);
530
+ }
531
+ return {
532
+ status: "started",
533
+ task,
534
+ sync: syncResult,
535
+ queue: queueResult,
536
+ };
537
+ };
538
+ const runCreateTransaction = async (actor, input) => {
539
+ try {
540
+ const result = await createPage(env, input);
541
+ const revisionAfter = readPageRevision(result.created);
542
+ return await finalizeJournaledWrite(actor, {
543
+ operation: "create",
544
+ resourceId: result.created,
545
+ revisionBefore: null,
546
+ revisionAfter,
547
+ result,
548
+ });
549
+ }
550
+ catch (error) {
551
+ const appError = asAppError(error);
552
+ if (getErrorDetailsCode(appError) === "sync_failed") {
553
+ const details = buildErrorDetails(appError);
554
+ recordSyncFailureAndThrow(actor, {
555
+ operation: "create",
556
+ resourceId: typeof details.pageId === "string" ? details.pageId : null,
557
+ revisionBefore: null,
558
+ revisionAfter: typeof details.revisionAfter === "string" ? details.revisionAfter : null,
559
+ error: appError,
560
+ });
370
561
  }
371
- })();
372
- currentWrite = promise;
373
- return promise;
562
+ throw appError;
563
+ }
374
564
  };
375
- const runDefaultCycle = async (task) => {
376
- return runWriteTask(task, async () => {
377
- logInfo(`${task}: start`);
378
- const syncResult = await runSync(env);
379
- logInfo(`${task}: sync ok mode=${syncResult.mode} inserted=${syncResult.inserted} updated=${syncResult.updated} deleted=${syncResult.deleted} vaultChanges=${syncResult.vault.changes}`);
380
- const queueResult = {
381
- enabled: false,
382
- processed: 0,
383
- done: 0,
384
- skipped: 0,
385
- errored: 0,
386
- batches: 0,
387
- };
388
- while (!stopping) {
389
- const batchResult = await processVaultQueueBatch(env, {
390
- log: (message) => logInfo(`queue ${message}`),
565
+ const runUpdateTransaction = async (actor, input) => {
566
+ const canonicalPageId = resolveCanonicalPageId(input.pageId);
567
+ const revisionBefore = readPageRevision(canonicalPageId);
568
+ try {
569
+ const result = await updatePage(env, input);
570
+ return await finalizeJournaledWrite(actor, {
571
+ operation: "update",
572
+ resourceId: result.pageId,
573
+ revisionBefore,
574
+ revisionAfter: result.revision,
575
+ result,
576
+ });
577
+ }
578
+ catch (error) {
579
+ const appError = asAppError(error);
580
+ if (getErrorDetailsCode(appError) === "sync_failed") {
581
+ const details = buildErrorDetails(appError);
582
+ recordSyncFailureAndThrow(actor, {
583
+ operation: "update",
584
+ resourceId: typeof details.pageId === "string" ? details.pageId : canonicalPageId,
585
+ revisionBefore: typeof details.revisionBefore === "string" ? details.revisionBefore : revisionBefore,
586
+ revisionAfter: typeof details.revisionAfter === "string" ? details.revisionAfter : null,
587
+ error: appError,
391
588
  });
392
- if (!batchResult.enabled) {
393
- break;
394
- }
395
- queueResult.enabled = true;
396
- if (batchResult.processed === 0) {
397
- break;
398
- }
399
- queueResult.processed += batchResult.processed;
400
- queueResult.done += batchResult.done;
401
- queueResult.skipped += batchResult.skipped;
402
- queueResult.errored += batchResult.errored;
403
- queueResult.batches += 1;
404
- }
405
- if (queueResult.enabled) {
406
- logInfo(`${task}: queue summary processed=${queueResult.processed} done=${queueResult.done} skipped=${queueResult.skipped} errored=${queueResult.errored} batches=${queueResult.batches}`);
407
- }
408
- return {
409
- status: "started",
410
- task,
411
- sync: syncResult,
412
- queue: queueResult,
413
- };
589
+ }
590
+ throw appError;
591
+ }
592
+ };
593
+ const runSyncTransaction = async (actor, input) => {
594
+ try {
595
+ const result = await runSyncCommand(env, input);
596
+ const resourceId = input.targetPaths?.[0] ?? input.vaultFileId ?? "*";
597
+ return await finalizeJournaledWrite(actor, {
598
+ operation: "sync",
599
+ resourceId,
600
+ revisionBefore: null,
601
+ revisionAfter: null,
602
+ result,
603
+ });
604
+ }
605
+ catch (error) {
606
+ const appError = asAppError(error);
607
+ return recordSyncFailureAndThrow(actor, {
608
+ operation: "sync",
609
+ resourceId: input.targetPaths?.[0] ?? input.vaultFileId ?? "*",
610
+ revisionBefore: null,
611
+ revisionAfter: null,
612
+ error: appError,
613
+ });
614
+ }
615
+ };
616
+ const runCycleTransaction = async (actor, task) => {
617
+ try {
618
+ const result = await runCycleTask(task);
619
+ return await finalizeJournaledWrite(actor, {
620
+ operation: task,
621
+ resourceId: "*",
622
+ revisionBefore: null,
623
+ revisionAfter: null,
624
+ result,
625
+ });
626
+ }
627
+ catch (error) {
628
+ const appError = asAppError(error);
629
+ return recordSyncFailureAndThrow(actor, {
630
+ operation: task,
631
+ resourceId: "*",
632
+ revisionBefore: null,
633
+ revisionAfter: null,
634
+ error: appError,
635
+ });
636
+ }
637
+ };
638
+ const runTemplateCreateTransaction = async (actor, input) => {
639
+ const result = Promise.resolve(createTemplate(env, {
640
+ type: input.type,
641
+ title: input.title,
642
+ }));
643
+ return await finalizeJournaledWrite(actor, {
644
+ operation: "template-create",
645
+ resourceId: `template:${input.type}`,
646
+ revisionBefore: null,
647
+ revisionAfter: null,
648
+ result: await result,
649
+ });
650
+ };
651
+ const runQueueRetryTransaction = async (actor, fileId) => {
652
+ const result = await retryDashboardQueueItem(env, fileId);
653
+ return await finalizeJournaledWrite(actor, {
654
+ operation: "queue-retry",
655
+ resourceId: `queue:${fileId}`,
656
+ revisionBefore: null,
657
+ revisionAfter: null,
658
+ result,
414
659
  });
415
660
  };
416
661
  const beginShutdown = async () => {
@@ -425,7 +670,7 @@ export async function runDaemonServer(options) {
425
670
  persistState();
426
671
  }
427
672
  try {
428
- await currentWrite?.catch(() => undefined);
673
+ await writeQueue.waitForIdle();
429
674
  }
430
675
  finally {
431
676
  await new Promise((resolve) => {
@@ -464,36 +709,51 @@ export async function runDaemonServer(options) {
464
709
  }
465
710
  if (method === "POST" && pathname === "/sync") {
466
711
  const body = await readJsonBody(request);
712
+ const actor = resolveWriteActor(request, body, buildCliWriteActor(env));
467
713
  const pathValue = typeof body.path === "string" && body.path.trim()
468
714
  ? body.path.trim()
469
715
  : Array.isArray(body.targetPaths) && typeof body.targetPaths[0] === "string"
470
716
  ? String(body.targetPaths[0])
471
717
  : undefined;
472
- const result = await runWriteTask("sync", async () => runSyncCommand(env, {
718
+ const result = await enqueueWriteTask("sync", async () => runSyncTransaction(actor, {
473
719
  targetPaths: pathValue ? [pathValue] : undefined,
474
720
  force: body.force === true,
475
721
  skipEmbedding: body.skipEmbedding === true,
476
722
  process: body.process === true,
477
723
  vaultFileId: typeof body.vaultFileId === "string" && body.vaultFileId.trim() ? body.vaultFileId.trim() : undefined,
478
- }));
724
+ }), {
725
+ summarizeResult: (payload) => ({
726
+ mode: payload.mode,
727
+ inserted: payload.inserted,
728
+ updated: payload.updated,
729
+ deleted: payload.deleted,
730
+ queueProcessed: payload.queueProcess?.processed ?? 0,
731
+ }),
732
+ });
479
733
  writeJsonResponse(response, 200, result);
480
734
  return;
481
735
  }
482
736
  if (method === "POST" && pathname === "/sync/trigger") {
483
- if (currentWrite) {
484
- throw new AppError(`Wiki daemon is busy running ${state?.currentTask ?? "another task"}.`, "runtime", {
485
- code: "busy",
486
- currentTask: state?.currentTask ?? "unknown",
487
- });
488
- }
489
- void runDefaultCycle("sync-trigger").catch((error) => {
490
- const appError = asAppError(error);
491
- logError(`sync-trigger failed: ${appError.message}`);
492
- });
493
- writeJsonResponse(response, 200, {
494
- status: "started",
495
- currentTask: "sync-trigger",
737
+ const body = await readJsonBody(request);
738
+ const actor = resolveWriteActor(request, body, buildCliWriteActor(env));
739
+ const result = await enqueueWriteTask("sync-trigger", () => runCycleTransaction(actor, "sync-trigger"), {
740
+ summarizeResult: summarizeCycleResult,
496
741
  });
742
+ writeJsonResponse(response, 200, result);
743
+ return;
744
+ }
745
+ if (method === "GET" && pathname === "/write-queue/summary") {
746
+ writeJsonResponse(response, 200, writeQueue.getSummary());
747
+ return;
748
+ }
749
+ const writeQueueJobMatch = pathname.match(/^\/write-queue\/jobs\/(.+)$/);
750
+ if (method === "GET" && writeQueueJobMatch) {
751
+ const jobId = decodePathParam(writeQueueJobMatch[1]);
752
+ const job = writeQueue.getJob(jobId);
753
+ if (!job) {
754
+ throw new AppError(`Write queue job not found: ${jobId}`, "not_found");
755
+ }
756
+ writeJsonResponse(response, 200, job);
497
757
  return;
498
758
  }
499
759
  if (method === "GET" && pathname === "/api/dashboard/status") {
@@ -535,7 +795,14 @@ export async function runDaemonServer(options) {
535
795
  const queueRetryMatch = pathname.match(/^\/api\/dashboard\/queue\/items\/(.+)\/retry$/);
536
796
  if (method === "POST" && queueRetryMatch) {
537
797
  const fileId = decodePathParam(queueRetryMatch[1]);
538
- writeJsonResponse(response, 200, await runWriteTask("queue-retry", async () => retryDashboardQueueItem(env, fileId)));
798
+ const body = await readJsonBody(request);
799
+ const actor = resolveWriteActor(request, body, buildCliWriteActor(env));
800
+ writeJsonResponse(response, 200, await enqueueWriteTask("queue-retry", async () => runQueueRetryTransaction(actor, fileId), {
801
+ summarizeResult: (payload) => ({
802
+ fileId,
803
+ status: typeof payload.status === "string" ? payload.status : "unknown",
804
+ }),
805
+ }));
539
806
  return;
540
807
  }
541
808
  const queueItemMatch = pathname.match(/^\/api\/dashboard\/queue\/items\/(.+)$/);
@@ -642,6 +909,28 @@ export async function runDaemonServer(options) {
642
909
  writeJsonResponse(response, 200, getPageInfo(env, pageId));
643
910
  return;
644
911
  }
912
+ if (method === "GET" && pathname === "/page-read") {
913
+ const pageId = url.searchParams.get("pageId");
914
+ if (!pageId) {
915
+ throw new AppError("pageId is required", "config", {
916
+ code: "invalid_request",
917
+ field: "pageId",
918
+ });
919
+ }
920
+ const { db, config, paths } = openRuntimeDb(env);
921
+ try {
922
+ const normalizedPageId = normalizePageId(pageId, paths.wikiPath);
923
+ const page = selectPageById(db, config, normalizedPageId);
924
+ if (!page) {
925
+ throw new AppError(`Page not found: ${normalizedPageId}`, "not_found");
926
+ }
927
+ writeJsonResponse(response, 200, readCanonicalPageSourceById(String(page.id), paths.wikiPath, config));
928
+ }
929
+ finally {
930
+ db.close();
931
+ }
932
+ return;
933
+ }
645
934
  if (method === "GET" && pathname === "/list") {
646
935
  writeJsonResponse(response, 200, listPages(env, {
647
936
  type: url.searchParams.get("type") ?? undefined,
@@ -676,14 +965,37 @@ export async function runDaemonServer(options) {
676
965
  }
677
966
  if (method === "POST" && pathname === "/create") {
678
967
  const body = await readJsonBody(request);
968
+ const actor = resolveWriteActor(request, body, buildCliWriteActor(env));
679
969
  const type = typeof body.type === "string" ? body.type : "";
680
970
  const title = typeof body.title === "string" ? body.title : "";
681
971
  const nodeId = typeof body.nodeId === "string" ? body.nodeId : undefined;
682
- const result = await runWriteTask("create", () => createPage(env, {
972
+ const result = await enqueueWriteTask("create", () => runCreateTransaction(actor, {
683
973
  type,
684
974
  title,
685
975
  nodeId,
686
- }));
976
+ }), {
977
+ summarizeResult: (payload) => ({
978
+ created: payload.created,
979
+ filePath: payload.filePath,
980
+ }),
981
+ });
982
+ writeJsonResponse(response, 200, result);
983
+ return;
984
+ }
985
+ if (method === "POST" && pathname === "/page-update") {
986
+ const body = await readJsonBody(request);
987
+ const actor = resolveWriteActor(request, body, buildCliWriteActor(env));
988
+ const result = await enqueueWriteTask("update", () => runUpdateTransaction(actor, {
989
+ pageId: typeof body.pageId === "string" ? body.pageId : "",
990
+ bodyMarkdown: typeof body.bodyMarkdown === "string" ? body.bodyMarkdown : undefined,
991
+ frontmatterPatch: body.frontmatterPatch,
992
+ ifRevision: typeof body.ifRevision === "string" ? body.ifRevision : undefined,
993
+ }), {
994
+ summarizeResult: (payload) => ({
995
+ pageId: payload.pageId,
996
+ revision: payload.revision,
997
+ }),
998
+ });
687
999
  writeJsonResponse(response, 200, result);
688
1000
  return;
689
1001
  }
@@ -736,10 +1048,16 @@ export async function runDaemonServer(options) {
736
1048
  }
737
1049
  if (method === "POST" && pathname === "/template/create") {
738
1050
  const body = await readJsonBody(request);
739
- const result = await runWriteTask("template-create", () => Promise.resolve(createTemplate(env, {
1051
+ const actor = resolveWriteActor(request, body, buildCliWriteActor(env));
1052
+ const result = await enqueueWriteTask("template-create", () => runTemplateCreateTransaction(actor, {
740
1053
  type: typeof body.type === "string" ? body.type : "",
741
1054
  title: typeof body.title === "string" ? body.title : "",
742
- })));
1055
+ }), {
1056
+ summarizeResult: (payload) => ({
1057
+ pageType: typeof payload.pageType === "string" ? payload.pageType : String(body.type ?? ""),
1058
+ templatePath: typeof payload.templatePath === "string" ? payload.templatePath : null,
1059
+ }),
1060
+ });
743
1061
  writeJsonResponse(response, 200, result);
744
1062
  return;
745
1063
  }
@@ -766,9 +1084,11 @@ export async function runDaemonServer(options) {
766
1084
  ? 400
767
1085
  : appError.type === "not_found"
768
1086
  ? 404
769
- : isBusyError(appError)
770
- ? 409
771
- : 500;
1087
+ : isServiceUnavailableError(appError)
1088
+ ? 503
1089
+ : isConflictError(appError)
1090
+ ? 409
1091
+ : 500;
772
1092
  writeJsonResponse(response, statusCode, {
773
1093
  error: appError.message,
774
1094
  type: appError.type,
@@ -801,7 +1121,9 @@ export async function runDaemonServer(options) {
801
1121
  process.on("SIGTERM", signalHandler);
802
1122
  process.on("SIGINT", signalHandler);
803
1123
  if (interval > 0) {
804
- void runDefaultCycle("cycle").catch((error) => {
1124
+ void enqueueWriteTask("cycle", () => runCycleTransaction(buildSystemWriteActor("daemon"), "cycle"), {
1125
+ summarizeResult: summarizeCycleResult,
1126
+ }).catch((error) => {
805
1127
  const appError = asAppError(error);
806
1128
  logError(`initial cycle failed: ${appError.message}`);
807
1129
  });