Skip to content

feat(wiki): scalable wiki build with recursive topic indexes - #174

Open
zhuzhaoyun wants to merge 31 commits into
mainfrom
codex/wiki-build-scalable
Open

feat(wiki): scalable wiki build with recursive topic indexes#174
zhuzhaoyun wants to merge 31 commits into
mainfrom
codex/wiki-build-scalable

Conversation

@zhuzhaoyun

Copy link
Copy Markdown
Owner

Summary

Implements a deterministic, resumable wiki build system that can scale to large vaults (50,000+ files) with recursive topic hierarchies and crash recovery.

Key Features

1. CLI Toolchain

  • scan → vault inventory with path safety and symlink protection
  • plan → semantic topic hierarchy with version control and user approval gate
  • next → batch claiming with attempt tokens and source change detection
  • prepare → work item preparation with JSON streaming and Unicode boundaries
  • checkpoint → idempotent checkpoints with crash recovery journal
  • finalize → recursive index generation with deterministic sharding
  • reindex → incremental index updates for specific topic chains

2. Resumability & Crash Recovery

  • Idempotent checkpoints: same payload → same result, different payload → CHECKPOINT_CONFLICT
  • Crash replay for prepared and applied journal phases
  • Stale attempt isolation: recovered batches get fresh attempt tokens
  • Self-healing mutation locks: detect dead PIDs and stale locks (>10min TTL)

3. Recursive Topic Indexes

  • Topic tree structure: root → intermediate → leaf
  • Deterministic sharding: <type>-NNNN.md when leaf exceeds 200 pages or 12000 tokens
  • Stale shard cleanup on sharded→inline transitions
  • Path-qualified wikilinks: [[topic/subtopic/page|display]]

4. User Approval Workflow

  • Plan validation before approval (no wiki/ writes until approved)
  • Frozen plan versions with digest verification
  • Batch-level source change detection

Files Changed

New Files

  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/{contracts,workspace,inventory,plan,preprocess,state,indexes}.mjs — core modules
  • apps/daemon/src/tools/skills/wiki-build/scripts/wiki-build.mjs — CLI entry point
  • apps/daemon/test/tools/wiki-build-{cli,scan,plan,preprocess,state,indexes,workflow}.test.ts — 73 test cases
  • apps/desktop/test/wiki-build-resources.test.js — packaging verification
  • docs/wiki-build-acceptance.md — acceptance test runbook

Modified Files

  • apps/daemon/src/tools/skills/wiki-build/SKILL.md — v2.0.0 with CLI workflow
  • apps/daemon/src/tools/skills/wiki-ingest/SKILL.md — v2.0.0 for recursive wiki
  • apps/daemon/src/core/wiki-prompts.ts — recursive index navigation prompts
  • apps/daemon/src/routes/graph.ts — path-qualified wikilink resolution
  • apps/daemon/test/tools/builtin-skills.test.ts — v2.0.0 assertions
  • apps/daemon/test/core/skill-installer.test.ts — version migration tests

Test Coverage

  • Daemon tests: 745 tests, 742 pass, 3 skipped
  • Desktop tests: 54 pass, 6 skipped
  • Typecheck: clean

Test Highlights

  • Crash recovery: prepared and applied journal replay
  • Stale attempt isolation after status --recover
  • Idempotent checkpoints with CHECKPOINT_CONFLICT
  • Self-healing mutation locks (dead PID + stale TTL)
  • Deterministic sharding (same input → same hashes)
  • Sharded→inline transition cleanup
  • Path-qualified wikilink resolution
  • Full CLI workflow E2E (scan → finalize with crash mid-build)

Commits (11 tasks)

  1. feat(wiki): add deterministic build cli — CLI + workspace
  2. feat(wiki): add bounded vault inventory — scan + inventory
  3. feat(wiki): freeze validated topic plans — plan validation + versioning
  4. feat(wiki): prepare bounded build inputs — preprocessing
  5. fix(wiki): stream large json and harden prepared paths — task 4 review fixes
  6. feat(wiki): add resumable batch checkpoints — state machine + checkpoints
  7. fix(wiki): complete applied-journal replay and self-heal lock — task 5 review fixes
  8. feat(wiki): generate recursive topic indexes — indexes + sharding
  9. fix(wiki): fix atomic write and clean stale shards — task 6 review fixes
  10. test(wiki): cover resumable build workflow — E2E workflow test
  11. feat(wiki): make build and ingest resumable — SKILL.md v2.0.0
  12. feat(wiki): navigate recursive topic indexes — prompts + graph
  13. test(wiki): verify build tool packaging — desktop packaging test
  14. docs(wiki): add acceptance test runbook — acceptance docs

Breaking Changes

None. The legacy flat wiki (wiki/INDEX.md with type directories) remains readable and queryable. The new recursive wiki coexists with legacy wikis.

Next Steps

  • Manual acceptance testing on articles-1 vault (see docs/wiki-build-acceptance.md)
  • Monitor production builds for edge cases
  • Consider adding hot.md and log.md to recursive wiki structure (currently only in prompts)

zhuzhaoyun and others added 29 commits July 18, 2026 19:26
- Stream large JSON via createReadStream state machine, recording only
  top-level key + value type without retaining values
- Track string escapes and roles so nested strings no longer confuse
  depth tracking
- Require explicit { approved: true, fields: [] } fieldPolicy whitelist
  before extracting large JSON values
- Validate batch id and attemptToken against safe path segments before
  writing prepared/<id>-<token>.json
- Keep fallback chunks on UTF-8/UTF-16 boundaries and track mixed
  CRLF/LF/CR byte ranges while streaming JSONL
