@sanqianx/project-knowledge 4.6.1
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/.agents/plugins/marketplace.json +20 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/CHANGELOG.md +780 -0
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +266 -0
- package/_site/README.md +30 -0
- package/_site/_test/ai-profile-resolver-test.js +137 -0
- package/_site/_test/ai-profile-test.js +75 -0
- package/_site/_test/ai-vendor-presets-test.js +91 -0
- package/_site/_test/automation-queue-test.js +14 -0
- package/_site/_test/automation-ui-test.js +40 -0
- package/_site/_test/background-task-registry-test.js +43 -0
- package/_site/_test/baseline-schema-test.js +91 -0
- package/_site/_test/bin-cli-test.js +218 -0
- package/_site/_test/bridge-adapter-test.js +95 -0
- package/_site/_test/bridge-consumer-service-test.js +310 -0
- package/_site/_test/cdp-browser-failure-test.js +100 -0
- package/_site/_test/chat-claudecodeui-match-test.js +92 -0
- package/_site/_test/claude-executable-discovery-test.js +90 -0
- package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -0
- package/_site/_test/claude-workbench-test.js +170 -0
- package/_site/_test/client-capture-restart-e2e-test.js +189 -0
- package/_site/_test/codex-conversation-projection-test.js +59 -0
- package/_site/_test/codex-notify-fanout-test.js +45 -0
- package/_site/_test/commit-boundary-freeze-test.js +147 -0
- package/_site/_test/commit-conversation-binding-test.js +99 -0
- package/_site/_test/commit-evidence-test.js +110 -0
- package/_site/_test/context-pack-test.js +198 -0
- package/_site/_test/conversation-api-test.js +119 -0
- package/_site/_test/conversation-store-test.js +136 -0
- package/_site/_test/cross-repo-isolation-e2e-test.js +187 -0
- package/_site/_test/data-dir-migration-test.js +77 -0
- package/_site/_test/desktop-browser-compat-test.js +28 -0
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -0
- package/_site/_test/embedding-config-test.js +32 -0
- package/_site/_test/explicit-commit-processor-test.js +37 -0
- package/_site/_test/fixtures/fake-browser-exit.js +10 -0
- package/_site/_test/fixtures/fake-browser-hang.js +10 -0
- package/_site/_test/fixtures/make-git-repos.js +89 -0
- package/_site/_test/folder-picker-output-test.js +19 -0
- package/_site/_test/full-integration-e2e-test.js +165 -0
- package/_site/_test/git-validation-test.js +81 -0
- package/_site/_test/hook-runtime-endpoint-test.js +88 -0
- package/_site/_test/hook-status-repair-api-test.js +152 -0
- package/_site/_test/hook-trigger-test.js +85 -0
- package/_site/_test/hook-worktree-test.js +42 -0
- package/_site/_test/import-preflight-api-test.js +233 -0
- package/_site/_test/import-ui-flow-test.js +171 -0
- package/_site/_test/index-builder-test.js +41 -0
- package/_site/_test/index-writer-concurrency-test.js +135 -0
- package/_site/_test/integration-adapters-test.js +243 -0
- package/_site/_test/integration-capture-setup-test.js +108 -0
- package/_site/_test/integration-surface-coverage-test.js +165 -0
- package/_site/_test/kb-framework-test.js +57 -0
- package/_site/_test/kbpath-follow-test.js +63 -0
- package/_site/_test/knowledge-db-test.js +144 -0
- package/_site/_test/knowledge-language-control-test.js +170 -0
- package/_site/_test/knowledge-maintenance-test.js +40 -0
- package/_site/_test/knowledge-migration-test.js +75 -0
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -0
- package/_site/_test/knowledge-query-test.js +54 -0
- package/_site/_test/knowledge-retrieval-service-test.js +77 -0
- package/_site/_test/knowledge-scopes-test.js +37 -0
- package/_site/_test/knowledge-storage-location-test.js +28 -0
- package/_site/_test/knowledge-storage-startup-test.js +52 -0
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -0
- package/_site/_test/legacy-claude-session-alias-test.js +106 -0
- package/_site/_test/legacy-forward-compat-test.js +257 -0
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -0
- package/_site/_test/legacy-routes-removed-test.js +47 -0
- package/_site/_test/legacy-workbench-exclusion-test.js +92 -0
- package/_site/_test/log-redaction-test.js +58 -0
- package/_site/_test/logging-api-test.js +89 -0
- package/_site/_test/logging-sse-no-gap-test.js +88 -0
- package/_site/_test/logging-ui-test.js +104 -0
- package/_site/_test/markdown-delta-overlay-test.js +85 -0
- package/_site/_test/markdown-maintenance-api-test.js +75 -0
- package/_site/_test/markdown-maintenance-test.js +11 -0
- package/_site/_test/mcp-server-test.js +149 -0
- package/_site/_test/model-context-windows-test.js +89 -0
- package/_site/_test/module-bridge-eventbridge-test.js +193 -0
- package/_site/_test/non-release-ci-test.js +34 -0
- package/_site/_test/offline-boundary-isolation-test.js +32 -0
- package/_site/_test/orphaned-claim-recovery-test.js +80 -0
- package/_site/_test/p0-data-migration-characterization-test.js +37 -0
- package/_site/_test/p0-e2e-gate-test.js +347 -0
- package/_site/_test/package-boundary-test.js +42 -0
- package/_site/_test/package-startup-test.js +56 -0
- package/_site/_test/packaged-ui-smoke-test.js +79 -0
- package/_site/_test/path-consistency-test.js +151 -0
- package/_site/_test/pending-sweep-test.js +9 -0
- package/_site/_test/post-commit-automation-test.js +87 -0
- package/_site/_test/pr-consumer-contract-test.js +55 -0
- package/_site/_test/project-control-panel-task14-test.js +63 -0
- package/_site/_test/project-delete-recovery-test.js +64 -0
- package/_site/_test/project-goal-editor-test.js +145 -0
- package/_site/_test/project-layout-v2-migration-test.js +127 -0
- package/_site/_test/project-lifecycle-transaction-test.js +106 -0
- package/_site/_test/project-remove-running-guard-test.js +79 -0
- package/_site/_test/project-store-test.js +90 -0
- package/_site/_test/prompt-settings-test.js +115 -0
- package/_site/_test/protected-architecture-gate-test.js +123 -0
- package/_site/_test/refactor-characterization-test.js +36 -0
- package/_site/_test/release-version-sync-test.js +98 -0
- package/_site/_test/requirement-binding-test.js +113 -0
- package/_site/_test/requirement-recorder-test.js +173 -0
- package/_site/_test/run-all-tests.js +156 -0
- package/_site/_test/runtime-endpoint-test.js +64 -0
- package/_site/_test/scanner-test.js +83 -0
- package/_site/_test/server-runtime-migration-safety-test.js +27 -0
- package/_site/_test/server-security-test.js +96 -0
- package/_site/_test/sessions-stream-test.js +92 -0
- package/_site/_test/shared-contracts-test.js +58 -0
- package/_site/_test/simple-import-test.js +97 -0
- package/_site/_test/startup-analysis-disabled-test.js +16 -0
- package/_site/_test/storage-foundation-test.js +78 -0
- package/_site/_test/structured-logger-test.js +101 -0
- package/_site/_test/task15-20-ui-flow-test.js +148 -0
- package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -0
- package/_site/_test/team-knowledge-store-test.js +131 -0
- package/_site/_test/tracking-start-test.js +143 -0
- package/_site/_test/ui-i18n-toggle-test.js +114 -0
- package/_site/_test/ui-smoke-test.js +73 -0
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -0
- package/_site/_test/workbench-permission-test.js +118 -0
- package/_site/_test/workspace-ui-contract-test.js +43 -0
- package/_site/lib/ai-profile-resolver.js +78 -0
- package/_site/lib/ai-vendor-presets.js +83 -0
- package/_site/lib/ai-workspace.js +104 -0
- package/_site/lib/atomic-file.js +204 -0
- package/_site/lib/automation-config.js +140 -0
- package/_site/lib/bridge-adapter.js +256 -0
- package/_site/lib/bridge-consumer-service.js +402 -0
- package/_site/lib/claude-cli-runner.js +1599 -0
- package/_site/lib/commit-conversation-binder.js +246 -0
- package/_site/lib/commit-processing-ledger.js +27 -0
- package/_site/lib/commit-prompt.js +202 -0
- package/_site/lib/commit-reconciler.js +444 -0
- package/_site/lib/context-pack-builder.js +299 -0
- package/_site/lib/contracts.js +279 -0
- package/_site/lib/conversation-exclusions.js +204 -0
- package/_site/lib/conversation-query-service.js +161 -0
- package/_site/lib/conversation-store.js +385 -0
- package/_site/lib/data-dir.js +81 -0
- package/_site/lib/data-state-classifier.js +40 -0
- package/_site/lib/embedding-service.js +85 -0
- package/_site/lib/evidence-bundle.js +228 -0
- package/_site/lib/folder-picker-output.js +24 -0
- package/_site/lib/git-runner.js +62 -0
- package/_site/lib/github-team-store.js +1175 -0
- package/_site/lib/hook-manager.js +202 -0
- package/_site/lib/index-builder.js +157 -0
- package/_site/lib/index-service.js +173 -0
- package/_site/lib/integration-installer.js +907 -0
- package/_site/lib/kb-framework.js +191 -0
- package/_site/lib/kb-validator.js +124 -0
- package/_site/lib/knowledge-db.js +363 -0
- package/_site/lib/knowledge-promotion.js +435 -0
- package/_site/lib/knowledge-retrieval-service.js +401 -0
- package/_site/lib/knowledge-schema.js +121 -0
- package/_site/lib/knowledge-tool-runtime.js +359 -0
- package/_site/lib/legacy-data-manifest.js +26 -0
- package/_site/lib/llm-client.js +173 -0
- package/_site/lib/markdown-knowledge-indexer.js +230 -0
- package/_site/lib/migration-service.js +442 -0
- package/_site/lib/model-context-windows.js +82 -0
- package/_site/lib/module-bridge.js +496 -0
- package/_site/lib/post-commit-automation.js +141 -0
- package/_site/lib/project-lifecycle-service.js +511 -0
- package/_site/lib/project-registry-store.js +129 -0
- package/_site/lib/project-store.js +262 -0
- package/_site/lib/prompt-registry.js +148 -0
- package/_site/lib/requirement-adapters.js +10 -0
- package/_site/lib/requirement-binder.js +112 -0
- package/_site/lib/requirement-recorder.js +278 -0
- package/_site/lib/runtime-endpoint.js +160 -0
- package/_site/lib/scanner.js +298 -0
- package/_site/lib/server-app.js +1520 -0
- package/_site/lib/settings-store.js +113 -0
- package/_site/lib/storage-layout.js +162 -0
- package/_site/lib/structured-logger.js +574 -0
- package/_site/scripts/codex-notify-fanout.cjs +51 -0
- package/_site/scripts/folder-picker.ps1 +156 -0
- package/_site/scripts/hook-trigger.js +156 -0
- package/_site/scripts/sync-release-version.js +158 -0
- package/_site/server.js +12 -0
- package/_site/start.bat +26 -0
- package/_site/stop.bat +11 -0
- package/bin/project-knowledge-integrations.js +241 -0
- package/bin/project-knowledge-kb.js +65 -0
- package/bin/project-knowledge-mcp.js +195 -0
- package/bin/project-knowledge.js +439 -0
- package/claude-prompts.json +21 -0
- package/docs/README.zh-CN.md +175 -0
- package/docs/assets/architecture.svg +182 -0
- package/docs/assets/dashboard.png +0 -0
- package/docs/assets/draft-review.png +0 -0
- package/docs/assets/logo.svg +50 -0
- package/docs/assets/runs-drafts.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/project-registry-schema.md +33 -0
- package/docs/team-knowledge-mode-a-plan.md +73 -0
- package/docs/testing-strategy.md +33 -0
- package/package.json +100 -0
- package/plugins/project-knowledge/.claude-plugin/plugin.json +19 -0
- package/plugins/project-knowledge/.codex-plugin/plugin.json +38 -0
- package/plugins/project-knowledge/.mcp.json +14 -0
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -0
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -0
- package/plugins/project-knowledge/skills/project-knowledge/agents/openai.yaml +11 -0
- package/templates/change.md +30 -0
- package/templates/project-readme.md +52 -0
- package/ui/app.css +58 -0
- package/ui/app.js +607 -0
- package/ui/favicon.svg +38 -0
- package/ui/i18n.js +146 -0
- package/ui/index.html +162 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { sha256 } = require('./knowledge-schema');
|
|
4
|
+
|
|
5
|
+
const CHUNKER_VERSION = 1;
|
|
6
|
+
const DEFAULT_MAX_CHARS = 1200;
|
|
7
|
+
const DEFAULT_OVERLAP_CHARS = 120;
|
|
8
|
+
const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules']);
|
|
9
|
+
const DERIVED_INDEX_FILENAME = '00-index.md';
|
|
10
|
+
|
|
11
|
+
function normalizeRel(value) {
|
|
12
|
+
return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stripFrontmatter(markdown) {
|
|
16
|
+
return String(markdown || '').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseListValue(value) {
|
|
20
|
+
const raw = String(value || '').trim();
|
|
21
|
+
const body = raw.startsWith('[') && raw.endsWith(']') ? raw.slice(1, -1) : raw;
|
|
22
|
+
return body.split(',').map(item => item.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseKnowledgeMetadata(markdown) {
|
|
26
|
+
const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(String(markdown || ''));
|
|
27
|
+
if (!match) return { tags: [], sourcePaths: [], routes: [], symbols: [], affectedModules: [] };
|
|
28
|
+
const values = {};
|
|
29
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
30
|
+
const field = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
|
|
31
|
+
if (field) values[field[1].toLowerCase()] = field[2];
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
tags: parseListValue(values.tags),
|
|
35
|
+
sourcePaths: parseListValue(values.sourcepaths || values.source_paths),
|
|
36
|
+
routes: parseListValue(values.routes),
|
|
37
|
+
symbols: parseListValue(values.symbols),
|
|
38
|
+
affectedModules: parseListValue(values.affectedmodules || values.affected_modules),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function splitLongText(text, maxChars, overlapChars) {
|
|
43
|
+
const clean = String(text || '').trim();
|
|
44
|
+
if (!clean) return [];
|
|
45
|
+
if (clean.length <= maxChars) return [clean];
|
|
46
|
+
const parts = [];
|
|
47
|
+
let start = 0;
|
|
48
|
+
while (start < clean.length) {
|
|
49
|
+
let end = Math.min(start + maxChars, clean.length);
|
|
50
|
+
if (end < clean.length) {
|
|
51
|
+
const floor = start + Math.floor(maxChars * 0.6);
|
|
52
|
+
const boundary = Math.max(clean.lastIndexOf('\n\n', end), clean.lastIndexOf('。', end), clean.lastIndexOf('. ', end));
|
|
53
|
+
if (boundary >= floor) end = boundary + 1;
|
|
54
|
+
}
|
|
55
|
+
parts.push(clean.slice(start, end).trim());
|
|
56
|
+
if (end >= clean.length) break;
|
|
57
|
+
start = Math.max(start + 1, end - overlapChars);
|
|
58
|
+
}
|
|
59
|
+
return parts.filter(Boolean);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function chunkMarkdown(markdown, options = {}) {
|
|
63
|
+
const maxChars = Number(options.maxChars || DEFAULT_MAX_CHARS);
|
|
64
|
+
const overlapChars = Math.min(Number(options.overlapChars || DEFAULT_OVERLAP_CHARS), Math.floor(maxChars / 3));
|
|
65
|
+
const body = stripFrontmatter(markdown).replace(/\r\n/g, '\n');
|
|
66
|
+
const lines = body.split('\n');
|
|
67
|
+
const headings = [];
|
|
68
|
+
const sections = [];
|
|
69
|
+
let buffer = [];
|
|
70
|
+
let sectionHeadings = [];
|
|
71
|
+
|
|
72
|
+
const flush = () => {
|
|
73
|
+
const text = buffer.join('\n').trim();
|
|
74
|
+
if (text) sections.push({ headingPath: [...sectionHeadings], text });
|
|
75
|
+
buffer = [];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
80
|
+
if (match) {
|
|
81
|
+
flush();
|
|
82
|
+
const level = match[1].length;
|
|
83
|
+
headings.length = level - 1;
|
|
84
|
+
headings[level - 1] = match[2].trim();
|
|
85
|
+
sectionHeadings = headings.filter(Boolean);
|
|
86
|
+
buffer.push(line);
|
|
87
|
+
} else {
|
|
88
|
+
buffer.push(line);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
flush();
|
|
92
|
+
|
|
93
|
+
const chunks = [];
|
|
94
|
+
for (const section of sections) {
|
|
95
|
+
for (const text of splitLongText(section.text, maxChars, overlapChars)) {
|
|
96
|
+
chunks.push({
|
|
97
|
+
chunkOrder: chunks.length,
|
|
98
|
+
headingPath: section.headingPath,
|
|
99
|
+
chunkText: text,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return chunks;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isDerivedIndex(filePath) {
|
|
107
|
+
return path.basename(String(filePath || '')).toLowerCase() === DERIVED_INDEX_FILENAME;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function listMarkdownFiles(root, options = {}) {
|
|
111
|
+
const includeDerived = options.includeDerived === true;
|
|
112
|
+
const files = [];
|
|
113
|
+
const walk = (dir) => {
|
|
114
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
115
|
+
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
|
|
116
|
+
const abs = path.join(dir, entry.name);
|
|
117
|
+
if (entry.isDirectory()) walk(abs);
|
|
118
|
+
else if (entry.isFile() && /\.md$/i.test(entry.name) && (includeDerived || !isDerivedIndex(entry.name))) files.push(abs);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
if (fs.existsSync(root)) walk(path.resolve(root));
|
|
122
|
+
return files.sort();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function inferEntryType(relativePath) {
|
|
126
|
+
const rel = normalizeRel(relativePath).toLowerCase();
|
|
127
|
+
if (rel === 'goal.md') return 'goal';
|
|
128
|
+
if (rel === 'architecture.md') return 'architecture';
|
|
129
|
+
if (rel.startsWith('modules/')) return 'module';
|
|
130
|
+
if (rel.startsWith('changes/')) return 'change';
|
|
131
|
+
return 'document';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function inferTitle(markdown, relativePath) {
|
|
135
|
+
const heading = /^#\s+(.+)$/m.exec(stripFrontmatter(markdown));
|
|
136
|
+
return heading ? heading[1].trim() : path.basename(relativePath, path.extname(relativePath));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class MarkdownKnowledgeIndexer {
|
|
140
|
+
constructor(options = {}) {
|
|
141
|
+
if (!options.database) throw new Error('database is required');
|
|
142
|
+
if (!options.embedder) throw new Error('embedder is required');
|
|
143
|
+
this.database = options.database;
|
|
144
|
+
this.embedder = options.embedder;
|
|
145
|
+
this.maxChars = options.maxChars || DEFAULT_MAX_CHARS;
|
|
146
|
+
this.overlapChars = options.overlapChars || DEFAULT_OVERLAP_CHARS;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async indexFile(input) {
|
|
150
|
+
const filePath = path.resolve(input.filePath);
|
|
151
|
+
const relativePath = normalizeRel(input.relativePath || path.basename(filePath));
|
|
152
|
+
const markdown = fs.readFileSync(filePath, 'utf8');
|
|
153
|
+
const documentHash = sha256(`chunker:${CHUNKER_VERSION}\n${markdown}`);
|
|
154
|
+
const existing = await this.database.rowsForEntry(input.spaceId, relativePath);
|
|
155
|
+
if (existing.length && existing.every(row => row.document_hash === documentHash)) {
|
|
156
|
+
return { ok: true, action: 'unchanged', entryId: relativePath, chunks: existing.length, documentHash };
|
|
157
|
+
}
|
|
158
|
+
const title = inferTitle(markdown, relativePath);
|
|
159
|
+
const metadata = parseKnowledgeMetadata(markdown);
|
|
160
|
+
const rawChunks = chunkMarkdown(markdown, { maxChars: this.maxChars, overlapChars: this.overlapChars });
|
|
161
|
+
const chunks = [];
|
|
162
|
+
for (const chunk of rawChunks) {
|
|
163
|
+
const embeddingText = [title, chunk.headingPath.join(' > '), chunk.chunkText].filter(Boolean).join('\n');
|
|
164
|
+
chunks.push({
|
|
165
|
+
...chunk,
|
|
166
|
+
title,
|
|
167
|
+
entryType: inferEntryType(relativePath),
|
|
168
|
+
// Keyword search now indexes chunk_text directly. Keep only compact
|
|
169
|
+
// title/heading metadata in the legacy search_text column so new rows
|
|
170
|
+
// do not duplicate the entire document body.
|
|
171
|
+
searchText: [title, chunk.headingPath.join(' > ')].filter(Boolean).join('\n'),
|
|
172
|
+
vector: await this.embedder.embedPassage(embeddingText),
|
|
173
|
+
tags: [...new Set([...metadata.tags, ...metadata.affectedModules])],
|
|
174
|
+
sourcePaths: metadata.sourcePaths.length ? metadata.sourcePaths : input.sourcePaths || [],
|
|
175
|
+
routes: metadata.routes,
|
|
176
|
+
symbols: metadata.symbols,
|
|
177
|
+
sourceProjectId: input.sourceProjectId || '',
|
|
178
|
+
sourceCommit: input.sourceCommit || '',
|
|
179
|
+
documentHash,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const result = await this.database.replaceEntry(input.spaceId, relativePath, chunks);
|
|
183
|
+
return { ...result, entryId: relativePath, chunks: chunks.length, documentHash };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async indexDirectory(input) {
|
|
187
|
+
const root = path.resolve(input.kbPath);
|
|
188
|
+
const files = listMarkdownFiles(root);
|
|
189
|
+
const present = new Set();
|
|
190
|
+
const results = [];
|
|
191
|
+
for (const filePath of files) {
|
|
192
|
+
const relativePath = normalizeRel(path.relative(root, filePath));
|
|
193
|
+
present.add(relativePath);
|
|
194
|
+
results.push(await this.indexFile({ ...input, filePath, relativePath }));
|
|
195
|
+
}
|
|
196
|
+
const stale = (await this.database.entryIds(input.spaceId)).filter(entryId => !present.has(entryId));
|
|
197
|
+
let deletedChunks = 0;
|
|
198
|
+
for (const entryId of stale) deletedChunks += (await this.database.deleteEntry(input.spaceId, entryId)).deleted;
|
|
199
|
+
const changed = results.filter(item => item.action !== 'unchanged');
|
|
200
|
+
const operations = changed.length + stale.length;
|
|
201
|
+
const rows = changed.reduce((sum, item) => sum + Number(item.upserted || 0) + Number(item.deleted || 0), 0) + deletedChunks;
|
|
202
|
+
const mutationState = this.database.noteMutations({ operations, rows });
|
|
203
|
+
let maintenance = { ok: true, optimized: false, deferred: input.deferMaintenance === true, state: mutationState };
|
|
204
|
+
if (await this.database.count([input.spaceId])) {
|
|
205
|
+
await this.database.ensureSearchIndexes();
|
|
206
|
+
if (input.deferMaintenance !== true) maintenance = await this.database.maybeOptimize();
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
spaceId: input.spaceId,
|
|
211
|
+
files: files.length,
|
|
212
|
+
indexed: changed.length,
|
|
213
|
+
unchanged: results.filter(item => item.action === 'unchanged').length,
|
|
214
|
+
deletedEntries: stale.length,
|
|
215
|
+
deletedChunks,
|
|
216
|
+
maintenance,
|
|
217
|
+
results,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
MarkdownKnowledgeIndexer,
|
|
224
|
+
CHUNKER_VERSION,
|
|
225
|
+
chunkMarkdown,
|
|
226
|
+
listMarkdownFiles,
|
|
227
|
+
isDerivedIndex,
|
|
228
|
+
inferEntryType,
|
|
229
|
+
parseKnowledgeMetadata,
|
|
230
|
+
};
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { SCHEMAS, SCHEMA_VERSIONS, DomainError } = require('./contracts');
|
|
5
|
+
const AtomicFile = require('./atomic-file');
|
|
6
|
+
const { StorageLayout } = require('./storage-layout');
|
|
7
|
+
const { defaultSettings, validateSettings } = require('./settings-store');
|
|
8
|
+
const { emptyRegistry, validateRegistry } = require('./project-registry-store');
|
|
9
|
+
const { defaultProjectConfig, defaultProjectState, validateProjectConfig, validateProjectState } = require('./project-store');
|
|
10
|
+
const { LEGACY_ASSETS, getLegacyAsset, assetPath } = require('./legacy-data-manifest');
|
|
11
|
+
const { STATES, classifyDataState } = require('./data-state-classifier');
|
|
12
|
+
|
|
13
|
+
function hashBuffer(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex'); }
|
|
14
|
+
function hashFile(filePath) { return hashBuffer(fs.readFileSync(filePath)); }
|
|
15
|
+
|
|
16
|
+
function copyRecursive(source, target) {
|
|
17
|
+
const stat = fs.statSync(source);
|
|
18
|
+
if (stat.isDirectory()) {
|
|
19
|
+
fs.mkdirSync(target, { recursive: true });
|
|
20
|
+
for (const entry of fs.readdirSync(source)) copyRecursive(path.join(source, entry), path.join(target, entry));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
24
|
+
fs.copyFileSync(source, target, fs.constants.COPYFILE_EXCL);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function removeOwnedPath(target) {
|
|
28
|
+
if (!fs.existsSync(target)) return;
|
|
29
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pruneEmptyParents(target, boundary) {
|
|
33
|
+
const root = path.resolve(boundary);
|
|
34
|
+
let current = path.dirname(path.resolve(target));
|
|
35
|
+
while (current !== root && current.startsWith(`${root}${path.sep}`)) {
|
|
36
|
+
if (!fs.existsSync(current) || fs.readdirSync(current).length > 0) break;
|
|
37
|
+
fs.rmdirSync(current);
|
|
38
|
+
current = path.dirname(current);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function deterministicProjectId(slug, config) {
|
|
43
|
+
const identity = String(config && (config.projectId || config.gitPath || config.localPath || config.repoPath) || slug);
|
|
44
|
+
const existing = String(config && config.projectId || '');
|
|
45
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$/.test(existing)) return existing;
|
|
46
|
+
return `project-${crypto.createHash('sha256').update(`${slug}\0${identity}`).digest('hex').slice(0, 20)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeReadJson(filePath, fallback = null) {
|
|
50
|
+
if (!fs.existsSync(filePath)) return fallback;
|
|
51
|
+
try {
|
|
52
|
+
// Legacy files written via PowerShell/tooling often carry a UTF-8 BOM;
|
|
53
|
+
// JSON.parse rejects it, and a BOM must never fail the whole migration.
|
|
54
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
55
|
+
return JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
|
|
56
|
+
}
|
|
57
|
+
catch (error) { throw new DomainError('DATA_CORRUPT', 'Legacy migration source JSON is corrupt.', { status: 500, cause: error, details: { category: path.basename(filePath) } }); }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sourceSnapshot(paths) {
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const source of paths) {
|
|
63
|
+
if (!fs.existsSync(source)) continue;
|
|
64
|
+
const stat = fs.statSync(source);
|
|
65
|
+
entries.push({
|
|
66
|
+
path: source,
|
|
67
|
+
kind: stat.isDirectory() ? 'directory' : 'file',
|
|
68
|
+
size: stat.size,
|
|
69
|
+
mtimeMs: stat.mtimeMs,
|
|
70
|
+
hash: stat.isFile() ? hashFile(source) : '',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function verifySourceSnapshot(entries) {
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!fs.existsSync(entry.path)) return false;
|
|
79
|
+
const stat = fs.statSync(entry.path);
|
|
80
|
+
if (entry.kind === 'file' && (stat.size !== entry.size || hashFile(entry.path) !== entry.hash)) return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function directoryDigest(root) {
|
|
86
|
+
const hash = crypto.createHash('sha256');
|
|
87
|
+
const walk = current => {
|
|
88
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
89
|
+
const item = path.join(current, entry.name);
|
|
90
|
+
hash.update(`${entry.isDirectory() ? 'd' : 'f'}:${path.relative(root, item).replace(/\\/g, '/')}\0`);
|
|
91
|
+
if (entry.isDirectory()) walk(item); else if (entry.isFile()) hash.update(fs.readFileSync(item));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
walk(root);
|
|
95
|
+
return hash.digest('hex');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class MigrationService {
|
|
99
|
+
constructor(options = {}) {
|
|
100
|
+
this.layout = options.layout || new StorageLayout(options);
|
|
101
|
+
this.atomic = options.atomic || AtomicFile;
|
|
102
|
+
this.legacyDataDir = path.resolve(options.legacyDataDir || this.layout.getDataDir());
|
|
103
|
+
this.indexValidator = options.indexValidator || (async indexPath => ({ ok: fs.existsSync(indexPath) }));
|
|
104
|
+
this.logger = options.logger || { info() {}, warn() {}, error() {} };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
completion() {
|
|
108
|
+
const completionPath = this.layout.getMigrationCompletionPath();
|
|
109
|
+
if (!fs.existsSync(completionPath)) return null;
|
|
110
|
+
const marker = this.atomic.readJsonStrict(completionPath, { category: 'layout-migration-completion' });
|
|
111
|
+
return marker && ['layout-migration-completion/v1', 'layout-migration-completion/v2'].includes(marker.schema) && marker.migrationId === SCHEMA_VERSIONS.layoutMigration
|
|
112
|
+
? marker
|
|
113
|
+
: null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
completionMarker({ runId = '', registry, settings, modelsMigrated = false } = {}) {
|
|
117
|
+
const profiles = settings && settings.ai && Array.isArray(settings.ai.profiles) ? settings.ai.profiles : [];
|
|
118
|
+
return {
|
|
119
|
+
schema: 'layout-migration-completion/v2',
|
|
120
|
+
migrationId: SCHEMA_VERSIONS.layoutMigration,
|
|
121
|
+
runId,
|
|
122
|
+
sourceRoot: this.legacyDataDir,
|
|
123
|
+
sourceManifestHash: hashBuffer(Buffer.from(JSON.stringify(LEGACY_ASSETS.map(asset => [asset.source, asset.target, asset.kind])))),
|
|
124
|
+
projectCount: registry.projectOrder.length,
|
|
125
|
+
aiProfileCount: profiles.length,
|
|
126
|
+
knowledgeRootConfigured: Boolean(settings.knowledge && settings.knowledge.rootPath),
|
|
127
|
+
embeddingConfigured: Boolean(settings.embedding && Object.keys(settings.embedding).length),
|
|
128
|
+
modelsMigrated,
|
|
129
|
+
completedAt: new Date().toISOString(),
|
|
130
|
+
verified: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
validateCompletedState(marker) {
|
|
135
|
+
try {
|
|
136
|
+
const registry = this.atomic.readJsonStrict(this.layout.getProjectRegistryPath(), { category: 'project-registry' });
|
|
137
|
+
validateRegistry(registry);
|
|
138
|
+
const settings = this.atomic.readJsonStrict(this.layout.getSettingsPath(), { category: 'settings' });
|
|
139
|
+
validateSettings(settings);
|
|
140
|
+
for (const projectId of registry.projectOrder) {
|
|
141
|
+
validateProjectConfig(this.atomic.readJsonStrict(this.layout.getProjectConfigPath(projectId), { category: 'project-config' }), projectId);
|
|
142
|
+
validateProjectState(this.atomic.readJsonStrict(this.layout.getProjectStatePath(projectId), { category: 'project-state' }));
|
|
143
|
+
}
|
|
144
|
+
// marker.projectCount is a migration-time snapshot only. Imports and
|
|
145
|
+
// deletions legitimately change the live registry count afterwards;
|
|
146
|
+
// comparing them here rejected every restart after the first legal
|
|
147
|
+
// project removal (completion-project-count-mismatch false positive).
|
|
148
|
+
return { ok: true, registry, settings };
|
|
149
|
+
} catch (error) { return { ok: false, reason: 'completion-state-invalid', error }; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
discover() {
|
|
153
|
+
const paths = LEGACY_ASSETS.map(asset => assetPath(this.legacyDataDir, asset)).filter(file => fs.existsSync(file));
|
|
154
|
+
const knowledgeStore = safeReadJson(path.join(this.legacyDataDir, 'knowledge-store.json'), {});
|
|
155
|
+
const indexCandidates = [
|
|
156
|
+
path.join(this.legacyDataDir, 'knowledge.lancedb'),
|
|
157
|
+
knowledgeStore && knowledgeStore.rootPath ? path.join(path.resolve(knowledgeStore.rootPath), '.project-knowledge', 'knowledge.lancedb') : '',
|
|
158
|
+
].filter(Boolean);
|
|
159
|
+
const indexPath = indexCandidates.find(candidate => fs.existsSync(candidate)) || '';
|
|
160
|
+
if (indexPath) paths.push(indexPath);
|
|
161
|
+
return {
|
|
162
|
+
legacyProjectsPath: path.join(this.legacyDataDir, 'projects.json'),
|
|
163
|
+
knowledgeStore,
|
|
164
|
+
indexPath,
|
|
165
|
+
modelsPath: assetPath(this.legacyDataDir, getLegacyAsset('models')),
|
|
166
|
+
sources: sourceSnapshot(paths),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
mergeLegacySettings(discovery) {
|
|
171
|
+
const settings = defaultSettings();
|
|
172
|
+
const read = name => safeReadJson(path.join(this.legacyDataDir, name), null);
|
|
173
|
+
const knowledge = read('knowledge-store.json');
|
|
174
|
+
const ai = read('ai-profiles.json');
|
|
175
|
+
const embedding = read('embedding-config.json');
|
|
176
|
+
const logging = read('logging.json');
|
|
177
|
+
const prompts = read('claude-prompts.json');
|
|
178
|
+
const github = read('github-team.json');
|
|
179
|
+
const providers = read('team-git-providers.json');
|
|
180
|
+
if (knowledge && typeof knowledge.rootPath === 'string') settings.knowledge.rootPath = knowledge.rootPath;
|
|
181
|
+
if (ai && typeof ai === 'object') settings.ai = ai;
|
|
182
|
+
if (embedding && typeof embedding === 'object') settings.embedding = embedding;
|
|
183
|
+
if (logging && typeof logging === 'object') {
|
|
184
|
+
if (Array.isArray(logging.levels)) settings.logging.levels = logging.levels;
|
|
185
|
+
settings.legacyExtensions.logging = { ...logging };
|
|
186
|
+
}
|
|
187
|
+
if (prompts && typeof prompts === 'object') settings.promptOverrides = prompts;
|
|
188
|
+
if (github || providers) settings.integrations = { ...(settings.integrations || {}), ...(github ? { githubTeam: github } : {}), ...(providers ? { teamGitProviders: providers } : {}) };
|
|
189
|
+
settings.updatedAt = new Date().toISOString();
|
|
190
|
+
return validateSettings(settings);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
buildProjectRecords(legacyProjects, settings) {
|
|
194
|
+
const registry = emptyRegistry();
|
|
195
|
+
const records = [];
|
|
196
|
+
for (const [slug, rawConfig] of Object.entries(legacyProjects || {})) {
|
|
197
|
+
const legacy = rawConfig && rawConfig.config && typeof rawConfig.config === 'object' ? rawConfig.config : (rawConfig || {});
|
|
198
|
+
const projectId = deterministicProjectId(slug, legacy);
|
|
199
|
+
const knowledgePath = legacy.knowledgePath || legacy.kbPath || (settings.knowledge.rootPath ? path.join(path.resolve(settings.knowledge.rootPath), slug) : path.join(this.legacyDataDir, 'projects', slug));
|
|
200
|
+
const config = defaultProjectConfig(projectId, {
|
|
201
|
+
displayName: legacy.displayName || slug,
|
|
202
|
+
storageName: legacy.storageName || `${slug}-${projectId.slice(-6)}`,
|
|
203
|
+
repoPath: legacy.repoPath || legacy.gitPath || legacy.localPath || this.legacyDataDir,
|
|
204
|
+
knowledgePath,
|
|
205
|
+
enabled: legacy.enabled !== false,
|
|
206
|
+
createdAt: legacy.createdAt || new Date(0).toISOString(),
|
|
207
|
+
teamBinding: legacy.teamBinding || (legacy.teamKbId ? { teamKbId: legacy.teamKbId } : null),
|
|
208
|
+
aiProfileId: legacy.aiProfileId || null,
|
|
209
|
+
knowledgeLanguage: legacy.knowledgeLanguage || 'zh-CN',
|
|
210
|
+
relatedProjectIds: legacy.relatedProjectIds || [],
|
|
211
|
+
repoIdentity: legacy.repoIdentity || null,
|
|
212
|
+
legacyExtensions: { slug, sourceSchema: legacy.schema || '' },
|
|
213
|
+
});
|
|
214
|
+
const hasTracking = Boolean(legacy.trackingStartCommit || legacy.lastAnalyzedCommit);
|
|
215
|
+
const state = defaultProjectState({
|
|
216
|
+
trackingStartCommit: legacy.trackingStartCommit || null,
|
|
217
|
+
lastAnalyzedCommit: legacy.lastAnalyzedCommit || null,
|
|
218
|
+
trackingMode: hasTracking ? 'normal' : (legacy.repoStatus === 'empty-repo' ? 'empty-repo' : 'normal'),
|
|
219
|
+
hook: { managedVersion: 0, migrationVersion: 0, lastVerifiedAt: '' },
|
|
220
|
+
});
|
|
221
|
+
validateProjectConfig(config, projectId);
|
|
222
|
+
validateProjectState(state);
|
|
223
|
+
registry.projectOrder.push(projectId);
|
|
224
|
+
registry.projects[projectId] = { createdAt: config.createdAt, displayNameSnapshot: config.displayName };
|
|
225
|
+
records.push({ slug, projectId, config, state });
|
|
226
|
+
}
|
|
227
|
+
registry.updatedAt = new Date().toISOString();
|
|
228
|
+
validateRegistry(registry);
|
|
229
|
+
return { registry, records };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
injectFault(options, stage) {
|
|
233
|
+
if (options && options.faultAt === stage) throw new Error(`injected migration fault: ${stage}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async migrateIfNeeded(options = {}) {
|
|
237
|
+
const existingCompletion = this.completion();
|
|
238
|
+
if (existingCompletion) {
|
|
239
|
+
const verified = this.validateCompletedState(existingCompletion);
|
|
240
|
+
if (!verified.ok) return { ok: false, migrated: false, completed: false, requiresManualRecovery: true, reason: verified.reason, error: verified.error };
|
|
241
|
+
if (existingCompletion.schema === 'layout-migration-completion/v2') return { ok: true, migrated: false, completed: true, marker: existingCompletion };
|
|
242
|
+
const marker = this.completionMarker({ runId: existingCompletion.runId || 'v1-marker-upgrade', registry: verified.registry, settings: verified.settings, modelsMigrated: fs.existsSync(this.layout.getCachePath('models')) });
|
|
243
|
+
this.atomic.writeJsonAtomic(this.layout.getMigrationCompletionPath(), marker);
|
|
244
|
+
return { ok: true, migrated: false, completed: true, marker };
|
|
245
|
+
}
|
|
246
|
+
const state = classifyDataState(this.legacyDataDir);
|
|
247
|
+
if (state.state === STATES.FRESH) return { ok: true, migrated: false, completed: false, reason: 'fresh-install' };
|
|
248
|
+
if (state.state === STATES.CORRUPT || state.state === STATES.MIGRATION_INCOMPLETE) {
|
|
249
|
+
return { ok: false, migrated: false, completed: false, requiresManualRecovery: true, reason: state.state.toLowerCase() };
|
|
250
|
+
}
|
|
251
|
+
const discovery = this.discover();
|
|
252
|
+
this.injectFault(options, 'discovery');
|
|
253
|
+
// A user may have configured only AI/embedding/knowledge settings before
|
|
254
|
+
// ever importing a project. That is still legacy user data and must be
|
|
255
|
+
// migrated into an explicitly empty v2 registry, never treated as Fresh.
|
|
256
|
+
const legacyProjects = safeReadJson(discovery.legacyProjectsPath, {});
|
|
257
|
+
if (legacyProjects && legacyProjects.schema === SCHEMAS.projectRegistry && legacyProjects.schemaVersion === 2 && fs.existsSync(this.layout.getSettingsPath())) {
|
|
258
|
+
validateRegistry(legacyProjects);
|
|
259
|
+
const settings = this.atomic.readJsonStrict(this.layout.getSettingsPath(), { category: 'settings' });
|
|
260
|
+
validateSettings(settings);
|
|
261
|
+
const marker = this.completionMarker({ runId: 'validated-existing-v2', registry: legacyProjects, settings, modelsMigrated: fs.existsSync(this.layout.getCachePath('models')) });
|
|
262
|
+
this.atomic.writeJsonAtomic(this.layout.getMigrationCompletionPath(), marker);
|
|
263
|
+
return { ok: true, migrated: false, completed: true, marker };
|
|
264
|
+
}
|
|
265
|
+
if (state.state !== STATES.LEGACY) return { ok: false, migrated: false, completed: false, requiresManualRecovery: true, reason: 'legacy-state-not-migratable' };
|
|
266
|
+
|
|
267
|
+
const migrationId = options.migrationRunId || `layout-v2-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
268
|
+
const recoveryDir = this.layout.getRecoveryPath(migrationId);
|
|
269
|
+
const backupDir = path.join(recoveryDir, 'backup');
|
|
270
|
+
const stagingDir = path.join(recoveryDir, 'staging');
|
|
271
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
272
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
273
|
+
const journalPath = path.join(recoveryDir, 'journal.json');
|
|
274
|
+
const journal = {
|
|
275
|
+
schema: SCHEMAS.migrationJournal,
|
|
276
|
+
migrationId: SCHEMA_VERSIONS.layoutMigration,
|
|
277
|
+
runId: migrationId,
|
|
278
|
+
phase: 'discovered',
|
|
279
|
+
startedAt: new Date().toISOString(),
|
|
280
|
+
sources: discovery.sources,
|
|
281
|
+
activated: [],
|
|
282
|
+
};
|
|
283
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
284
|
+
const legacyRegistryBackup = path.join(backupDir, 'projects.json');
|
|
285
|
+
try {
|
|
286
|
+
for (const entry of discovery.sources) {
|
|
287
|
+
const relative = path.relative(this.legacyDataDir, entry.path);
|
|
288
|
+
const safeRelative = relative.startsWith('..') || path.isAbsolute(relative)
|
|
289
|
+
? path.join('external', hashBuffer(Buffer.from(entry.path)).slice(0, 16), path.basename(entry.path))
|
|
290
|
+
: relative;
|
|
291
|
+
copyRecursive(entry.path, path.join(backupDir, safeRelative));
|
|
292
|
+
}
|
|
293
|
+
journal.phase = 'backed-up';
|
|
294
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
295
|
+
this.injectFault(options, 'backup');
|
|
296
|
+
|
|
297
|
+
const settings = this.mergeLegacySettings(discovery);
|
|
298
|
+
const projectData = this.buildProjectRecords(legacyProjects, settings);
|
|
299
|
+
const staged = [];
|
|
300
|
+
const stageJson = (relative, value) => {
|
|
301
|
+
const stagedPath = path.join(stagingDir, relative);
|
|
302
|
+
this.atomic.writeJsonAtomic(stagedPath, value);
|
|
303
|
+
staged.push({ relative, kind: 'file', hash: hashFile(stagedPath) });
|
|
304
|
+
};
|
|
305
|
+
stageJson('settings.json', settings);
|
|
306
|
+
for (const record of projectData.records) {
|
|
307
|
+
stageJson(path.join('projects', record.projectId, 'config.json'), record.config);
|
|
308
|
+
stageJson(path.join('projects', record.projectId, 'state.json'), record.state);
|
|
309
|
+
}
|
|
310
|
+
stageJson('projects.json', projectData.registry);
|
|
311
|
+
let modelsMigrated = false;
|
|
312
|
+
if (fs.existsSync(discovery.modelsPath)) {
|
|
313
|
+
const modelTarget = this.layout.getCachePath('models');
|
|
314
|
+
if (fs.existsSync(modelTarget)) {
|
|
315
|
+
if (directoryDigest(discovery.modelsPath) !== directoryDigest(modelTarget)) {
|
|
316
|
+
throw new DomainError('MIGRATION_TARGET_CONFLICT', 'Legacy model cache conflicts with the current model cache.', { status: 409, details: { category: 'cache/models' } });
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
const relative = path.join('cache', 'models');
|
|
320
|
+
copyRecursive(discovery.modelsPath, path.join(stagingDir, relative));
|
|
321
|
+
staged.push({ relative, kind: 'directory', hash: directoryDigest(path.join(stagingDir, relative)) });
|
|
322
|
+
modelsMigrated = true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (discovery.indexPath) {
|
|
326
|
+
const relative = path.join('index', 'knowledge.lancedb');
|
|
327
|
+
copyRecursive(discovery.indexPath, path.join(stagingDir, relative));
|
|
328
|
+
staged.push({ relative, kind: 'directory', hash: '' });
|
|
329
|
+
}
|
|
330
|
+
journal.staged = staged;
|
|
331
|
+
journal.projectMap = Object.fromEntries(projectData.records.map(record => [record.slug, record.projectId]));
|
|
332
|
+
journal.phase = 'staged';
|
|
333
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
334
|
+
this.injectFault(options, 'staging');
|
|
335
|
+
|
|
336
|
+
if (!verifySourceSnapshot(discovery.sources)) throw new DomainError('MIGRATION_SOURCE_CHANGED', 'Migration source changed while staging.', { status: 409 });
|
|
337
|
+
validateSettings(JSON.parse(fs.readFileSync(path.join(stagingDir, 'settings.json'), 'utf8')));
|
|
338
|
+
validateRegistry(JSON.parse(fs.readFileSync(path.join(stagingDir, 'projects.json'), 'utf8')));
|
|
339
|
+
for (const record of projectData.records) {
|
|
340
|
+
validateProjectConfig(JSON.parse(fs.readFileSync(path.join(stagingDir, 'projects', record.projectId, 'config.json'), 'utf8')), record.projectId);
|
|
341
|
+
validateProjectState(JSON.parse(fs.readFileSync(path.join(stagingDir, 'projects', record.projectId, 'state.json'), 'utf8')));
|
|
342
|
+
}
|
|
343
|
+
if (discovery.indexPath) {
|
|
344
|
+
const indexResult = await this.indexValidator(path.join(stagingDir, 'index', 'knowledge.lancedb'));
|
|
345
|
+
if (!indexResult || indexResult.ok !== true) throw new DomainError('MIGRATION_FAILED', 'Staged index validation failed.', { status: 500 });
|
|
346
|
+
}
|
|
347
|
+
journal.phase = 'validated';
|
|
348
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
349
|
+
this.injectFault(options, 'validation');
|
|
350
|
+
|
|
351
|
+
const activationOrder = staged.filter(item => item.relative !== 'projects.json');
|
|
352
|
+
const activationBackupDir = path.join(backupDir, 'activation-targets');
|
|
353
|
+
journal.activationTargets = activationOrder.map(item => {
|
|
354
|
+
const target = path.join(this.layout.getDataDir(), item.relative);
|
|
355
|
+
const existed = fs.existsSync(target);
|
|
356
|
+
if (item.kind === 'directory' && existed) {
|
|
357
|
+
throw new DomainError('MIGRATION_TARGET_CONFLICT', 'Migration target already exists.', { status: 409, details: { category: item.relative } });
|
|
358
|
+
}
|
|
359
|
+
if (existed) copyRecursive(target, path.join(activationBackupDir, item.relative));
|
|
360
|
+
return { relative: item.relative, kind: item.kind, existed };
|
|
361
|
+
});
|
|
362
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
363
|
+
for (const item of activationOrder) {
|
|
364
|
+
const source = path.join(stagingDir, item.relative);
|
|
365
|
+
const target = path.join(this.layout.getDataDir(), item.relative);
|
|
366
|
+
if (item.kind === 'directory') {
|
|
367
|
+
copyRecursive(source, target);
|
|
368
|
+
} else {
|
|
369
|
+
this.atomic.writeFileAtomic(target, fs.readFileSync(source));
|
|
370
|
+
}
|
|
371
|
+
journal.activated.push(item.relative);
|
|
372
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
373
|
+
}
|
|
374
|
+
this.atomic.writeFileAtomic(this.layout.getProjectRegistryPath(), fs.readFileSync(path.join(stagingDir, 'projects.json')));
|
|
375
|
+
journal.activated.push('projects.json');
|
|
376
|
+
journal.phase = 'activated';
|
|
377
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
378
|
+
this.injectFault(options, 'activation');
|
|
379
|
+
|
|
380
|
+
validateSettings(this.atomic.readJsonStrict(this.layout.getSettingsPath(), { category: 'settings' }));
|
|
381
|
+
const openedRegistry = this.atomic.readJsonStrict(this.layout.getProjectRegistryPath(), { category: 'project-registry' });
|
|
382
|
+
validateRegistry(openedRegistry);
|
|
383
|
+
for (const projectId of openedRegistry.projectOrder) {
|
|
384
|
+
validateProjectConfig(this.atomic.readJsonStrict(this.layout.getProjectConfigPath(projectId), { category: 'project-config' }), projectId);
|
|
385
|
+
validateProjectState(this.atomic.readJsonStrict(this.layout.getProjectStatePath(projectId), { category: 'project-state' }));
|
|
386
|
+
}
|
|
387
|
+
journal.phase = 'open-verified';
|
|
388
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
389
|
+
this.injectFault(options, 'open-verification');
|
|
390
|
+
|
|
391
|
+
const marker = this.completionMarker({ runId: migrationId, registry: openedRegistry, settings: this.atomic.readJsonStrict(this.layout.getSettingsPath(), { category: 'settings' }), modelsMigrated });
|
|
392
|
+
this.atomic.writeJsonAtomic(this.layout.getMigrationCompletionPath(), marker);
|
|
393
|
+
journal.phase = 'completed';
|
|
394
|
+
journal.completedAt = marker.completedAt;
|
|
395
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
396
|
+
return { ok: true, migrated: true, completed: true, marker, recoveryDir, projectMap: journal.projectMap };
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const rollbackErrors = [];
|
|
399
|
+
for (const targetInfo of [...(journal.activationTargets || [])].reverse()) {
|
|
400
|
+
const target = path.join(this.layout.getDataDir(), targetInfo.relative);
|
|
401
|
+
try {
|
|
402
|
+
if (targetInfo.existed) {
|
|
403
|
+
const backup = path.join(backupDir, 'activation-targets', targetInfo.relative);
|
|
404
|
+
if (targetInfo.kind === 'directory') {
|
|
405
|
+
removeOwnedPath(target);
|
|
406
|
+
copyRecursive(backup, target);
|
|
407
|
+
} else {
|
|
408
|
+
this.atomic.writeFileAtomic(target, fs.readFileSync(backup));
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
removeOwnedPath(target);
|
|
412
|
+
pruneEmptyParents(target, this.layout.getDataDir());
|
|
413
|
+
}
|
|
414
|
+
} catch (rollbackError) {
|
|
415
|
+
rollbackErrors.push({ target: targetInfo.relative, message: String(rollbackError.message || rollbackError) });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
if (fs.existsSync(legacyRegistryBackup)) {
|
|
420
|
+
this.atomic.writeFileAtomic(this.layout.getProjectRegistryPath(), fs.readFileSync(legacyRegistryBackup));
|
|
421
|
+
}
|
|
422
|
+
} catch (rollbackError) {
|
|
423
|
+
rollbackErrors.push({ target: 'projects.json', message: String(rollbackError.message || rollbackError) });
|
|
424
|
+
}
|
|
425
|
+
journal.phase = 'failed';
|
|
426
|
+
journal.failedAt = new Date().toISOString();
|
|
427
|
+
journal.error = { name: error.name || 'Error', code: error.code || '', message: String(error.message || error) };
|
|
428
|
+
journal.rollback = { ok: rollbackErrors.length === 0, errors: rollbackErrors };
|
|
429
|
+
this.atomic.writeJsonAtomic(journalPath, journal);
|
|
430
|
+
this.logger.error('migration.failed', { error, recoveryDir, rollbackErrors });
|
|
431
|
+
return { ok: false, migrated: false, completed: false, useLegacy: rollbackErrors.length === 0, recoveryDir, error, rollbackErrors };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
MigrationService,
|
|
438
|
+
deterministicProjectId,
|
|
439
|
+
hashFile,
|
|
440
|
+
sourceSnapshot,
|
|
441
|
+
verifySourceSnapshot,
|
|
442
|
+
};
|