- Chunk external normalized Markdown by its normalized extension

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
- Treat a prepared or applied journal as a crash-replay authorized by
  its payloadHash, skipping the activeBatchId/attemptToken guards so a
  crash between state-write and journal-completion can still complete
  instead of throwing BATCH_NOT_ACTIVE
- Add CHECKPOINT_CONFLICT guard for differing payloads on replay
- Make the mutation lock self-healing: store {pid, startedAt}, reclaim
  when the holder pid is dead or past the 10m stale TTL, and surface a
  live holder as LOCK_BUSY instead of leaking EEXIST
- Share acquireMutationLock between sync withMutationLock and the async
  prepare/checkpoint path so both recover from a stuck lock
- Resolve staged pages via realpath so a staging symlink cannot
  materialize arbitrary vault files
- Short-circuit recoverRunning when nothing is running
- Document the retry-batch id collision-resilience intent

Co-Authored-By: Claude <noreply@anthropic.com>
- Add indexes.mjs with buildIndexModel, writeIndexes, verifyCoverage,
  finalizeBuild, reindexTopicAndAncestors
- Register finalize and reindex CLI commands in wiki-build.mjs
- Add --summaries and --topic-id flags to parseArgs
- Add test helpers: writeCompletedBuildState, completedThreeLevelBuild,
  completedLeafBuild, completedBuildWithMixedResults
- 12 tests covering: three-level hierarchy, deterministic sharding,
  source page missing, reindex chain isolation, pending batch rejection,
  completed_with_errors phase, verifyCoverage, token-based sharding,
  estimateIndexTokens, TOPIC_SUMMARY_MISSING validation

Co-Authored-By: Claude <noreply@anthropic.com>
- Remove unlinkSync before renameSync in writeIndexes (crash-safety gap)
- Clean up stale index-shards/ directories when topic transitions
  from sharded to inline (writeIndexes + reindexTopicAndAncestors)
- Remove dead code in verifyCoverage (model.missing check was redundant)
- Add comment about conservative token estimation at sharding boundary
- Add test for sharded-to-inline transition

Co-Authored-By: Claude <noreply@anthropic.com>
Add E2E workflow test exercising all CLI commands from scan to finalize:
- Full happy path with simulated crash mid-build (recover + re-claim)
- Partial failure + skip + finalize with completed_with_errors
- Failed file retry via retry batch + finalize with completed phase

3 new tests, 73/73 total wiki-build tests passing, no source changes needed.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OpenCodeReview found 29 issue(s) in this PR.

  • ✅ 29 posted as inline comment(s)
  • 📝 0 posted as summary

Comment on lines +262 to +264
const versions = readApprovedVersions(paths);
const latest = versions.length ? Math.max(...versions) : 0;
if (candidate.planVersion <= latest) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approvePlan (lines 262-263) calls readApprovedVersions which reads the current plan file and history directory to determine the latest approved version. Between this read and the version check (lines 264-272), a concurrent process could approve the same version, causing the version-increment check to pass incorrectly. The acquireMutationLock from workspace.mjs is not invoked here, leaving a TOCTOU race window. Wrap the version-check-and-write block with acquireMutationLock / withMutationLock to serialize plan approvals.

Suggestion:

Suggested change
const versions = readApprovedVersions(paths);
const latest = versions.length ? Math.max(...versions) : 0;
if (candidate.planVersion <= latest) {
const result = withMutationLock(paths, () => {
const versions = readApprovedVersions(paths);
const latest = versions.length ? Math.max(...versions) : 0;
if (candidate.planVersion <= latest) {

return;
}
if (!inventoryIds.has(fileId)) issue(errors, 'FILE_ID_UNKNOWN', { fileId, source });
if (classifications.has(fileId)) issue(errors, 'FILE_CLASSIFICATION_DUPLICATE', { fileId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FILE_CLASSIFICATION_DUPLICATE error (line 159) does not include the source parameter, making it hard to determine which parts of the plan (assignments, excluded, or undecided) caused the conflict. Include source and the existing classification's source in the error details for better debuggability.

Suggestion:

Suggested change
if (classifications.has(fileId)) issue(errors, 'FILE_CLASSIFICATION_DUPLICATE', { fileId });
if (classifications.has(fileId)) issue(errors, 'FILE_CLASSIFICATION_DUPLICATE', { fileId, source, existingSource: classifications.get(fileId) });

Comment on lines +199 to +202
if (!Number.isInteger(policy.contextWindowTokens) || policy.contextWindowTokens < 1
|| policy.maxInputTokens !== Math.floor(policy.contextWindowTokens * policy.maxInputFraction)) {
issue(errors, 'BATCH_INPUT_TOKENS_INVALID');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batchPolicy.maxInputTokens validation (line 200) uses a strict equality check against Math.floor(policy.contextWindowTokens * policy.maxInputFraction). When maxInputTokens is undefined (not provided in the candidate), the inequality check undefined !== computedValue fails with BATCH_INPUT_TOKENS_INVALID, but the error message doesn't distinguish between a missing value and an incorrect one. Consider adding a separate check for policy.maxInputTokens being undefined/not an integer before the equality check, or providing a more specific error code.

Suggestion:

Suggested change
if (!Number.isInteger(policy.contextWindowTokens) || policy.contextWindowTokens < 1
|| policy.maxInputTokens !== Math.floor(policy.contextWindowTokens * policy.maxInputFraction)) {
issue(errors, 'BATCH_INPUT_TOKENS_INVALID');
}
if (!Number.isInteger(policy.contextWindowTokens) || policy.contextWindowTokens < 1) {
issue(errors, 'BATCH_CONTEXT_WINDOW_INVALID');
}
if (!Number.isInteger(policy.maxInputTokens) || policy.maxInputTokens < 1) {
issue(errors, 'BATCH_MAX_INPUT_TOKENS_INVALID');
} else if (policy.maxInputTokens !== Math.floor(policy.contextWindowTokens * policy.maxInputFraction)) {
issue(errors, 'BATCH_INPUT_TOKENS_MISMATCH');
}

Comment on lines +420 to +426
// Crash replay: a prepared/applied journal authorizes the replay via its own
// payloadHash — the activeBatchId/attemptToken guards must be skipped because
// the state may already have been advanced past step 11 (activeBatchId cleared).
const resuming = existing?.phase === 'prepared' || existing?.phase === 'applied';
if (resuming && existing.payloadHash !== payloadHash) {
throw codedError('CHECKPOINT_CONFLICT', `Batch ${result.batchId} already recorded a different payload`, { batchId: result.batchId });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In checkpointBatch(), when resuming after a crash (resuming=true), the code skips validation of activeBatchId and attemptToken entirely. This means a stale journal from a previous attempt could be replayed even after the batch has been reclaimed with a new attemptToken. The journal's attemptToken should be compared against the current state's attemptToken when resuming to detect this stale replay scenario.

Suggestion:

Suggested change
// Crash replay: a prepared/applied journal authorizes the replay via its own
// payloadHash — the activeBatchId/attemptToken guards must be skipped because
// the state may already have been advanced past step 11 (activeBatchId cleared).
const resuming = existing?.phase === 'prepared' || existing?.phase === 'applied';
if (resuming && existing.payloadHash !== payloadHash) {
throw codedError('CHECKPOINT_CONFLICT', `Batch ${result.batchId} already recorded a different payload`, { batchId: result.batchId });
}
// Crash replay: a prepared/applied journal authorizes the replay via its own
// payloadHash — the activeBatchId/attemptToken guards must be skipped because
// the state may already have been advanced past step 11 (activeBatchId cleared).
const resuming = existing?.phase === 'prepared' || existing?.phase === 'applied';
if (resuming && existing.payloadHash !== payloadHash) {
throw codedError('CHECKPOINT_CONFLICT', `Batch ${result.batchId} already recorded a different payload`, { batchId: result.batchId });
}
if (resuming && existing.attemptToken !== result.attemptToken) {
throw codedError('STALE_ATTEMPT', `Journal attempt token ${existing.attemptToken} does not match payload ${result.attemptToken}`, { batchId: result.batchId });
}

Comment on lines +500 to +513
for (const fileId of fileIdsForBatch) {
const fileResult = fileResults.get(fileId);
if (!fileResult) continue;
const fileState = next.files[fileId];
if (fileResult.status === 'succeeded') {
fileState.status = 'succeeded';
fileState.contentHash = fileResult.contentHash ?? fileState.contentHash;
anySucceeded = true;
} else if (fileResult.status === 'failed') {
fileState.status = 'failed';
fileState.lastError = fileResult.error ?? null;
anyFailed = true;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In checkpointBatch(), when a batch contains files not reported in the result payload (result.files), those files remain in 'running' status. The code only iterates over fileIdsForBatch and skips files not in fileResults. These orphaned files would stay as 'running' indefinitely, preventing the batch from being considered complete. Consider reverting unreported files to 'pending' or 'failed' with a descriptive error.

Suggestion:

Suggested change
for (const fileId of fileIdsForBatch) {
const fileResult = fileResults.get(fileId);
if (!fileResult) continue;
const fileState = next.files[fileId];
if (fileResult.status === 'succeeded') {
fileState.status = 'succeeded';
fileState.contentHash = fileResult.contentHash ?? fileState.contentHash;
anySucceeded = true;
} else if (fileResult.status === 'failed') {
fileState.status = 'failed';
fileState.lastError = fileResult.error ?? null;
anyFailed = true;
}
}
for (const fileId of fileIdsForBatch) {
const fileResult = fileResults.get(fileId);
if (!fileResult) {
// File missing from result payload — mark as failed so it doesn't stay running forever.
const fileState = next.files[fileId];
fileState.status = 'failed';
fileState.lastError = { code: 'MISSING_RESULT', message: `File ${fileId} not reported in checkpoint result` };
anyFailed = true;
continue;
}
const fileState = next.files[fileId];
if (fileResult.status === 'succeeded') {
fileState.status = 'succeeded';
fileState.contentHash = fileResult.contentHash ?? fileState.contentHash;
anySucceeded = true;
} else if (fileResult.status === 'failed') {
fileState.status = 'failed';
fileState.lastError = fileResult.error ?? null;
anyFailed = true;
}
}

Comment on lines +18 to +24
export function makeVault() {
const path = mkdtempSync(join(tmpdir(), 'molio-wiki-build-'));
return {
path,
cleanup: () => rmSync(path, { recursive: true, force: true }),
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The makeVault function creates a temporary directory but requires manual cleanup via the returned cleanup() callback. If a test assertion fails or an exception is thrown between makeVault() and cleanup(), the temporary directory leaks. Over many test runs, this can accumulate and cause disk space issues.

Consider using node:test's built-in after hook or a disposable pattern (e.g., returning a [vault, cleanup] tuple and wrapping test bodies in try/finally).

Suggestion:

Suggested change
export function makeVault() {
const path = mkdtempSync(join(tmpdir(), 'molio-wiki-build-'));
return {
path,
cleanup: () => rmSync(path, { recursive: true, force: true }),
};
}
export function makeVault() {
const path = mkdtempSync(join(tmpdir(), 'molio-wiki-build-'));
return {
path,
cleanup: () => rmSync(path, { recursive: true, force: true }),
};
}
/** Wraps a vault fixture so cleanup runs even if the callback throws. */
export function withVault<T>(fn: (vault: { path: string }) => T): T {
const vault = makeVault();
try {
return fn(vault);
} finally {
vault.cleanup();
}
}

Comment on lines +255 to +259
export function stagePage(vaultPath: string, stagingDir: string, relPath: string, content: string) {
const target = join(vaultPath, stagingDir, relPath);
mkdirSync(join(target, '..'), { recursive: true });
writeFileSync(target, content, 'utf8');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stagePage function constructs a file path using join(vaultPath, stagingDir, relPath) without validating that the resolved path stays within the vault. While stagingDir comes from the CLI's controlled output in tests, a defense-in-depth check would prevent accidental writes outside the vault directory if the function is ever reused with untrusted inputs.

Consider resolving the final path and asserting it is within vaultPath, similar to how assertPathWithinVault works in the production code.

Suggestion:

Suggested change
export function stagePage(vaultPath: string, stagingDir: string, relPath: string, content: string) {
const target = join(vaultPath, stagingDir, relPath);
mkdirSync(join(target, '..'), { recursive: true });
writeFileSync(target, content, 'utf8');
}
import { resolve } from 'node:path';
export function stagePage(vaultPath: string, stagingDir: string, relPath: string, content: string) {
const target = resolve(vaultPath, stagingDir, relPath);
if (!target.startsWith(resolve(vaultPath) + '/') && target !== resolve(vaultPath)) {
throw new Error(`Path escapes vault: ${target}`);
}
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content, 'utf8');
}

};
}

export function makePlanFixture(inventoryDigest: string): any {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple functions return any type (e.g., makePlanFixture, makePlanFixtureForFiles, readState, runWikiBuildCli). While this is a test helper file, using any can hide type errors in test code that consumes these fixtures. The plan.mjs module already defines JSDoc typedefs (TopicNode, FileAssignment, Batch) that could be mirrored as TypeScript interfaces in a shared types file.

Suggestion:

Suggested change
export function makePlanFixture(inventoryDigest: string): any {
// Consider defining these interfaces in a shared types file:
// interface PlanFixture { schemaVersion: number; planVersion: number; ... }
export function makePlanFixture(inventoryDigest: string): any {

Comment on lines +116 to +119
for (const record of records) {
const match = files.find((file) => file.filename === record.path);
if (match) record.id = match.id;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the for...of loop, if a record's path doesn't match any file in the files array, match will be undefined and record.id will be set to undefined. While this is unlikely in normal test usage (the vault only contains the files that were just written), it could silently produce confusing failures if the vault somehow contains unexpected files (e.g., from a previous test run that wasn't cleaned up).

Consider adding a guard or at least logging a warning when a record has no matching file.

Suggestion:

Suggested change
for (const record of records) {
const match = files.find((file) => file.filename === record.path);
if (match) record.id = match.id;
}
for (const record of records) {
const match = files.find((file) => file.filename === record.path);
if (match) {
record.id = match.id;
} else {
throw new Error(`Unexpected inventory record with no matching file: ${record.path}`);
}
}

Comment on lines +303 to +307
const pagesMap: Record<string, any> = {};
for (const page of pages) {
pagesMap[page.path] = {
sha256: createHash('sha256').update(page.content).digest('hex'),
batchId: plan.batches[0]?.id ?? 'batch-001',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In writeCompletedBuildState, the pagesMap loop uses plan.batches[0]?.id ?? 'batch-001' as the batchId for every page, regardless of which batch actually produced that page. This means all pages are assigned to the first batch, which is incorrect for multi-batch plans. In completedLeafBuild, for example, there are multiple batches but all pages get batchId from batches[0].

This may cause issues in tests that verify batch-to-page relationships. Consider looking up the correct batch for each page based on the page's topicId and the batch assignments.

Suggestion:

Suggested change
const pagesMap: Record<string, any> = {};
for (const page of pages) {
pagesMap[page.path] = {
sha256: createHash('sha256').update(page.content).digest('hex'),
batchId: plan.batches[0]?.id ?? 'batch-001',
const pagesMap: Record<string, any> = {};
for (const page of pages) {
const matchingBatch = plan.batches.find((b: any) => b.topicId === page.topicId);
pagesMap[page.path] = {
sha256: createHash('sha256').update(page.content).digest('hex'),
batchId: matchingBatch?.id ?? plan.batches[0]?.id ?? 'batch-001',

zhuzhaoyun and others added 2 commits July 19, 2026 20:42
- Extract filterAffectedIndexes from reindexTopicAndAncestors for reuse
- Add buildIndexModelLenient (no summary requirement for incremental builds)
- Add appendBuildLog (prepend to wiki/log.md after each checkpoint)
- Add writeHotCache (rewrite wiki/hot.md with current build status)
- Add rebuildAfterCheckpoint (incremental index rebuild per batch)
- Slim finalizeBuild: coverage check now optional via --verify flag
- Add parseFrontmatter and assembleAutoPayload to state.mjs
- Integrate rebuildAfterCheckpoint into checkpointBatch (steps 9-10)
- Demote prepareWorkItems to file-level by default, chunk only with --chunk
- Add checkpoint --auto mode (frontmatter-driven, no hand-written JSON)
- Add run --resume command (recover + claim in one step)
- Add --auto, --failed-file, --resume, --chunk, --verify CLI flags
- Update SKILL.md workflow to 2-command loop (next + checkpoint --auto)
- Update all 4 test files with new coverage (90 tests, all green)

Co-Authored-By: Claude <noreply@anthropic.com>
- renderEntry: derive wikilink target from page.path (actual flat
  location) instead of assembling topicPath/type/title (which doesn't
  match how pages are stored on disk, causing all INDEX links to break)
- topicDisplayName: fall back to slug then id when topic.name is
  missing (plans only require slug/id, name is optional — rendering
  "undefined" when absent)
- state.mjs log entry: same name→slug→id fallback for topic names

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Affected Areas & Test Selection

No area matched. Only P0 specs will run as baseline.

Changed files: 29

  • apps/daemon/src/core/wiki-prompts.ts
  • apps/daemon/src/routes/graph.ts
  • apps/daemon/src/tools/skills/wiki-build/SKILL.md
  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/contracts.mjs
  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/indexes.mjs
  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/inventory.mjs
  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/plan.mjs
  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/preprocess.mjs
  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/state.mjs
  • apps/daemon/src/tools/skills/wiki-build/scripts/lib/workspace.mjs
  • apps/daemon/src/tools/skills/wiki-build/scripts/wiki-build.mjs
  • apps/daemon/src/tools/skills/wiki-ingest/SKILL.md
  • apps/daemon/test/core/skill-installer.test.ts
  • apps/daemon/test/core/weixin/wiki-sys-prompt-files.test.ts
  • apps/daemon/test/routes/graph.test.ts
  • apps/daemon/test/tools/builtin-skills.test.ts
  • apps/daemon/test/tools/wiki-build-cli.test.ts
  • apps/daemon/test/tools/wiki-build-indexes.test.ts
  • apps/daemon/test/tools/wiki-build-plan.test.ts
  • apps/daemon/test/tools/wiki-build-preprocess.test.ts
  • ... and 9 more

Top changed symbols (by caller count):

Symbol Kind Callers
MAX_DIR_ENTRIES const 5
walk method 5
MAX_TOTAL const 3
codedError function 3
SCHEMA_VERSION const 2
DEFAULT_CAPACITY const 2
TEXT_EXTENSIONS const 1
DOCLING_EXTENSIONS const 1
CONFIRM_EXTENSIONS const 1
PRUNED_NAMES const 1
DEFAULT_PREPROCESS_POLICY const 1
buildGraph function 0
FILE_STATUSES const 0
BATCH_STATUSES const 0
BUILD_PHASES const 0

E2E specs that will run on this PR:

  • (P0 baseline only — see apps/web/e2e/area-map.json)

Generated by Impact Analysis workflow. This is informational, not a gate.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 OpenCodeReview found 36 issue(s) in this PR.

  • ✅ 31 posted as inline comment(s)
  • 📝 5 posted as summary

📄 apps/daemon/src/tools/skills/wiki-build/scripts/lib/workspace.mjs

The missing array (pages with unknown topicIds) is populated in buildIndexModel but never checked by finalizeBuild. When verifyCoverage is not enabled (the default path), pages with unknown topicIds are silently excluded from indexes, causing undetected data loss. The missing field from buildIndexModel should be checked and thrown as an error here, similar to how missingPages (pages missing from disk) is handled just above.

💡 Suggested Change

Before:

  // Build index model
  const model = buildIndexModel(plan, state.pages, summaries);

  // Verify coverage (optional, only when --verify is passed)

After:

  // Build index model
  const model = buildIndexModel(plan, state.pages, summaries);

  if (model.missing.length > 0) {
    throw codedError('UNKNOWN_TOPIC_ID',
      `${model.missing.length} page(s) reference unknown topicIds`,
      { codes: ['UNKNOWN_TOPIC_ID'], missingPages: model.missing });
  }

  // Verify coverage (optional, only when --verify is passed)

📄 apps/daemon/src/tools/skills/wiki-build/scripts/lib/workspace.mjs

The estimatedIndexTokens function uses Buffer.byteLength(markdown, 'utf8') / 3 to estimate tokens. For CJK characters, UTF-8 encoding is 3 bytes per character, but GPT tokenizers typically count each CJK character as 1-2 tokens (not 1 as bytes/3 would suggest). This means the token count is underestimated for CJK-heavy content, potentially causing shards to exceed maxLeafIndexTokens. Consider using a more conservative ratio (e.g., bytes/2) or a CJK-aware heuristic.

💡 Suggested Change

Before:

export function estimateIndexTokens(markdown) {
  return Math.ceil(Buffer.byteLength(markdown, 'utf8') / 3);
}

After:

export function estimateIndexTokens(markdown) {
  // CJK characters typically consume 2-3 tokens each in GPT tokenizers.
  // Using bytes/2 provides a safer upper bound, especially for CJK-heavy content.
  return Math.ceil(Buffer.byteLength(markdown, 'utf8') / 2);
}

📄 apps/daemon/src/tools/skills/wiki-build/scripts/lib/workspace.mjs

Inconsistent semicolons: buildIndexModelLenient omits semicolons on variable declarations (e.g., const capacity = ..., const topics = ..., const missing = []), while buildIndexModel and the rest of the file consistently use semicolons. This creates a style mismatch within the same module.

💡 Suggested Change

Before:

export function buildIndexModelLenient(plan, pages, summaries = {}) {
  const capacity = plan.capacity ?? DEFAULT_CAPACITY
  const topics = plan.topics ?? []
  const { topicById, topicPathMap } = buildTopicMaps(topics)

  // Group pages by topicId
  const pagesByTopic = new Map()
  const missing = []

After:

export function buildIndexModelLenient(plan, pages, summaries = {}) {
  const capacity = plan.capacity ?? DEFAULT_CAPACITY;
  const topics = plan.topics ?? [];
  const { topicById, topicPathMap } = buildTopicMaps(topics);

  // Group pages by topicId
  const pagesByTopic = new Map();
  const missing = [];

📄 apps/daemon/src/tools/skills/wiki-build/scripts/lib/workspace.mjs

The stale index-shards/ cleanup logic (lines 492-510) only iterates over model.indexes entries. If a leaf topic is completely removed from the plan, there is no corresponding INDEX.md in the model, and the old index-shards/ directory for that topic will never be cleaned up. This orphans disk space. reindexTopicAndAncestors has its own cleanup loop (lines 845-861) that handles topics in the chain, but finalizeBuildwriteIndexes has no equivalent cleanup for removed topics. Consider adding a scan of existing wiki/*/index-shards/ directories on disk and removing those not referenced in the current model.

💡 Suggested Change

Before:

  // Clean up stale index-shards/ directories that are no longer in the model.
  // This handles the sharded→inline transition: when a topic previously had
  // shards but now produces an inline index, the old index-shards/ directory
  // must be removed.
  for (const [relPath] of Object.entries(model.indexes)) {
    if (relPath.endsWith('/INDEX.md') && !relPath.includes('/index-shards/')) {
      const topicDir = dirname(join(vault, relPath));
      const shardDir = join(topicDir, 'index-shards');
      if (existsSync(shardDir)) {
        // Check whether the model includes any shards for this topic
        const topicRelDir = dirname(relPath);
        const hasModelShards = Object.keys(model.indexes).some(
          (p) => p.startsWith(`${topicRelDir}/index-shards/`),
        );
        if (!hasModelShards) {
          for (const file of readdirSync(shardDir)) {
            try { unlinkSync(join(shardDir, file)); } catch { /* best effort */ }
          }
          try { rmdirSync(shardDir); } catch { /* best effort */ }
        }
      }
    }
  }

After:

  // Clean up stale index-shards/ directories that are no longer in the model.
  // This handles the sharded→inline transition: when a topic previously had
  // shards but now produces an inline index, the old index-shards/ directory
  // must be removed.
  for (const [relPath] of Object.entries(model.indexes)) {
    if (relPath.endsWith('/INDEX.md') && !relPath.includes('/index-shards/')) {
      const topicDir = dirname(join(vault, relPath));
      const shardDir = join(topicDir, 'index-shards');
      if (existsSync(shardDir)) {
        // Check whether the model includes any shards for this topic
        const topicRelDir = dirname(relPath);
        const hasModelShards = Object.keys(model.indexes).some(
          (p) => p.startsWith(`${topicRelDir}/index-shards/`),
        );
        if (!hasModelShards) {
          for (const file of readdirSync(shardDir)) {
            try { unlinkSync(join(shardDir, file)); } catch { /* best effort */ }
          }
          try { rmdirSync(shardDir); } catch { /* best effort */ }
        }
      }
    }
  }

  // Clean up orphaned index-shards/ directories for topics removed from the plan.
  // When a topic is removed, its INDEX.md disappears from the model but its
  // shard directory may remain on disk.
  const wikiDir = join(vault, 'wiki');
  if (existsSync(wikiDir)) {
    const knownShardDirs = new Set(
      Object.keys(model.indexes)
        .filter((p) => p.includes('/index-shards/'))
        .map((p) => dirname(join(vault, p))),
    );
    const walkAndClean = (dir) => {
      for (const entry of readdirSync(dir, { withFileTypes: true })) {
        const full = join(dir, entry.name);
        if (entry.isDirectory() && entry.name === 'index-shards' && !knownShardDirs.has(full)) {
          for (const file of readdirSync(full)) {
            try { unlinkSync(join(full, file)); } catch { /* best effort */ }
          }
          try { rmdirSync(full); } catch { /* best effort */ }
        } else if (entry.isDirectory()) {
          walkAndClean(full);
        }
      }
    };
    walkAndClean(wikiDir);
  }

📄 apps/daemon/src/tools/skills/wiki-build/scripts/lib/workspace.mjs

appendBuildLog uses writeFileSync directly without the atomic write-then-rename pattern used elsewhere in the codebase (e.g., writeIndexes, atomicWrite in workspace.mjs). If the process crashes mid-write, the log file will be corrupted. While log corruption is less critical than data corruption, using the atomic pattern would be more consistent and robust.

💡 Suggested Change

Before:

  mkdirSync(dirname(logPath), { recursive: true })
  if (existsSync(logPath)) {
    const existing = readFileSync(logPath, 'utf8')
    // Insert after the "# 构建日志\n" header line
    const headerEnd = existing.indexOf('\n')
    if (headerEnd >= 0 && existing.startsWith('# ')) {
      writeFileSync(logPath, `${existing.slice(0, headerEnd + 1)}\n${block}${existing.slice(headerEnd + 1)}`, 'utf8')
    } else {
      writeFileSync(logPath, `${block}${existing}`, 'utf8')
    }
  } else {
    writeFileSync(logPath, `# 构建日志\n\n${block}`, 'utf8')
  }

After:

  mkdirSync(dirname(logPath), { recursive: true })
  let content = '';
  if (existsSync(logPath)) {
    const existing = readFileSync(logPath, 'utf8');
    // Insert after the "# 构建日志\n" header line
    const headerEnd = existing.indexOf('\n');
    if (headerEnd >= 0 && existing.startsWith('# ')) {
      content = `${existing.slice(0, headerEnd + 1)}\n${block}${existing.slice(headerEnd + 1)}`;
    } else {
      content = `${block}${existing}`;
    }
  } else {
    content = `# 构建日志\n\n${block}`;
  }
  // Use atomic write pattern to avoid corruption on crash
  const tmpPath = `${logPath}.tmp`;
  const fd = openSync(tmpPath, 'w');
  try {
    writeFileSync(fd, content, 'utf8');
    fsyncSync(fd);
  } finally {
    closeSync(fd);
  }
  renameSync(tmpPath, logPath);

Comment on lines 88 to +89
pathToKey.set(relPath, key);
pathToKey.set(relPath.replace(/\\/g, '/').toLowerCase(), key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pathToKey collision risk: relPath from scanTree always uses / separators, so the replace(/\\/g, '/') is a no-op. But .toLowerCase() means two files differing only by case (e.g., Foo.md and foo.md) will overwrite each other in pathToKey. On case-insensitive filesystems (macOS/Windows) this is a non-issue since the filesystem prevents such files from coexisting, but on Linux it silently loses one file's mapping. Consider using the original-case path as the canonical key and only storing the lowercase variant as a secondary lookup.

Suggestion:

Suggested change
pathToKey.set(relPath, key);
pathToKey.set(relPath.replace(/\\/g, '/').toLowerCase(), key);
pathToKey.set(relPath, key);
// Case-insensitive lookup; on case-sensitive filesystems two files
// differing only in case would collide, but that's a pathological edge case.
pathToKey.set(relPath.replace(/\\/g, '/').toLowerCase(), key);

Comment on lines +217 to +220
const exactCandidates = [`${normalizedTarget}.md`];
if (sourcePath.startsWith('wiki/') && !normalizedTarget.startsWith('wiki/')) {
exactCandidates.push(`wiki/${normalizedTarget}.md`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exactCandidates construction in resolveLink strips leading / and ./ from the normalized target, but pathToKey stores relative paths like wiki/concepts/foo.md (never with leading /). This is consistent. However, the wiki/ prefix prepend logic on line 218-219 only applies when sourcePath.startsWith('wiki/'). If a link [[entities/SomeEntity]] is written from a non-wiki source file, the wiki/ prefix won't be prepended, and the link won't resolve even though the target exists at wiki/entities/SomeEntity.md. This is likely intentional for the wiki build context, but worth confirming the expected behavior for non-wiki source files linking to wiki content.

Comment on lines +27 to +34
export const FILE_STATUSES = Object.freeze([
'pending', 'running', 'succeeded', 'failed', 'skipped',
]);
export const BATCH_STATUSES = FILE_STATUSES;
export const BUILD_PHASES = Object.freeze([
'not_started', 'scanned', 'draft', 'approved', 'running',
'paused', 'completed', 'completed_with_errors',
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FILE_STATUSES, BATCH_STATUSES, and BUILD_PHASES are exported but never imported by any other module. These are dead code. If they are intended for future use, consider adding them when the consuming code is ready. If they represent the canonical status/phase values used by the build system, they should be consumed by the modules that use string literals like 'pending', 'running', 'succeeded', 'failed', 'skipped' throughout the codebase (e.g., state.mjs, indexes.mjs).

Suggestion:

Suggested change
export const FILE_STATUSES = Object.freeze([
'pending', 'running', 'succeeded', 'failed', 'skipped',
]);
export const BATCH_STATUSES = FILE_STATUSES;
export const BUILD_PHASES = Object.freeze([
'not_started', 'scanned', 'draft', 'approved', 'running',
'paused', 'completed', 'completed_with_errors',
]);
export const FILE_STATUSES = Object.freeze([
'pending', 'running', 'succeeded', 'failed', 'skipped',
]);
export const BATCH_STATUSES = FILE_STATUSES;
export const BUILD_PHASES = Object.freeze([
'not_started', 'scanned', 'draft', 'approved', 'running',
'paused', 'completed', 'completed_with_errors',
]);

export const FILE_STATUSES = Object.freeze([
'pending', 'running', 'succeeded', 'failed', 'skipped',
]);
export const BATCH_STATUSES = FILE_STATUSES;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BATCH_STATUSES = FILE_STATUSES creates an alias, not a copy. If batch and file statuses diverge in the future, modifying one would silently affect the other. Consider using a separate array or a spread copy: export const BATCH_STATUSES = Object.freeze([...FILE_STATUSES]).

Suggestion:

Suggested change
export const BATCH_STATUSES = FILE_STATUSES;
export const BATCH_STATUSES = Object.freeze([...FILE_STATUSES]);

Comment on lines +350 to +353
if (existsSync(targetPath)) {
try { unlinkSync(targetPath); } catch { /* rename will overwrite */ }
}
renameSync(temporary, targetPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

atomicReplaceFile uses renameSync after attempting unlinkSync. On Windows, if unlinkSync fails (e.g., another process holds the file, permission denied), the catch block silently swallows the error, and renameSync will then fail with a different error — but the original unlinkSync failure is lost. The function should throw a descriptive error instead of catching and ignoring the unlink failure. Additionally, the catch block catches ALL errors (including unexpected ones like ENOENT), not just EBUSY/EPERM. Consider using renameSync with a try/catch, and if it fails with EPERM/EACCES (Windows-specific), retry with copyFileSync + unlinkSync as a fallback.

Suggestion:

Suggested change
if (existsSync(targetPath)) {
try { unlinkSync(targetPath); } catch { /* rename will overwrite */ }
}
renameSync(temporary, targetPath);
if (existsSync(targetPath)) {
try {
unlinkSync(targetPath);
} catch (err) {
if (err.code !== 'ENOENT') {
throw codedError('ATOMIC_REPLACE_FAILED',
`Failed to remove target ${targetPath}: ${err.message}`, { code: err.code });
}
}
}
try {
renameSync(temporary, targetPath);
} catch (err) {
// On Windows, renameSync may fail if the target exists despite unlink.
// Fall back to copy + unlink.
if (err.code === 'EPERM' || err.code === 'EACCES') {
const { copyFileSync } = require('node:fs');
copyFileSync(temporary, targetPath);
unlinkSync(temporary);
} else {
throw err;
}
}

Comment on lines +406 to +409
const colonIndex = line.indexOf(':')
if (colonIndex < 1) continue
const key = line.slice(0, colonIndex).trim()
let value = line.slice(colonIndex + 1).trim()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseFrontmatter uses line.indexOf(':') to split key-value pairs. This fails on values containing colons — e.g., summary: Foo: bar is parsed as summary: "Foo" instead of summary: "Foo: bar". Wiki page frontmatter generated by the tool can contain colons in summaries and titles. This can cause assembleAutoPayload to reject valid staged pages as missing required frontmatter fields, or silently produce incorrect metadata. Fix by splitting only on the first colon.

Suggestion:

Suggested change
const colonIndex = line.indexOf(':')
if (colonIndex < 1) continue
const key = line.slice(0, colonIndex).trim()
let value = line.slice(colonIndex + 1).trim()
// Split only on the first colon to allow colons in values
const colonIndex = line.indexOf(':')
if (colonIndex < 1) continue
const key = line.slice(0, colonIndex).trim()
let value = line.slice(colonIndex + 1).trim()

Comment on lines +82 to +89
if (existsSync(paths.plan)) {
readJson(paths.plan);
return { phase: 'approved' };
}
if (existsSync(paths.planDraft)) {
readJson(paths.planDraft);
return { phase: 'draft' };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The status function reads plan and planDraft files using readJson without try-catch. If these files are corrupted or contain malformed JSON, JSON.parse will throw an unhandled exception, crashing the process instead of returning a graceful error status (e.g., { phase: 'error', message: 'Corrupted file' }).

Suggestion:

Suggested change
if (existsSync(paths.plan)) {
readJson(paths.plan);
return { phase: 'approved' };
}
if (existsSync(paths.planDraft)) {
readJson(paths.planDraft);
return { phase: 'draft' };
}
if (existsSync(paths.plan)) {
try {
readJson(paths.plan);
return { phase: 'approved' };
} catch {
return { phase: 'error', message: 'Corrupted plan file' };
}
}
if (existsSync(paths.planDraft)) {
try {
readJson(paths.planDraft);
return { phase: 'draft' };
} catch {
return { phase: 'error', message: 'Corrupted plan draft file' };
}
}

Comment on lines +230 to +231
const payload = withMutationLock(paths, () => assembleAutoPayload(paths, batchId, attemptToken, failedFiles));
withAsyncMutationLock(paths, () => checkpointBatch(paths, payload))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkpoint --auto path calls assembleAutoPayload inside a synchronous withMutationLock and checkpointBatch inside a separate withAsyncMutationLock. Between the release of the sync lock and acquisition of the async lock, another process could modify the state (e.g., claim a different batch, modify file statuses). While checkpointBatch re-validates activeBatchId and attemptToken, the assembleAutoPayload results (which read state/plan) could become stale. Consider using a single lock scope that covers both the payload assembly and the checkpoint, or accept that checkpointBatch's own re-validation is the authoritative check.

Suggestion:

Suggested change
const payload = withMutationLock(paths, () => assembleAutoPayload(paths, batchId, attemptToken, failedFiles));
withAsyncMutationLock(paths, () => checkpointBatch(paths, payload))
withAsyncMutationLock(paths, async () => {
const payload = assembleAutoPayload(paths, batchId, attemptToken, failedFiles);
return checkpointBatch(paths, payload);
})

Comment on lines +260 to +261
const plan = readJson(paths.plan);
const state = readJson(paths.state);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reindex command reads paths.plan and paths.state with readJson but does not check if the files exist first. If the build has not completed the plan phase, readFileSync will throw an ENOENT error, crashing the process with an unhelpful error message instead of returning a friendly CLI error.

Suggestion:

Suggested change
const plan = readJson(paths.plan);
const state = readJson(paths.state);
if (!existsSync(paths.plan)) cliError(command, 'PLAN_NOT_FOUND', 'Plan file not found. Run "plan approve" first.');
if (!existsSync(paths.state)) cliError(command, 'STATE_NOT_FOUND', 'State file not found. Run "next" and batch processing first.');
const plan = readJson(paths.plan);
const state = readJson(paths.state);

Comment on lines +246 to +247
const summariesPath = assertPathWithinVault(vault, resolve(vault, options.summaries));
const summaries = readJson(summariesPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The finalize command calls assertPathWithinVault which only ensures the path is within the vault but does not guarantee the file exists (it resolves to the nearest existing parent directory). The subsequent readJson call will throw ENOENT if the summaries file doesn't exist, producing an unhelpful error. Add an explicit existsSync check before readJson.

Suggestion:

Suggested change
const summariesPath = assertPathWithinVault(vault, resolve(vault, options.summaries));
const summaries = readJson(summariesPath);
const summariesPath = assertPathWithinVault(vault, resolve(vault, options.summaries));
if (!existsSync(summariesPath)) cliError(command, 'FILE_NOT_FOUND', `Summaries file not found: ${summariesPath}`);
const summaries = readJson(summariesPath);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant